Files
billo-release-agent/tests/api/test_status_with_auth.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

167 lines
6.1 KiB
Python

"""Tests for status/manual endpoints with operator token authentication.
Phase 5 - Step 3: Verifies that POST /manual/* require operator token
when configured. GET endpoints are not protected.
Written FIRST (TDD RED phase).
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.status import router as status_router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app(operator_token: str = "") -> FastAPI:
"""Return a FastAPI app with status router and configurable operator token."""
app = FastAPI()
app.include_router(status_router)
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = operator_token
mock_staging_store = MagicMock()
mock_staging_store.list_versions = AsyncMock(return_value=[])
mock_staging_store.load = AsyncMock(return_value=None)
mock_pool = MagicMock()
app.state.settings = mock_settings
app.state.graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
app.state.tool_clients = MagicMock()
app.state.staging_store = mock_staging_store
app.state.db_pool = mock_pool
app.state.background_tasks = set()
app.state.started_at = datetime.now(tz=timezone.utc)
return app
# ---------------------------------------------------------------------------
# POST /manual/pr/{pr_id} with auth
# ---------------------------------------------------------------------------
class TestManualPrTriggerWithAuth:
def test_valid_token_allows_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/pr/42",
headers={"X-Operator-Token": "secure-token"},
)
assert response.status_code == 202
def test_missing_token_rejects_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert response.status_code == 401
def test_wrong_token_rejects_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/pr/42",
headers={"X-Operator-Token": "bad-token"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert response.status_code == 202
# ---------------------------------------------------------------------------
# POST /manual/release with auth
# ---------------------------------------------------------------------------
class TestManualReleaseTriggerWithAuth:
def test_valid_token_allows_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
headers={"X-Operator-Token": "secure-token"},
)
assert response.status_code == 202
def test_missing_token_rejects_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
assert response.status_code == 401
def test_wrong_token_rejects_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
headers={"X-Operator-Token": "wrong"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
assert response.status_code == 202
# ---------------------------------------------------------------------------
# GET /status, /releases, /staging do NOT require auth
# ---------------------------------------------------------------------------
class TestReadEndpointsNoAuth:
def test_get_status_no_token_needed(self) -> None:
"""GET /status should never require auth."""
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/status")
assert response.status_code == 200
def test_get_releases_no_token_needed(self) -> None:
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/releases/my-repo")
assert response.status_code == 200
def test_get_staging_no_token_needed(self) -> None:
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/staging?repo=my-repo")
assert response.status_code == 200