"""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