395 lines
15 KiB
Python
395 lines
15 KiB
Python
# ============================================================
|
||
# 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"
|