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.
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""Tests for approvals endpoints with operator token authentication.
|
|
|
|
Phase 5 - Step 3: Verifies that POST /approvals/{thread_id} and
|
|
GET /approvals/pending require operator token when configured.
|
|
Written FIRST (TDD RED phase).
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from release_agent.api.approvals import router as approvals_router
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_test_app(operator_token: str = "") -> FastAPI:
|
|
"""Return a FastAPI app with approvals router and configurable operator token."""
|
|
app = FastAPI()
|
|
app.include_router(approvals_router)
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.operator_token.get_secret_value.return_value = operator_token
|
|
|
|
mock_pool = MagicMock()
|
|
mock_conn = AsyncMock()
|
|
mock_cursor = AsyncMock()
|
|
mock_cursor.fetchall = AsyncMock(return_value=[])
|
|
mock_cursor.fetchone = AsyncMock(return_value=("pr_completed",))
|
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
mock_conn.__aexit__ = AsyncMock(return_value=False)
|
|
mock_pool.connection = MagicMock(return_value=mock_conn)
|
|
|
|
app.state.settings = mock_settings
|
|
app.state.graphs = {
|
|
"pr_completed": MagicMock(),
|
|
"release": MagicMock(),
|
|
}
|
|
app.state.tool_clients = MagicMock()
|
|
app.state.db_pool = mock_pool
|
|
app.state.background_tasks = set()
|
|
|
|
return app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /approvals/{thread_id} with auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPostApprovalWithAuth:
|
|
def test_valid_token_allows_post_approval(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with patch(
|
|
"release_agent.api.approvals._resume_graph", new_callable=AsyncMock
|
|
) as mock_resume:
|
|
mock_resume.return_value = {}
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/approvals/thread-123",
|
|
json={"decision": "merge"},
|
|
headers={"X-Operator-Token": "secret-token"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_missing_token_rejects_post_approval(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/approvals/thread-123",
|
|
json={"decision": "merge"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
def test_wrong_token_rejects_post_approval(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/approvals/thread-123",
|
|
json={"decision": "merge"},
|
|
headers={"X-Operator-Token": "wrong-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.approvals._resume_graph", new_callable=AsyncMock
|
|
) as mock_resume:
|
|
mock_resume.return_value = {}
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/approvals/thread-123",
|
|
json={"decision": "merge"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /approvals/pending with auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetPendingApprovalsWithAuth:
|
|
def test_valid_token_allows_get_pending(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/approvals/pending",
|
|
headers={"X-Operator-Token": "secret-token"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_missing_token_rejects_get_pending(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with TestClient(app) as client:
|
|
response = client.get("/approvals/pending")
|
|
assert response.status_code == 401
|
|
|
|
def test_wrong_token_rejects_get_pending(self) -> None:
|
|
app = _make_test_app(operator_token="secret-token")
|
|
with TestClient(app) as client:
|
|
response = client.get(
|
|
"/approvals/pending",
|
|
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 TestClient(app) as client:
|
|
response = client.get("/approvals/pending")
|
|
assert response.status_code == 200
|