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.
167 lines
6.1 KiB
Python
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
|