Files
billo-release-agent/tests/api/test_approvals_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

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