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.
This commit is contained in:
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
259
tests/api/test_approvals.py
Normal file
259
tests/api/test_approvals.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Tests for approvals endpoint. 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(
|
||||
*,
|
||||
interrupted_threads: list[dict] | None = None,
|
||||
graph_resume_result: dict | None = None,
|
||||
) -> FastAPI:
|
||||
"""Return a FastAPI app with mocked state for approvals tests."""
|
||||
app = FastAPI()
|
||||
app.include_router(approvals_router)
|
||||
|
||||
if interrupted_threads is None:
|
||||
interrupted_threads = []
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.operator_token.get_secret_value.return_value = ""
|
||||
mock_graphs = {
|
||||
"pr_completed": MagicMock(),
|
||||
"release": MagicMock(),
|
||||
}
|
||||
mock_clients = MagicMock()
|
||||
|
||||
# Mock pool that returns interrupted threads from DB
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
|
||||
rows = [
|
||||
(
|
||||
t["thread_id"],
|
||||
t.get("graph_name", "pr_completed"),
|
||||
t.get("interrupt_value", "Confirm?"),
|
||||
t.get("created_at", datetime.now(tz=timezone.utc)),
|
||||
t.get("repo_name"),
|
||||
t.get("pr_id"),
|
||||
t.get("version"),
|
||||
)
|
||||
for t in interrupted_threads
|
||||
]
|
||||
mock_cursor.fetchall = AsyncMock(return_value=rows)
|
||||
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 = mock_graphs
|
||||
app.state.tool_clients = mock_clients
|
||||
app.state.db_pool = mock_pool
|
||||
app.state.background_tasks = set()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /approvals/{thread_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPostApproval:
|
||||
def test_valid_merge_decision_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
|
||||
app.state.graphs["pr_completed"] = mock_graph
|
||||
|
||||
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
|
||||
mock_resume.return_value = {"messages": ["resumed"]}
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/approvals/thread-123",
|
||||
json={"decision": "merge"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["thread_id"] == "thread-123"
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
|
||||
def test_valid_cancel_decision_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
|
||||
mock_resume.return_value = {"messages": ["cancelled"]}
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/approvals/thread-456",
|
||||
json={"decision": "cancel"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_invalid_decision_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/approvals/thread-123",
|
||||
json={"decision": "invalid_decision"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_missing_decision_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/approvals/thread-123",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_response_contains_thread_id(self) -> None:
|
||||
app = _make_test_app()
|
||||
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/my-thread-id",
|
||||
json={"decision": "approve"},
|
||||
)
|
||||
assert response.json()["thread_id"] == "my-thread-id"
|
||||
|
||||
def test_approve_decision_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
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/t1",
|
||||
json={"decision": "approve"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_skip_decision_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
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/t1",
|
||||
json={"decision": "skip"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_trigger_decision_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
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/t1",
|
||||
json={"decision": "trigger"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /approvals/pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPendingApprovals:
|
||||
def test_empty_pending_returns_200(self) -> None:
|
||||
app = _make_test_app(interrupted_threads=[])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/approvals/pending")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_pending_approvals_list_structure(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
threads = [
|
||||
{
|
||||
"thread_id": "t1",
|
||||
"graph_name": "pr_completed",
|
||||
"interrupt_value": "Confirm merge?",
|
||||
"created_at": now,
|
||||
"repo_name": "my-repo",
|
||||
"pr_id": "42",
|
||||
"version": "v1.0.0",
|
||||
}
|
||||
]
|
||||
app = _make_test_app(interrupted_threads=threads)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/approvals/pending")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["items"][0]["thread_id"] == "t1"
|
||||
assert data["items"][0]["graph_name"] == "pr_completed"
|
||||
|
||||
def test_multiple_pending_approvals(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
threads = [
|
||||
{
|
||||
"thread_id": f"t{i}",
|
||||
"graph_name": "pr_completed",
|
||||
"interrupt_value": "Confirm?",
|
||||
"created_at": now,
|
||||
"repo_name": None,
|
||||
"pr_id": None,
|
||||
"version": None,
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
app = _make_test_app(interrupted_threads=threads)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/approvals/pending")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
def test_pending_approval_optional_fields_nullable(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
threads = [
|
||||
{
|
||||
"thread_id": "t1",
|
||||
"graph_name": "release",
|
||||
"interrupt_value": "Run release?",
|
||||
"created_at": now,
|
||||
"repo_name": None,
|
||||
"pr_id": None,
|
||||
"version": None,
|
||||
}
|
||||
]
|
||||
app = _make_test_app(interrupted_threads=threads)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/approvals/pending")
|
||||
item = response.json()["items"][0]
|
||||
assert item["repo_name"] is None
|
||||
assert item["pr_id"] is None
|
||||
assert item["version"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resume_graph helper function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResumeGraph:
|
||||
def test_resume_graph_callable(self) -> None:
|
||||
from release_agent.api.approvals import _resume_graph
|
||||
import inspect
|
||||
assert inspect.iscoroutinefunction(_resume_graph)
|
||||
139
tests/api/test_approvals_with_auth.py
Normal file
139
tests/api/test_approvals_with_auth.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""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
|
||||
149
tests/api/test_dependencies.py
Normal file
149
tests/api/test_dependencies.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for API FastAPI dependencies. Written FIRST (TDD RED phase)."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from release_agent.api.dependencies import (
|
||||
get_db_pool,
|
||||
get_graphs,
|
||||
get_settings,
|
||||
get_staging_store,
|
||||
get_tool_clients,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_app_with_state(**state_kwargs) -> FastAPI:
|
||||
"""Return a minimal FastAPI app with app.state attributes set."""
|
||||
app = FastAPI()
|
||||
for key, value in state_kwargs.items():
|
||||
setattr(app.state, key, value)
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetSettings:
|
||||
def test_returns_settings_from_state(self) -> None:
|
||||
mock_settings = MagicMock()
|
||||
app = _make_app_with_state(settings=mock_settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# We test the dependency directly by simulating a request
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
result = get_settings(request)
|
||||
assert result is mock_settings
|
||||
|
||||
def test_raises_when_settings_missing(self) -> None:
|
||||
app = FastAPI() # no state.settings
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
get_settings(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_graphs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetGraphs:
|
||||
def test_returns_graphs_from_state(self) -> None:
|
||||
mock_graphs = {"pr_completed": MagicMock(), "release": MagicMock()}
|
||||
app = _make_app_with_state(graphs=mock_graphs)
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
result = get_graphs(request)
|
||||
assert result is mock_graphs
|
||||
|
||||
def test_raises_when_graphs_missing(self) -> None:
|
||||
app = FastAPI()
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
get_graphs(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_tool_clients
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetToolClients:
|
||||
def test_returns_tool_clients_from_state(self) -> None:
|
||||
mock_clients = MagicMock()
|
||||
app = _make_app_with_state(tool_clients=mock_clients)
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
result = get_tool_clients(request)
|
||||
assert result is mock_clients
|
||||
|
||||
def test_raises_when_tool_clients_missing(self) -> None:
|
||||
app = FastAPI()
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
get_tool_clients(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_staging_store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStagingStore:
|
||||
def test_returns_staging_store_from_state(self) -> None:
|
||||
mock_store = MagicMock()
|
||||
app = _make_app_with_state(staging_store=mock_store)
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
result = get_staging_store(request)
|
||||
assert result is mock_store
|
||||
|
||||
def test_raises_when_staging_store_missing(self) -> None:
|
||||
app = FastAPI()
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
get_staging_store(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_db_pool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetDbPool:
|
||||
def test_returns_db_pool_from_state(self) -> None:
|
||||
mock_pool = MagicMock()
|
||||
app = _make_app_with_state(db_pool=mock_pool)
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
result = get_db_pool(request)
|
||||
assert result is mock_pool
|
||||
|
||||
def test_raises_when_db_pool_missing(self) -> None:
|
||||
app = FastAPI()
|
||||
|
||||
request = MagicMock()
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
get_db_pool(request)
|
||||
446
tests/api/test_internals.py
Normal file
446
tests/api/test_internals.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""Tests for internal async helper functions.
|
||||
|
||||
Tests _run_graph, _upsert_thread, _resume_graph, and exception handlers.
|
||||
Written FIRST then verified (TDD GREEN phase for internal helpers).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upsert_thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpsertThread:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_thread_executes_sql(self) -> None:
|
||||
from release_agent.api.webhooks import _upsert_thread
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _upsert_thread(
|
||||
mock_pool,
|
||||
thread_id="t1",
|
||||
thread_status="running",
|
||||
state={"repo_name": "my-repo"},
|
||||
)
|
||||
|
||||
mock_cursor.execute.assert_called_once()
|
||||
args = mock_cursor.execute.call_args[0]
|
||||
assert "agent_threads" in args[0]
|
||||
assert args[1][0] == "t1"
|
||||
assert args[1][4] == "running"
|
||||
# state is JSON-encoded
|
||||
state_json = json.loads(args[1][5])
|
||||
assert state_json["repo_name"] == "my-repo"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_thread_completed_status(self) -> None:
|
||||
from release_agent.api.webhooks import _upsert_thread
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _upsert_thread(
|
||||
mock_pool,
|
||||
thread_id="t2",
|
||||
thread_status="completed",
|
||||
state={},
|
||||
)
|
||||
|
||||
args = mock_cursor.execute.call_args[0]
|
||||
assert args[1][4] == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_thread_failed_status(self) -> None:
|
||||
from release_agent.api.webhooks import _upsert_thread
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _upsert_thread(
|
||||
mock_pool,
|
||||
thread_id="t3",
|
||||
thread_status="failed",
|
||||
state={"errors": ["something went wrong"]},
|
||||
)
|
||||
|
||||
args = mock_cursor.execute.call_args[0]
|
||||
assert args[1][4] == "failed"
|
||||
state_json = json.loads(args[1][5])
|
||||
assert state_json["errors"] == ["something went wrong"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_graph tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunGraph:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_graph_success_upserts_completed(self) -> None:
|
||||
from release_agent.api.webhooks import _run_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _run_graph(
|
||||
graph=mock_graph,
|
||||
initial_state={"repo_name": "test"},
|
||||
thread_id="t1",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
# Should have been called with "running" then "completed"
|
||||
calls = mock_cursor.execute.call_args_list
|
||||
assert len(calls) == 2
|
||||
# First call: "running", second call: "completed"
|
||||
assert calls[0][0][1][4] == "running"
|
||||
assert calls[1][0][1][4] == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_graph_failure_upserts_failed(self) -> None:
|
||||
from release_agent.api.webhooks import _run_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("graph crashed"))
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _run_graph(
|
||||
graph=mock_graph,
|
||||
initial_state={"repo_name": "test"},
|
||||
thread_id="t-fail",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
calls = mock_cursor.execute.call_args_list
|
||||
# First call: "running", second call: "failed"
|
||||
assert calls[0][0][1][4] == "running"
|
||||
assert calls[1][0][1][4] == "failed"
|
||||
# State should contain errors
|
||||
failed_state = json.loads(calls[1][0][1][5])
|
||||
assert "errors" in failed_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_graph_invokes_with_correct_config(self) -> None:
|
||||
from release_agent.api.webhooks import _run_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={})
|
||||
mock_clients = MagicMock()
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _run_graph(
|
||||
graph=mock_graph,
|
||||
initial_state={"repo_name": "test"},
|
||||
thread_id="t-config",
|
||||
tool_clients=mock_clients,
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
call_args = mock_graph.ainvoke.call_args
|
||||
config = call_args[1]["config"]
|
||||
assert config["configurable"]["thread_id"] == "t-config"
|
||||
assert config["configurable"]["clients"] is mock_clients
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resume_graph tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResumeGraphInternal:
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_graph_success(self) -> None:
|
||||
from release_agent.api.approvals import _resume_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
result = await _resume_graph(
|
||||
graph=mock_graph,
|
||||
thread_id="t1",
|
||||
decision="merge",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
mock_graph.ainvoke.assert_called_once()
|
||||
# Verify the decision was passed
|
||||
call_args = mock_graph.ainvoke.call_args
|
||||
assert call_args[0][0]["decision"] == "merge"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_graph_failure_re_raises(self) -> None:
|
||||
from release_agent.api.approvals import _resume_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("resume failed"))
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
with pytest.raises(RuntimeError, match="resume failed"):
|
||||
await _resume_graph(
|
||||
graph=mock_graph,
|
||||
thread_id="t1",
|
||||
decision="cancel",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_graph_upserts_completed_on_success(self) -> None:
|
||||
from release_agent.api.approvals import _resume_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await _resume_graph(
|
||||
graph=mock_graph,
|
||||
thread_id="t-success",
|
||||
decision="approve",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
# The last execute call should be "completed"
|
||||
last_call = mock_cursor.execute.call_args_list[-1]
|
||||
assert last_call[0][1][4] == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_graph_upserts_failed_on_exception(self) -> None:
|
||||
from release_agent.api.approvals import _resume_graph
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(side_effect=ValueError("bad"))
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await _resume_graph(
|
||||
graph=mock_graph,
|
||||
thread_id="t-fail",
|
||||
decision="skip",
|
||||
tool_clients=MagicMock(),
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
last_call = mock_cursor.execute.call_args_list[-1]
|
||||
assert last_call[0][1][4] == "failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_graph_in_background tests (main.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunGraphInBackground:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_with_db_pool(self) -> None:
|
||||
from release_agent.main import run_graph_in_background
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={"done": True})
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await run_graph_in_background(
|
||||
graph=mock_graph,
|
||||
initial_state={"repo_name": "test"},
|
||||
thread_id="t-bg",
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
calls = mock_cursor.execute.call_args_list
|
||||
assert calls[0][0][1][4] == "running"
|
||||
assert calls[1][0][1][4] == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_with_db_pool(self) -> None:
|
||||
from release_agent.main import run_graph_in_background
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("bg failed"))
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
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)
|
||||
|
||||
await run_graph_in_background(
|
||||
graph=mock_graph,
|
||||
initial_state={},
|
||||
thread_id="t-bg-fail",
|
||||
db_pool=mock_pool,
|
||||
)
|
||||
|
||||
last_call = mock_cursor.execute.call_args_list[-1]
|
||||
assert last_call[0][1][4] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_without_db_pool(self) -> None:
|
||||
"""run_graph_in_background works even without a db_pool."""
|
||||
from release_agent.main import run_graph_in_background
|
||||
|
||||
mock_graph = AsyncMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value={})
|
||||
|
||||
# Should not raise even with no db_pool
|
||||
await run_graph_in_background(
|
||||
graph=mock_graph,
|
||||
initial_state={},
|
||||
thread_id="t-no-pool",
|
||||
db_pool=None,
|
||||
)
|
||||
|
||||
mock_graph.ainvoke.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception handler tests (main.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExceptionHandlerFunctions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_agent_error_handler_returns_500(self) -> None:
|
||||
from release_agent.main import _release_agent_error_handler
|
||||
from release_agent.exceptions import ServiceError
|
||||
|
||||
request = MagicMock()
|
||||
exc = ServiceError(service="azdo", status_code=503, detail="unavailable")
|
||||
|
||||
response = await _release_agent_error_handler(request, exc)
|
||||
|
||||
assert response.status_code == 500
|
||||
body = json.loads(response.body)
|
||||
assert body["error"] == "ServiceError"
|
||||
assert "unavailable" in body["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_error_handler_returns_500(self) -> None:
|
||||
from release_agent.main import _generic_error_handler
|
||||
|
||||
request = MagicMock()
|
||||
exc = ValueError("something generic")
|
||||
|
||||
response = await _generic_error_handler(request, exc)
|
||||
|
||||
assert response.status_code == 500
|
||||
body = json.loads(response.body)
|
||||
assert body["error"] == "InternalServerError"
|
||||
assert "An unexpected error occurred" in body["detail"]
|
||||
294
tests/api/test_models.py
Normal file
294
tests/api/test_models.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Tests for API request/response models. Written FIRST (TDD RED phase)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from release_agent.api.models import (
|
||||
ApprovalDecision,
|
||||
ApprovalResponse,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
ManualReleaseRequest,
|
||||
ManualTriggerResponse,
|
||||
PendingApproval,
|
||||
PendingApprovalsResponse,
|
||||
ReleaseVersionListResponse,
|
||||
StagingResponse,
|
||||
WebhookResponse,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebhookResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebhookResponse:
|
||||
def test_valid_construction(self) -> None:
|
||||
resp = WebhookResponse(thread_id="thread-123", message="scheduled")
|
||||
assert resp.thread_id == "thread-123"
|
||||
assert resp.message == "scheduled"
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = WebhookResponse(thread_id="t1", message="ok")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.thread_id = "other" # type: ignore[misc]
|
||||
|
||||
def test_missing_thread_id_raises(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WebhookResponse(message="ok") # type: ignore[call-arg]
|
||||
|
||||
def test_missing_message_raises(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WebhookResponse(thread_id="t1") # type: ignore[call-arg]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ApprovalDecision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApprovalDecision:
|
||||
def test_merge_decision(self) -> None:
|
||||
d = ApprovalDecision(decision="merge")
|
||||
assert d.decision == "merge"
|
||||
|
||||
def test_cancel_decision(self) -> None:
|
||||
d = ApprovalDecision(decision="cancel")
|
||||
assert d.decision == "cancel"
|
||||
|
||||
def test_approve_decision(self) -> None:
|
||||
d = ApprovalDecision(decision="approve")
|
||||
assert d.decision == "approve"
|
||||
|
||||
def test_skip_decision(self) -> None:
|
||||
d = ApprovalDecision(decision="skip")
|
||||
assert d.decision == "skip"
|
||||
|
||||
def test_trigger_decision(self) -> None:
|
||||
d = ApprovalDecision(decision="trigger")
|
||||
assert d.decision == "trigger"
|
||||
|
||||
def test_invalid_decision_raises(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ApprovalDecision(decision="invalid") # type: ignore[arg-type]
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
d = ApprovalDecision(decision="merge")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
d.decision = "cancel" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ApprovalResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApprovalResponse:
|
||||
def test_valid_construction(self) -> None:
|
||||
resp = ApprovalResponse(
|
||||
thread_id="t1", status="resumed", message="Graph resumed"
|
||||
)
|
||||
assert resp.thread_id == "t1"
|
||||
assert resp.status == "resumed"
|
||||
assert resp.message == "Graph resumed"
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = ApprovalResponse(thread_id="t1", status="ok", message="m")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.status = "bad" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PendingApproval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPendingApproval:
|
||||
def test_full_construction(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
pa = PendingApproval(
|
||||
thread_id="t1",
|
||||
graph_name="pr_completed",
|
||||
interrupt_value="Confirm merge?",
|
||||
created_at=now,
|
||||
repo_name="my-repo",
|
||||
pr_id="42",
|
||||
version="v1.2.3",
|
||||
)
|
||||
assert pa.thread_id == "t1"
|
||||
assert pa.graph_name == "pr_completed"
|
||||
assert pa.repo_name == "my-repo"
|
||||
assert pa.pr_id == "42"
|
||||
assert pa.version == "v1.2.3"
|
||||
|
||||
def test_optional_fields_none(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
pa = PendingApproval(
|
||||
thread_id="t1",
|
||||
graph_name="release",
|
||||
interrupt_value="Confirm?",
|
||||
created_at=now,
|
||||
)
|
||||
assert pa.repo_name is None
|
||||
assert pa.pr_id is None
|
||||
assert pa.version is None
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
pa = PendingApproval(
|
||||
thread_id="t1",
|
||||
graph_name="g",
|
||||
interrupt_value="v",
|
||||
created_at=now,
|
||||
)
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
pa.thread_id = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PendingApprovalsResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPendingApprovalsResponse:
|
||||
def test_empty_list(self) -> None:
|
||||
resp = PendingApprovalsResponse(items=[], count=0)
|
||||
assert resp.items == []
|
||||
assert resp.count == 0
|
||||
|
||||
def test_with_items(self) -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
item = PendingApproval(
|
||||
thread_id="t1",
|
||||
graph_name="g",
|
||||
interrupt_value="v",
|
||||
created_at=now,
|
||||
)
|
||||
resp = PendingApprovalsResponse(items=[item], count=1)
|
||||
assert resp.count == 1
|
||||
assert len(resp.items) == 1
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = PendingApprovalsResponse(items=[], count=0)
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.count = 5 # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HealthResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHealthResponse:
|
||||
def test_ok_status(self) -> None:
|
||||
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=123.4)
|
||||
assert resp.status == "ok"
|
||||
assert resp.version == "0.1.0"
|
||||
assert resp.uptime_seconds == pytest.approx(123.4)
|
||||
|
||||
def test_degraded_status(self) -> None:
|
||||
resp = HealthResponse(status="degraded", version="0.1.0", uptime_seconds=0.0)
|
||||
assert resp.status == "degraded"
|
||||
|
||||
def test_invalid_status_raises(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
HealthResponse(status="unknown", version="0.1.0", uptime_seconds=0.0) # type: ignore[arg-type]
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=1.0)
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.status = "degraded" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReleaseVersionListResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReleaseVersionListResponse:
|
||||
def test_valid_construction(self) -> None:
|
||||
resp = ReleaseVersionListResponse(repo="my-repo", versions=["v1.0.0", "v1.1.0"])
|
||||
assert resp.repo == "my-repo"
|
||||
assert resp.versions == ["v1.0.0", "v1.1.0"]
|
||||
|
||||
def test_empty_versions(self) -> None:
|
||||
resp = ReleaseVersionListResponse(repo="r", versions=[])
|
||||
assert resp.versions == []
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = ReleaseVersionListResponse(repo="r", versions=[])
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.repo = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StagingResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStagingResponse:
|
||||
def test_with_staging(self) -> None:
|
||||
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
|
||||
resp = StagingResponse(repo="my-repo", staging=staging_data)
|
||||
assert resp.repo == "my-repo"
|
||||
assert resp.staging is not None
|
||||
assert resp.staging["version"] == "v1.0.0"
|
||||
|
||||
def test_without_staging(self) -> None:
|
||||
resp = StagingResponse(repo="my-repo", staging=None)
|
||||
assert resp.staging is None
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = StagingResponse(repo="r", staging=None)
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.repo = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManualTriggerResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualTriggerResponse:
|
||||
def test_valid_construction(self) -> None:
|
||||
resp = ManualTriggerResponse(thread_id="t1", message="triggered")
|
||||
assert resp.thread_id == "t1"
|
||||
assert resp.message == "triggered"
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = ManualTriggerResponse(thread_id="t1", message="m")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.thread_id = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManualReleaseRequest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualReleaseRequest:
|
||||
def test_valid_construction(self) -> None:
|
||||
req = ManualReleaseRequest(repo="my-repo")
|
||||
assert req.repo == "my-repo"
|
||||
|
||||
def test_missing_repo_raises(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ManualReleaseRequest() # type: ignore[call-arg]
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
req = ManualReleaseRequest(repo="r")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
req.repo = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ErrorResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorResponse:
|
||||
def test_error_only(self) -> None:
|
||||
resp = ErrorResponse(error="Something went wrong")
|
||||
assert resp.error == "Something went wrong"
|
||||
assert resp.detail is None
|
||||
|
||||
def test_error_with_detail(self) -> None:
|
||||
resp = ErrorResponse(error="Not found", detail="Thread t1 not found")
|
||||
assert resp.detail == "Thread t1 not found"
|
||||
|
||||
def test_frozen_immutable(self) -> None:
|
||||
resp = ErrorResponse(error="e")
|
||||
with pytest.raises((TypeError, ValidationError)):
|
||||
resp.error = "other" # type: ignore[misc]
|
||||
111
tests/api/test_operator_auth.py
Normal file
111
tests/api/test_operator_auth.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Tests for operator token authentication dependency.
|
||||
|
||||
Phase 5 - Step 3: require_operator_token FastAPI dependency.
|
||||
Written FIRST (TDD RED phase).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from release_agent.api.dependencies import require_operator_token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_app_with_token(operator_token: str = "") -> FastAPI:
|
||||
"""Return a minimal app with a protected route and the given token config."""
|
||||
app = FastAPI()
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.operator_token.get_secret_value.return_value = operator_token
|
||||
app.state.settings = mock_settings
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected_route(_: None = Depends(require_operator_token)):
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# require_operator_token tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRequireOperatorToken:
|
||||
def test_valid_token_allows_access(self) -> None:
|
||||
app = _make_app_with_token("super-secret-token")
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/protected",
|
||||
headers={"X-Operator-Token": "super-secret-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_missing_token_header_returns_401_when_token_configured(self) -> None:
|
||||
app = _make_app_with_token("super-secret-token")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_wrong_token_returns_401(self) -> None:
|
||||
app = _make_app_with_token("super-secret-token")
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/protected",
|
||||
headers={"X-Operator-Token": "wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_empty_operator_token_config_skips_auth(self) -> None:
|
||||
"""When operator_token is not configured (empty), all requests pass."""
|
||||
app = _make_app_with_token("")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_empty_operator_token_config_passes_even_without_header(self) -> None:
|
||||
app = _make_app_with_token("")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected", headers={})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_token_comparison_is_constant_time(self) -> None:
|
||||
"""Verify hmac.compare_digest is used (not == operator) — tested by checking
|
||||
that the function still works correctly, not timing (which we can't test here)."""
|
||||
app = _make_app_with_token("my-token")
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/protected",
|
||||
headers={"X-Operator-Token": "my-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_empty_string_token_header_rejected_when_token_configured(self) -> None:
|
||||
app = _make_app_with_token("configured-token")
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/protected",
|
||||
headers={"X-Operator-Token": ""},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_401_response_has_detail_field(self) -> None:
|
||||
app = _make_app_with_token("secret")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected")
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
def test_valid_token_returns_correct_response_body(self) -> None:
|
||||
app = _make_app_with_token("token123")
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/protected",
|
||||
headers={"X-Operator-Token": "token123"},
|
||||
)
|
||||
assert response.json() == {"ok": True}
|
||||
473
tests/api/test_slack_interactions.py
Normal file
473
tests/api/test_slack_interactions.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Tests for api/slack_interactions.py endpoint.
|
||||
|
||||
Written FIRST (TDD RED phase).
|
||||
|
||||
Tests cover:
|
||||
- Signature verification (HMAC-SHA256)
|
||||
- Payload parsing
|
||||
- Button routing
|
||||
- _resume_graph invocation
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from release_agent.api.slack_interactions import router as slack_interactions_router
|
||||
from release_agent.api.slack_interactions import _verify_slack_signature
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TEST_SIGNING_SECRET = "test-signing-secret-abc"
|
||||
|
||||
|
||||
def _make_slack_signature(*, signing_secret: str, timestamp: str, body: str) -> str:
|
||||
"""Compute a valid Slack signing signature."""
|
||||
base_string = f"v0:{timestamp}:{body}"
|
||||
sig = hmac.new(
|
||||
signing_secret.encode(),
|
||||
base_string.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"v0={sig}"
|
||||
|
||||
|
||||
def _make_test_app(
|
||||
*,
|
||||
signing_secret: str = _TEST_SIGNING_SECRET,
|
||||
thread_graph_name: str | None = "release",
|
||||
graph_result: dict | None = None,
|
||||
) -> FastAPI:
|
||||
"""Return a FastAPI test app with mocked state for slack interactions."""
|
||||
app = FastAPI()
|
||||
app.include_router(slack_interactions_router)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.slack_signing_secret.get_secret_value.return_value = signing_secret
|
||||
mock_settings.operator_token.get_secret_value.return_value = ""
|
||||
|
||||
mock_graph = MagicMock()
|
||||
mock_graph.ainvoke = AsyncMock(return_value=graph_result or {"messages": ["done"]})
|
||||
|
||||
mock_graphs = {
|
||||
"release": mock_graph,
|
||||
"pr_completed": MagicMock(),
|
||||
}
|
||||
mock_clients = MagicMock()
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value=(thread_graph_name,) if thread_graph_name else None
|
||||
)
|
||||
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 = mock_graphs
|
||||
app.state.tool_clients = mock_clients
|
||||
app.state.db_pool = mock_pool
|
||||
app.state.background_tasks = set()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _make_button_payload(
|
||||
*,
|
||||
thread_id: str = "test-thread-123",
|
||||
value: str = "approve",
|
||||
user_id: str = "U12345",
|
||||
user_name: str = "alice",
|
||||
) -> str:
|
||||
"""Build a URL-encoded Slack button action payload."""
|
||||
payload = {
|
||||
"type": "block_actions",
|
||||
"user": {"id": user_id, "name": user_name},
|
||||
"actions": [
|
||||
{
|
||||
"type": "button",
|
||||
"value": f"{thread_id}:{value}",
|
||||
"action_id": f"approval_{value}_{thread_id}",
|
||||
}
|
||||
],
|
||||
}
|
||||
return urllib.parse.urlencode({"payload": json.dumps(payload)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _verify_slack_signature pure function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVerifySlackSignature:
|
||||
"""Tests for the _verify_slack_signature pure function."""
|
||||
|
||||
def test_returns_true_for_valid_signature(self) -> None:
|
||||
timestamp = str(int(time.time()))
|
||||
body = "test=body&data=here"
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
signature=sig,
|
||||
) is True
|
||||
|
||||
def test_returns_false_for_wrong_secret(self) -> None:
|
||||
timestamp = str(int(time.time()))
|
||||
body = "test=body"
|
||||
sig = _make_slack_signature(
|
||||
signing_secret="wrong-secret",
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
signature=sig,
|
||||
) is False
|
||||
|
||||
def test_returns_false_for_tampered_body(self) -> None:
|
||||
timestamp = str(int(time.time()))
|
||||
original_body = "original=body"
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=original_body,
|
||||
)
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body="tampered=body",
|
||||
signature=sig,
|
||||
) is False
|
||||
|
||||
def test_returns_false_for_wrong_timestamp(self) -> None:
|
||||
body = "test=body"
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp="1000000",
|
||||
body=body,
|
||||
)
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp="9999999",
|
||||
body=body,
|
||||
signature=sig,
|
||||
) is False
|
||||
|
||||
def test_returns_false_for_malformed_signature(self) -> None:
|
||||
timestamp = str(int(time.time()))
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body="body",
|
||||
signature="not-a-valid-sig",
|
||||
) is False
|
||||
|
||||
def test_returns_false_for_empty_signature(self) -> None:
|
||||
timestamp = str(int(time.time()))
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body="body",
|
||||
signature="",
|
||||
) is False
|
||||
|
||||
def test_uses_hmac_sha256(self) -> None:
|
||||
timestamp = "1234567890"
|
||||
body = "payload=data"
|
||||
base = f"v0:{timestamp}:{body}"
|
||||
expected_hash = hmac.new(
|
||||
_TEST_SIGNING_SECRET.encode(),
|
||||
base.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
sig = f"v0={expected_hash}"
|
||||
|
||||
# Inject current_time matching timestamp to bypass replay prevention
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
signature=sig,
|
||||
current_time=1234567890.0,
|
||||
) is True
|
||||
|
||||
def test_rejects_stale_timestamp(self) -> None:
|
||||
old_timestamp = "1000000000" # year 2001
|
||||
body = "payload=data"
|
||||
base = f"v0:{old_timestamp}:{body}"
|
||||
expected_hash = hmac.new(
|
||||
_TEST_SIGNING_SECRET.encode(),
|
||||
base.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
sig = f"v0={expected_hash}"
|
||||
|
||||
# Valid signature but timestamp too old
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=old_timestamp,
|
||||
body=body,
|
||||
signature=sig,
|
||||
) is False
|
||||
|
||||
def test_rejects_non_integer_timestamp(self) -> None:
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp="not-a-number",
|
||||
body="body",
|
||||
signature="v0=abc",
|
||||
) is False
|
||||
|
||||
def test_signature_prefix_must_be_v0(self) -> None:
|
||||
timestamp = "1234567890"
|
||||
body = "payload=data"
|
||||
base = f"v0:{timestamp}:{body}"
|
||||
hash_val = hmac.new(
|
||||
_TEST_SIGNING_SECRET.encode(),
|
||||
base.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
wrong_prefix_sig = f"v1={hash_val}"
|
||||
|
||||
assert _verify_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
signature=wrong_prefix_sig,
|
||||
) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /slack/interactions endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlackInteractionsEndpoint:
|
||||
"""Tests for POST /slack/interactions."""
|
||||
|
||||
def test_returns_200_for_valid_request(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="t-abc", value="approve")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_returns_403_for_invalid_signature(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload()
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": "v0=invalid_signature",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_returns_400_when_missing_timestamp_header(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
body = _make_button_payload()
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Signature": "v0=something",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code in (400, 403, 422)
|
||||
|
||||
def test_rejects_when_signing_secret_not_configured(self) -> None:
|
||||
app = _make_test_app(signing_secret="")
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="t-abc", value="approve")
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": "v0=any_sig",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_returns_200_with_approve_action(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="thread-1", value="approve")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_returns_200_with_reject_action(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="thread-2", value="reject")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_schedules_graph_resume_in_background(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="t-bg", value="approve")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_returns_404_for_unknown_thread(self) -> None:
|
||||
app = _make_test_app(thread_graph_name=None)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="unknown-thread", value="approve")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return 200 immediately (Slack requires immediate 200)
|
||||
# but the background task may log an error
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_body_is_empty_or_ok(self) -> None:
|
||||
app = _make_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
body = _make_button_payload(thread_id="t-ok", value="approve")
|
||||
sig = _make_slack_signature(
|
||||
signing_secret=_TEST_SIGNING_SECRET,
|
||||
timestamp=timestamp,
|
||||
body=body,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/slack/interactions",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": sig,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Body may be empty or a simple JSON with ok=True
|
||||
if response.content:
|
||||
data = response.json()
|
||||
assert data.get("ok") is True or "ok" not in data
|
||||
270
tests/api/test_status.py
Normal file
270
tests/api/test_status.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for status, releases, staging, and manual trigger endpoints.
|
||||
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.status import router as status_router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_test_app(
|
||||
*,
|
||||
versions: list[str] | None = None,
|
||||
staging_data: dict | None = None,
|
||||
) -> FastAPI:
|
||||
"""Return a FastAPI app with mocked state for status tests."""
|
||||
app = FastAPI()
|
||||
app.include_router(status_router)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.operator_token.get_secret_value.return_value = ""
|
||||
mock_graphs = {
|
||||
"pr_completed": MagicMock(),
|
||||
"release": MagicMock(),
|
||||
}
|
||||
mock_clients = MagicMock()
|
||||
|
||||
mock_staging_store = MagicMock()
|
||||
mock_staging_store.list_versions = AsyncMock(return_value=versions or [])
|
||||
|
||||
# staging store returns StagingRelease-like or None
|
||||
if staging_data is not None:
|
||||
mock_staging_obj = MagicMock()
|
||||
mock_staging_obj.model_dump = MagicMock(return_value=staging_data)
|
||||
mock_staging_store.load = AsyncMock(return_value=mock_staging_obj)
|
||||
else:
|
||||
mock_staging_store.load = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
|
||||
app.state.settings = mock_settings
|
||||
app.state.graphs = mock_graphs
|
||||
app.state.tool_clients = mock_clients
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStatus:
|
||||
def test_returns_200(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_status_field(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
assert data["status"] in ("ok", "degraded")
|
||||
|
||||
def test_response_has_version_field(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "version" in data
|
||||
assert isinstance(data["version"], str)
|
||||
|
||||
def test_response_has_uptime_seconds(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
data = response.json()
|
||||
assert "uptime_seconds" in data
|
||||
assert data["uptime_seconds"] >= 0.0
|
||||
|
||||
def test_status_is_ok_when_healthy(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/status")
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /releases/{repo}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetReleaseVersions:
|
||||
def test_returns_200(self) -> None:
|
||||
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/my-repo")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_repo_and_versions(self) -> None:
|
||||
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/my-repo")
|
||||
data = response.json()
|
||||
assert data["repo"] == "my-repo"
|
||||
assert data["versions"] == ["v1.0.0", "v1.1.0"]
|
||||
|
||||
def test_empty_versions_list(self) -> None:
|
||||
app = _make_test_app(versions=[])
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/releases/unknown-repo")
|
||||
data = response.json()
|
||||
assert data["versions"] == []
|
||||
|
||||
def test_repo_name_in_path_used(self) -> None:
|
||||
mock_staging_store = MagicMock()
|
||||
mock_staging_store.list_versions = AsyncMock(return_value=[])
|
||||
app = _make_test_app()
|
||||
app.state.staging_store = mock_staging_store
|
||||
|
||||
with TestClient(app) as client:
|
||||
client.get("/releases/specific-repo")
|
||||
|
||||
mock_staging_store.list_versions.assert_called_once_with("specific-repo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /staging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetStaging:
|
||||
def test_returns_200_with_staging(self) -> None:
|
||||
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
|
||||
app = _make_test_app(staging_data=staging_data)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=my-repo")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_has_repo_and_staging(self) -> None:
|
||||
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
|
||||
app = _make_test_app(staging_data=staging_data)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=my-repo")
|
||||
data = response.json()
|
||||
assert data["repo"] == "my-repo"
|
||||
assert data["staging"] is not None
|
||||
assert data["staging"]["version"] == "v1.0.0"
|
||||
|
||||
def test_returns_null_staging_when_not_found(self) -> None:
|
||||
app = _make_test_app(staging_data=None)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging?repo=no-staging-repo")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["staging"] is None
|
||||
|
||||
def test_missing_repo_query_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/staging")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /manual/pr/{pr_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualPrTrigger:
|
||||
def test_returns_202(self) -> None:
|
||||
app = _make_test_app()
|
||||
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
|
||||
|
||||
def test_response_has_thread_id(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/manual/pr/42")
|
||||
data = response.json()
|
||||
assert "thread_id" in data
|
||||
assert isinstance(data["thread_id"], str)
|
||||
|
||||
def test_response_has_message(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/manual/pr/42")
|
||||
assert "message" in response.json()
|
||||
|
||||
def test_schedules_background_task(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
) as mock_create:
|
||||
with TestClient(app) as client:
|
||||
client.post("/manual/pr/99")
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /manual/release
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManualReleaseTrigger:
|
||||
def test_returns_202(self) -> None:
|
||||
app = _make_test_app()
|
||||
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
|
||||
|
||||
def test_response_has_thread_id(self) -> None:
|
||||
app = _make_test_app()
|
||||
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"},
|
||||
)
|
||||
data = response.json()
|
||||
assert "thread_id" in data
|
||||
|
||||
def test_missing_repo_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/manual/release",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_schedules_background_task(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
|
||||
) as mock_create:
|
||||
with TestClient(app) as client:
|
||||
client.post(
|
||||
"/manual/release",
|
||||
json={"repo": "my-repo"},
|
||||
)
|
||||
mock_create.assert_called_once()
|
||||
166
tests/api/test_status_with_auth.py
Normal file
166
tests/api/test_status_with_auth.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""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
|
||||
218
tests/api/test_webhooks.py
Normal file
218
tests/api/test_webhooks.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Tests for webhook endpoint. Written FIRST (TDD RED phase)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from release_agent.api.webhooks import (
|
||||
_validate_webhook_secret,
|
||||
router as webhook_router,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_SECRET = "super-secret-webhook-key"
|
||||
|
||||
_COMPLETED_PR_PAYLOAD = {
|
||||
"subscription_id": "sub-1",
|
||||
"event_type": "git.pullrequest.updated",
|
||||
"resource": {
|
||||
"repository": {
|
||||
"id": "repo-1",
|
||||
"name": "my-repo",
|
||||
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
|
||||
},
|
||||
"pull_request_id": 42,
|
||||
"title": "feat: add feature",
|
||||
"source_ref_name": "refs/heads/feature/BILL-123-add-feature",
|
||||
"target_ref_name": "refs/heads/main",
|
||||
"status": "completed",
|
||||
"closed_date": "2024-01-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
_ACTIVE_PR_PAYLOAD = {
|
||||
"subscription_id": "sub-2",
|
||||
"event_type": "git.pullrequest.updated",
|
||||
"resource": {
|
||||
"repository": {
|
||||
"id": "repo-1",
|
||||
"name": "my-repo",
|
||||
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
|
||||
},
|
||||
"pull_request_id": 43,
|
||||
"title": "WIP: work in progress",
|
||||
"source_ref_name": "refs/heads/feature/BILL-456",
|
||||
"target_ref_name": "refs/heads/main",
|
||||
"status": "active",
|
||||
"closed_date": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_test_app(webhook_secret: str = VALID_SECRET) -> FastAPI:
|
||||
"""Return a FastAPI app with mocked state for webhook tests."""
|
||||
app = FastAPI()
|
||||
app.include_router(webhook_router)
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.webhook_secret.get_secret_value.return_value = webhook_secret
|
||||
|
||||
mock_graphs = {
|
||||
"pr_completed": MagicMock(),
|
||||
"release": MagicMock(),
|
||||
}
|
||||
|
||||
mock_clients = MagicMock()
|
||||
mock_pool = MagicMock()
|
||||
|
||||
# background_tasks set tracked on state
|
||||
app.state.settings = mock_settings
|
||||
app.state.graphs = mock_graphs
|
||||
app.state.tool_clients = mock_clients
|
||||
app.state.db_pool = mock_pool
|
||||
app.state.background_tasks = set()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_webhook_secret (unit tests, pure function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateWebhookSecret:
|
||||
def test_valid_secret_returns_true(self) -> None:
|
||||
assert _validate_webhook_secret("mysecret", "mysecret") is True
|
||||
|
||||
def test_wrong_secret_returns_false(self) -> None:
|
||||
assert _validate_webhook_secret("wrong", "mysecret") is False
|
||||
|
||||
def test_empty_header_returns_false(self) -> None:
|
||||
assert _validate_webhook_secret("", "mysecret") is False
|
||||
|
||||
def test_none_header_returns_false(self) -> None:
|
||||
assert _validate_webhook_secret(None, "mysecret") is False # type: ignore[arg-type]
|
||||
|
||||
def test_uses_constant_time_comparison(self) -> None:
|
||||
# Should not raise even for very different lengths
|
||||
assert _validate_webhook_secret("a", "very-long-secret-value") is False
|
||||
|
||||
def test_empty_expected_rejects_all(self) -> None:
|
||||
# Empty expected secret = auth misconfigured, reject everything
|
||||
assert _validate_webhook_secret("", "") is False
|
||||
assert _validate_webhook_secret("any-value", "") is False
|
||||
assert _validate_webhook_secret(None, "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /webhooks/azdo — integration tests via TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebhookEndpoint:
|
||||
def test_valid_completed_pr_returns_202(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app, raise_server_exceptions=True) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
headers={"X-Webhook-Secret": VALID_SECRET},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "thread_id" in data
|
||||
assert "message" in data
|
||||
|
||||
def test_missing_secret_header_returns_401(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_wrong_secret_header_returns_401(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
headers={"X-Webhook-Secret": "wrong-secret"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_invalid_payload_returns_422(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json={"invalid": "payload"},
|
||||
headers={"X-Webhook-Secret": VALID_SECRET},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_active_pr_event_returns_200_ignored(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_ACTIVE_PR_PAYLOAD,
|
||||
headers={"X-Webhook-Secret": VALID_SECRET},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "ignored" in data.get("message", "").lower() or "ignored" in str(data).lower()
|
||||
|
||||
def test_completed_pr_thread_id_is_string(self) -> None:
|
||||
app = _make_test_app()
|
||||
with patch(
|
||||
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
headers={"X-Webhook-Secret": VALID_SECRET},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert isinstance(response.json()["thread_id"], str)
|
||||
|
||||
def test_completed_pr_schedules_background_task(self) -> None:
|
||||
app = _make_test_app()
|
||||
task_mock = MagicMock()
|
||||
with patch(
|
||||
"release_agent.api.webhooks.asyncio.create_task", return_value=task_mock
|
||||
) as mock_create:
|
||||
with TestClient(app) as client:
|
||||
client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
headers={"X-Webhook-Secret": VALID_SECRET},
|
||||
)
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error response shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWebhookErrorShape:
|
||||
def test_401_has_detail_field(self) -> None:
|
||||
app = _make_test_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/webhooks/azdo",
|
||||
json=_COMPLETED_PR_PAYLOAD,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
# FastAPI HTTPException returns {"detail": ...}
|
||||
assert "detail" in response.json()
|
||||
Reference in New Issue
Block a user