activity-core/tests/test_ops_console_api.py
tegwick 7761acf86a
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 5s
Build and Publish Container Image / build-and-push (push) Successful in 1m45s
Link Temporal UI from ops console; propose SSO access WP-0025
Add nav/deep link to Temporal Web UI (configurable URL, default
127.0.0.1:8080 for port-forward). Document dual port-forward and draft
ACTIVITY-WP-0025 for Keycloak SSO ingress without port-forward.
2026-07-22 00:30:17 +02:00

252 lines
8.6 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_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)
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 "Operator token" in res.text
assert "Temporal UI" in res.text
assert "127.0.0.1:8080" 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