aps-agent/tests/golden/test_governance_api.py

395 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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"