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