activity-core/tests/test_ops_console_api.py
tegwick 944fd158de
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 18s
Repair production automation truth and schedule cleanup
2026-08-20 11:20:23 +02:00

308 lines
11 KiB
Python

"""Contract tests for /ops console API (ACTIVITY-WP-0024)."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from activity_core import ops_api
from activity_core.ops_console import clear_audit_buffer_for_tests, recent_audits, record_ops_audit
@pytest.fixture(autouse=True)
def _clean_audit() -> None:
clear_audit_buffer_for_tests()
yield
clear_audit_buffer_for_tests()
@pytest.fixture
def ops_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "test-token")
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
monkeypatch.setenv("ACTCORE_DB_URL", "postgresql+asyncpg://unused/unused")
app = FastAPI()
app.include_router(ops_api.router)
session_factory = MagicMock(name="session_factory")
temporal = MagicMock(name="temporal")
ops_api.bind_ops_deps(lambda: session_factory, lambda: temporal)
app.state.session_factory = session_factory
app.state.temporal = temporal
return app
@pytest.mark.asyncio
async def test_auth_status(ops_app: FastAPI) -> None:
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/auth/status")
assert res.status_code == 200
body = res.json()
assert body["operator_token_configured"] is True
assert body["mutation_header"] == "X-Operator-Token"
assert "temporal_ui_url" in body
assert body["temporal_ui_url"].startswith("http")
@pytest.mark.asyncio
async def test_trigger_requires_token(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
def_id = uuid.uuid4()
async def fake_status(*_a: Any, **_k: Any) -> dict[str, Any]:
return {"mode": "automation-status", "activities": []}
monkeypatch.setattr(ops_api, "ops_status", fake_status)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(f"/ops/automations/{def_id}/trigger")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_trigger_with_sso_principal(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
"""SSO Remote-User is preferred; audit must record sso:<user>, never the token."""
def_id = uuid.uuid4()
row = MagicMock()
row.name = "Weekly SBOM"
row.context_sources = []
row.task_templates = []
row.trigger_config = {"trigger_type": "cron", "cron_expression": "0 9 * * 1"}
session = AsyncMock()
session.get = AsyncMock(return_value=row)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
handle = MagicMock()
handle.id = f"activity-{def_id}:manual-sso"
ops_app.state.temporal.start_workflow = AsyncMock(return_value=handle)
captured: dict[str, Any] = {}
async def capture_audit(**kwargs: Any) -> dict[str, Any]:
captured.update(kwargs)
return {
"action": kwargs["action"],
"audit_id": "sso-a1",
"principal": kwargs["principal"],
}
monkeypatch.setattr("activity_core.ops_api.record_ops_audit", capture_audit)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(
f"/ops/automations/{def_id}/trigger",
headers={"Remote-User": "alice.operator"},
json={},
)
assert res.status_code == 200
assert res.json()["audit"]["principal"] == "sso:alice.operator"
assert captured["principal"] == "sso:alice.operator"
assert "token" not in str(res.json()).lower() or "test-token" not in str(res.json())
@pytest.mark.asyncio
async def test_trigger_with_token(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
def_id = uuid.uuid4()
row = MagicMock()
row.name = "Weekly SBOM"
row.context_sources = []
row.task_templates = []
row.trigger_config = {"trigger_type": "cron", "cron_expression": "0 9 * * 1"}
session = AsyncMock()
session.get = AsyncMock(return_value=row)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
handle = MagicMock()
handle.id = f"activity-{def_id}:manual-test"
ops_app.state.temporal.start_workflow = AsyncMock(return_value=handle)
monkeypatch.setattr(
"activity_core.ops_api.record_ops_audit",
AsyncMock(
return_value={
"action": "trigger",
"audit_id": "a1",
"principal": "operator",
}
),
)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(
f"/ops/automations/{def_id}/trigger",
headers={"X-Operator-Token": "test-token"},
json={},
)
assert res.status_code == 200
body = res.json()
assert body["workflow_id"] == handle.id
assert body["name"] == "Weekly SBOM"
assert "token" not in str(body).lower() or "test-token" not in str(body)
@pytest.mark.asyncio
async def test_side_effect_requires_confirm(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
def_id = uuid.uuid4()
row = MagicMock()
row.name = "Weekly Forgejo Package Prune"
row.context_sources = [{"query": "forgejo_package_prune", "apply": True}]
row.task_templates = []
row.trigger_config = {}
session = AsyncMock()
session.get = AsyncMock(return_value=row)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post(
f"/ops/automations/{def_id}/trigger",
headers={"X-Operator-Token": "test-token"},
json={},
)
assert res.status_code == 400
assert "side effect" in res.json()["detail"].lower()
@pytest.mark.asyncio
async def test_status_endpoint_wraps_report(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_status(**kwargs: Any) -> dict[str, Any]:
return {
"mode": "automation-status",
"window": {"since": kwargs.get("since")},
"summary": {"total": 0},
"activities": [],
"warnings": [],
"exit_code": 0,
}
monkeypatch.setattr(ops_api, "ops_status", fake_status)
session = AsyncMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
count_result = MagicMock()
count_result.all.return_value = []
stuck_result = MagicMock()
stuck_result.scalar_one.return_value = 0
session.execute = AsyncMock(side_effect=[count_result, stuck_result])
ops_app.state.session_factory.return_value = session
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/automations/status", params={"since": "sunday"})
assert res.status_code == 200
assert res.json()["mode"] == "automation-status"
assert res.json()["window"]["since"] == "sunday"
@pytest.mark.asyncio
async def test_inventory_endpoint(
ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_inv(**_k: Any) -> dict[str, Any]:
return {
"mode": "automation-inventory",
"automations": [
{
"id": str(uuid.uuid4()),
"name": "Daily Triage",
"enabled": True,
"trigger_type": "cron",
"cron_expression": "20 7 * * *",
}
],
"summary": {"total": 1},
"warnings": [],
}
monkeypatch.setattr(ops_api, "ops_inventory", fake_inv)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/automations")
assert res.status_code == 200
assert res.json()["summary"]["total"] == 1
assert res.json()["automations"][0]["name"] == "Daily Triage"
@pytest.mark.asyncio
async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_inv(**_k: Any) -> dict[str, Any]:
return {
"automations": [
{
"id": str(uuid.uuid4()),
"name": "Daily Triage",
"enabled": True,
"trigger_type": "cron",
"cron_expression": "0 0 * * *",
"timezone": "Europe/Berlin",
"temporal": {"paused": False},
}
],
"summary": {"total": 1},
"warnings": [],
}
monkeypatch.setattr(ops_api, "ops_inventory", fake_inv)
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get("/ops/ui/")
assert res.status_code == 200
assert "Daily Triage" in res.text
assert "SSO" in res.text or "Break-glass" in res.text
assert "Temporal UI" in res.text
assert "temporal.coulomb.social" in res.text
@pytest.mark.asyncio
async def test_record_ops_audit_no_secret_leak(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("STATE_HUB_URL", raising=False)
event = await record_ops_audit(
action="trigger",
activity_id=str(uuid.uuid4()),
activity_name="X",
principal="operator",
detail={"workflow_id": "wf-1"},
)
assert event["action"] == "trigger"
assert "token" not in event
audits = recent_audits()
assert audits[0]["audit_id"] == event["audit_id"]
@pytest.mark.asyncio
async def test_runs_404(ops_app: FastAPI) -> None:
def_id = uuid.uuid4()
session = AsyncMock()
session.get = AsyncMock(return_value=None)
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
ops_app.state.session_factory.return_value = session
transport = ASGITransport(app=ops_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.get(f"/ops/automations/{def_id}/runs")
assert res.status_code == 404