aps-agent/tests/golden/test_governance_api.py

395 lines
15 KiB
Python
Raw Normal View History

# ============================================================
# R71.6 治理与智能体补齐 · 规则管理 + 审计统计黄金测试
# 独立 FastAPI + TestClient 挂 governance router 与 gov router,
# 不依赖网关 app.py;复用真实 AutomationScheduler/RuleExecutor。
# 覆盖:CRUD、启停(tick 联动)、规则审计轨迹、审计 stats 聚合、
# 非法状态 fail-closed 与拒绝留痕。
# ============================================================
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from server.agent_core import harness
from server.agent_core.automation import AutomationScheduler, RuleExecutor, RuleRegistry
from server.aps_domain import workflow
from server.aps_domain.governance_rules import (
GovernanceRuleError,
GovernanceRuleService,
)
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.gateway import governance_api
from server.state.checkpoints import CheckpointStore
class _MemStore:
"""极简内存 store:world + next_id + save(黄金测试隔离)。"""
def __init__(self, events: list[dict] | None = None) -> None:
self.tenant_uuid = "platform"
self.world_key = "default"
self.data: dict = {"auditEvents": list(events or [])}
self._seq = len(self.data["auditEvents"])
def next_id(self, _kind: str) -> int:
self._seq += 1
return self._seq
def save(self) -> None:
pass
def _rule_payload(**overrides) -> dict:
payload = {
"ruleId": "r-dispatch",
"roomId": "room-1",
"trigger": {"on": "schedule", "every": 60},
"gear": "G1",
"action": "notify",
"enabled": True,
"description": "test rule",
"guardrails": ["no-auto-publish"],
"params": {"level": "L1"},
}
payload.update(overrides)
return payload
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""独立应用:挂两个 router,注入内存 store + 固定时钟调度器。"""
monkeypatch.setenv("APS_AUDIT_MIRROR", "0")
monkeypatch.setenv("APS_AUDIT_MIRROR_DIR", str(tmp_path / "mirror"))
store = _MemStore()
store.world_key = "governance-test"
store.data["scheduleVersions"] = []
registry = RuleRegistry()
executor = RuleExecutor(registry=registry)
scheduler = AutomationScheduler(
executor, now=lambda: datetime(2026, 8, 12, 8, 0, tzinfo=UTC),
)
service = GovernanceRuleService(
store_provider=lambda: store,
scheduler=scheduler,
state_path=str(tmp_path / "automation_state.json"),
)
identity = IdentityContext(
user_id=7101,
username="authenticated-planner",
fullname="Authenticated Planner",
tenant_uuid="platform",
roles=("system", "admin", "planner", "approver", "auditor"),
)
project_store = SimpleNamespace(
active_world_key=lambda: "governance-test",
snapshot=lambda **_kwargs: {"activeSessionId": None},
)
checkpoints = CheckpointStore(str(tmp_path / "checkpoints.json"))
harness.configure_approval_store(str(tmp_path / "approvals.json"))
monkeypatch.setattr("server.state.projects.get_project_store", lambda: project_store)
monkeypatch.setattr(workflow, "get_checkpoints", lambda: checkpoints)
monkeypatch.setattr(workflow, "_conversation_side_snapshot", lambda: None)
app = FastAPI()
@app.middleware("http")
async def bind_test_identity(request, call_next):
token = bind_identity(identity)
try:
return await call_next(request)
finally:
reset_identity(token)
app.include_router(governance_api.router)
app.include_router(governance_api.gov_router)
app.dependency_overrides[governance_api.get_governance_service] = lambda: service
return TestClient(app), store, service, identity, checkpoints
def _confirm(client, confirm_id: str, *, approve: bool = True) -> str:
_, store, service, identity, _ = client
token = bind_identity(identity)
try:
return workflow.execute_confirmed(
store,
confirm_id,
approve,
actor="forged-query-actor",
governance_service=service,
)
finally:
reset_identity(token)
def _confirm_id(response) -> str:
assert response.status_code == 202, response.text
block = response.json()["block"]
assert block["props"]["power"] == "P2"
return str(block["props"]["confirmId"])
def _create_confirmed(client, **overrides) -> str:
c, _, _, _, _ = client
staged = c.post(
"/api/governance/rules?actor=forged-query-actor",
json=_rule_payload(**overrides),
)
confirm_id = _confirm_id(staged)
_confirm(client, confirm_id)
return confirm_id
# ---------------- CRUD + 审计留痕 ----------------
def test_create_list_get_and_audit_trail(client):
c, store, _, _, checkpoints = client
r = c.post("/api/governance/rules?actor=forged", json=_rule_payload())
confirm_id = _confirm_id(r)
assert c.get("/api/governance/rules").json()["rules"] == []
assert store.data["auditEvents"] == []
_confirm(client, confirm_id)
body = c.get("/api/governance/rules/r-dispatch").json()["rule"]
assert body["ruleId"] == "r-dispatch"
assert body["enabled"] is True
assert body["paused"] is False
governance_events = [e for e in store.data["auditEvents"] if e["category"] == "GOVERNANCE"]
assert len(governance_events) == 1
assert governance_events[0]["action"] == "governance.rule.create"
assert governance_events[0]["target"]["type"] == "RULE"
assert governance_events[0]["target"]["id"] == "r-dispatch"
assert governance_events[0]["power"] == "P2"
assert governance_events[0]["actor"] == "authenticated-planner"
assert governance_events[0]["rationale"]["confirmId"] == confirm_id
assert governance_events[0]["beforeSnapshot"]
assert checkpoints.get(governance_events[0]["beforeSnapshot"]) is not None
lst = c.get("/api/governance/rules")
assert lst.status_code == 200
assert [item["ruleId"] for item in lst.json()["rules"]] == ["r-dispatch"]
one = c.get("/api/governance/rules/r-dispatch")
assert one.status_code == 200
assert one.json()["rule"]["trigger"]["every"] == 60
trail = c.get("/api/governance/rules/r-dispatch/audit")
assert trail.status_code == 200
assert trail.json()["total"] == 1
assert trail.json()["events"][0]["action"] == "governance.rule.create"
def test_update_rule_merges_and_audits_before_after(client):
c, store, _, _, _ = client
_create_confirmed(client)
r = c.put(
"/api/governance/rules/r-dispatch?actor=forged",
json={"gear": "G2", "description": "updated", "params": {"level": "L3"}},
)
confirm_id = _confirm_id(r)
assert c.get("/api/governance/rules/r-dispatch").json()["rule"]["gear"] == "G1"
_confirm(client, confirm_id)
rule = c.get("/api/governance/rules/r-dispatch").json()["rule"]
assert rule["gear"] == "G2"
assert rule["description"] == "updated"
assert rule["params"] == {"level": "L3"}
assert rule["trigger"]["every"] == 60 # 未提交字段保留
update_events = [e for e in store.data["auditEvents"] if e["action"] == "governance.rule.update"]
assert len(update_events) == 1
assert update_events[0]["rationale"]["before"]["gear"] == "G1"
assert update_events[0]["rationale"]["after"]["gear"] == "G2"
def test_delete_rule_removes_and_audits(client):
c, store, _, _, _ = client
_create_confirmed(client)
r = c.delete("/api/governance/rules/r-dispatch?actor=forged")
confirm_id = _confirm_id(r)
assert c.get("/api/governance/rules/r-dispatch").status_code == 200
_confirm(client, confirm_id)
assert c.get("/api/governance/rules/r-dispatch").status_code == 404
assert any(e["action"] == "governance.rule.delete" for e in store.data["auditEvents"])
def test_rule_audit_filters_by_rule(client):
c, _, _, _, _ = client
_create_confirmed(client, ruleId="r-a")
_create_confirmed(client, ruleId="r-b", roomId="room-2")
trail_a = c.get("/api/governance/rules/r-a/audit").json()
trail_b = c.get("/api/governance/rules/r-b/audit").json()
assert trail_a["total"] == 1
assert trail_b["total"] == 1
assert trail_a["events"][0]["target"]["id"] == "r-a"
assert trail_b["events"][0]["target"]["id"] == "r-b"
# ---------------- 启停 + tick 联动 ----------------
def test_enable_disable_controls_tick(client):
c, store, service, _, _ = client
_create_confirmed(client, enabled=False)
def tick() -> list:
return service._scheduler().tick(
world=store.data, next_id=store.next_id, save=store.save,
now=datetime(2026, 8, 12, 8, 0, tzinfo=UTC),
)
assert tick() == [] # 停用规则不触发
r = c.post("/api/governance/rules/r-dispatch/enable?actor=forged")
confirm_id = _confirm_id(r)
assert c.get("/api/governance/rules/r-dispatch").json()["rule"]["enabled"] is False
_confirm(client, confirm_id)
runs = tick()
assert [run.rule_id for run in runs] == ["r-dispatch"]
r = c.post("/api/governance/rules/r-dispatch/disable?actor=forged")
confirm_id = _confirm_id(r)
assert c.get("/api/governance/rules/r-dispatch").json()["rule"]["enabled"] is True
_confirm(client, confirm_id)
assert tick() == []
actions = {e["action"] for e in store.data["auditEvents"]}
assert {"governance.rule.enable", "governance.rule.disable"} <= actions
# ---------------- fail-closed ----------------
def test_fail_closed_duplicate_invalid_and_missing(client):
c, store, _, _, _ = client
_create_confirmed(client)
dup = c.post("/api/governance/rules?actor=forged", json=_rule_payload())
assert dup.status_code == 409, dup.text
assert dup.json()["detail"]["code"] == "RULE_EXISTS"
invalid = c.post(
"/api/governance/rules?actor=forged",
json=_rule_payload(ruleId="r-bad", trigger={"on": "bogus"}),
)
assert invalid.status_code == 422, invalid.text
assert invalid.json()["detail"]["code"] == "INVALID_RULE"
assert c.get("/api/governance/rules/r-missing").status_code == 404
assert all(e["result"] == "SUCCESS" for e in store.data["auditEvents"])
def test_reject_confirmation_does_not_write(client):
c, store, _, _, _ = client
staged = c.post("/api/governance/rules?actor=forged", json=_rule_payload())
confirm_id = _confirm_id(staged)
message = _confirm(client, confirm_id, approve=False)
assert "已驳回" in message
assert c.get("/api/governance/rules").json()["rules"] == []
governance_writes = [
event for event in store.data["auditEvents"]
if event["category"] == "GOVERNANCE"
]
assert governance_writes == []
rejected = [event for event in store.data["auditEvents"] if event["result"] == "DENIED"]
assert rejected[-1]["action"] == "governance.rule.create.reject"
assert rejected[-1]["actor"] == "authenticated-planner"
def test_world_save_failure_rolls_back_and_is_explicit(client):
c, store, service, _, checkpoints = client
confirm_id = _confirm_id(c.post("/api/governance/rules", json=_rule_payload()))
def fail_save() -> None:
raise OSError("world save unavailable")
store.save = fail_save
with pytest.raises(GovernanceRuleError) as exc_info:
_confirm(client, confirm_id)
assert exc_info.value.code == "PERSISTENCE_FAILED"
assert "world save unavailable" in str(exc_info.value)
assert service.list_rules() == []
assert store.data["auditEvents"] == []
assert checkpoints.list_meta() == []
def test_scheduler_state_failure_rolls_back_and_is_explicit(client, monkeypatch):
c, store, service, _, checkpoints = client
confirm_id = _confirm_id(c.post("/api/governance/rules", json=_rule_payload()))
def fail_state(_path) -> None:
raise OSError("scheduler state unavailable")
monkeypatch.setattr(service._scheduler(), "save_state", fail_state)
with pytest.raises(GovernanceRuleError) as exc_info:
_confirm(client, confirm_id)
assert exc_info.value.code == "PERSISTENCE_FAILED"
assert "scheduler state unavailable" in str(exc_info.value)
assert service.list_rules() == []
assert store.data["auditEvents"] == []
assert checkpoints.list_meta() == []
# ---------------- 审计统计聚合 ----------------
def test_audit_stats_aggregates_by_module_action_result_and_window(client):
c, store, _, _, _ = client
store.data["auditEvents"] = [
{"id": 1, "at": "2026-08-10 08:00", "category": "GATE", "action": "schedule.publish.stage", "result": "SUCCESS"},
{"id": 2, "at": "2026-08-10 09:00", "category": "GATE", "action": "schedule.publish.stage", "result": "DENIED"},
{"id": 3, "at": "2026-08-11 10:00", "category": "TOOL", "action": "tool.run", "result": "SUCCESS"},
{"id": 4, "at": "2026-08-12 10:00", "category": "TOOL", "action": "tool.run", "result": "SUCCESS"},
]
r = c.get(
"/api/gov/audit/stats"
"?startTime=2026-08-10&endTime=2026-08-11&result=SUCCESS&granularity=day",
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["source"] == "world"
assert body["total"] == 4
assert body["filtered"] == 2
assert body["byModule"] == [
{"module": "GATE", "count": 1},
{"module": "TOOL", "count": 1},
]
assert body["byAction"] == [
{"action": "schedule.publish.stage", "count": 1},
{"action": "tool.run", "count": 1},
]
assert body["byResult"] == [{"result": "SUCCESS", "count": 2}]
assert body["trend"] == [
{"bucket": "2026-08-10", "count": 1},
{"bucket": "2026-08-11", "count": 1},
]
assert body["rows"] == [
{"module": "GATE", "action": "schedule.publish.stage", "result": "SUCCESS", "count": 1},
{"module": "TOOL", "action": "tool.run", "result": "SUCCESS", "count": 1},
]
def test_audit_stats_hour_granularity_and_filters(client):
c, store, _, _, _ = client
store.data["auditEvents"] = [
{"id": 1, "at": "2026-08-10 08:15", "category": "TOOL", "action": "tool.run", "result": "SUCCESS"},
{"id": 2, "at": "2026-08-10 09:15", "category": "TOOL", "action": "tool.run", "result": "SUCCESS"},
]
r = c.get(
"/api/gov/audit/stats"
"?startTime=2026-08-10 08:00&endTime=2026-08-10 08:59&granularity=hour",
)
assert r.status_code == 200, r.text
body = r.json()
assert body["filtered"] == 1
assert body["trend"] == [{"bucket": "2026-08-10 08", "count": 1}]
def test_audit_stats_rejects_bad_params(client):
c, _, _, _, _ = client
assert c.get("/api/gov/audit/stats?granularity=week").status_code == 422
bad_time = c.get("/api/gov/audit/stats?startTime=not-a-date")
assert bad_time.status_code == 400
assert bad_time.json()["detail"]["code"] == "INVALID_TIME"