Implement ACTIVITY-WP-0024 operator automation console
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 36s

Add /ops REST inventory, status, runs, and fail-closed operator-token
mutations (trigger, enable/disable, pause/unpause) with audit trail.
Ship thin HTML UI at /ops/ui, runbook/k8s access docs, and contract tests.
This commit is contained in:
tegwick 2026-07-21 23:51:39 +02:00
parent 81d350de71
commit 71027f0a67
11 changed files with 1494 additions and 20 deletions

58
tests/test_ops_auth.py Normal file
View file

@ -0,0 +1,58 @@
"""Unit tests for operator token auth (ACTIVITY-WP-0024-T02/T06)."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from activity_core.ops_auth import (
extract_operator_token,
operator_token_configured,
verify_operator_token,
)
def test_extract_operator_token_prefers_header() -> None:
assert (
extract_operator_token(
x_operator_token="abc",
authorization="Bearer other",
)
== "abc"
)
def test_extract_operator_token_bearer() -> None:
assert extract_operator_token(authorization="Bearer secret-token") == "secret-token"
def test_verify_requires_config_when_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
with pytest.raises(HTTPException) as exc:
verify_operator_token(None)
assert exc.value.status_code == 403
def test_verify_allows_anonymous_dev(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
monkeypatch.setenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", "1")
assert verify_operator_token(None) == "anonymous-dev"
def test_verify_token_match(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "correct-horse")
assert verify_operator_token("correct-horse") == "operator"
with pytest.raises(HTTPException) as exc:
verify_operator_token("wrong")
assert exc.value.status_code == 401
with pytest.raises(HTTPException) as exc2:
verify_operator_token(None)
assert exc2.value.status_code == 401
def test_operator_token_configured(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
assert operator_token_configured() is False
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "x")
assert operator_token_configured() is True

View file

@ -0,0 +1,248 @@
"""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"
@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
@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