aps-agent/tests/golden/test_approval_store.py

410 lines
15 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import multiprocessing
import threading
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from server.agent_core import harness
from server.agent_core.approval_store import ApprovalStore
from server.auth.context import IdentityContext, bind_identity, reset_identity
def _identity(user_id: int, tenant: str = "platform") -> IdentityContext:
return IdentityContext(
user_id,
f"user-{user_id}",
f"User {user_id}",
tenant,
roles=("planner",),
)
def _as(identity: IdentityContext, callback):
token = bind_identity(identity)
try:
return callback()
finally:
reset_identity(token)
def _stage(action: str = "mes.dispatch") -> tuple[str, dict]:
params = (
{"track": "fixed", "versionId": 91, "evidenceRefs": ["schedule-version:91"]}
if action == "mes.dispatch"
else {"versionId": 91}
)
block = harness.stage_confirmation(
"persistence-test",
action,
params,
title="Approval persistence",
summary_lines=["test"],
)
return str(block.props["confirmId"]), params
def _approve_in_process(path: str, confirm_id: str, user_id: int, barrier, output) -> None:
harness.configure_approval_store(path)
token = bind_identity(_identity(user_id))
try:
barrier.wait(timeout=10)
output.put(harness.take_confirmation(confirm_id, approve=True))
finally:
reset_identity(token)
def _consume_in_process(
path: str,
grant: str,
confirm_id: str,
params: dict,
barrier,
output,
) -> None:
harness.configure_approval_store(path)
barrier.wait(timeout=10)
output.put(harness.consume_execution_grant(
grant,
confirm_id=confirm_id,
action="mes.dispatch",
params=params,
))
def test_pending_and_history_survive_store_reload(tmp_path: Path):
path = str(tmp_path / "approvals.json")
harness.configure_approval_store(path)
confirm_id, _params = _stage("schedule.publish")
harness.configure_approval_store(path)
pending = harness.list_pending()
assert [item["confirmId"] for item in pending] == [confirm_id]
approved = harness.take_confirmation(confirm_id, approve=True)
assert approved and approved["needsSecondConfirm"] is False
harness.configure_approval_store(path)
assert harness.list_pending() == []
assert harness.list_approval_history()[-1]["status"] == "APPROVED"
def test_expired_pending_is_removed_and_recorded(tmp_path: Path):
path = str(tmp_path / "approvals.json")
store = harness.configure_approval_store(path)
confirm_id, _params = _stage()
expires_at = float(store.pending[confirm_id]["expiresAtEpoch"])
assert store.expire(now_epoch=expires_at + 1) is True
harness.configure_approval_store(path)
assert harness.is_confirmation_pending(confirm_id) is False
assert harness.list_approval_history()[-1]["status"] == "EXPIRED"
def test_execution_grant_survives_reload_and_remains_one_time(tmp_path: Path):
path = str(tmp_path / "approvals.json")
harness.configure_approval_store(path)
confirm_id, params = _stage()
first = harness.take_confirmation(confirm_id, approve=True)
second = _as(_identity(2002), lambda: harness.take_confirmation(confirm_id, approve=True))
assert first and first["needsSecondConfirm"] is True
assert second and second["executionGrant"]
grant = str(second["executionGrant"])
harness.configure_approval_store(path)
assert harness.consume_execution_grant(
grant,
confirm_id=confirm_id,
action="mes.dispatch",
params=params,
) is True
harness.configure_approval_store(path)
assert harness.consume_execution_grant(
grant,
confirm_id=confirm_id,
action="mes.dispatch",
params=params,
) is False
assert harness.list_approval_history()[-1]["status"] == "EXECUTED"
def test_expired_execution_grant_cannot_be_consumed(tmp_path: Path):
path = str(tmp_path / "approvals.json")
store = harness.configure_approval_store(path)
confirm_id, params = _stage()
harness.take_confirmation(confirm_id, approve=True)
approved = _as(_identity(2002), lambda: harness.take_confirmation(confirm_id, approve=True))
assert approved and approved["executionGrant"]
grant = str(approved["executionGrant"])
expires_at = float(store.grants[grant]["expiresAtEpoch"])
store.expire(now_epoch=expires_at + 1)
harness.configure_approval_store(path)
assert harness.consume_execution_grant(
grant,
confirm_id=confirm_id,
action="mes.dispatch",
params=params,
) is False
assert harness.list_approval_history()[-1]["status"] == "GRANT_EXPIRED"
def test_default_scope_p2_remains_private_but_p3_allows_dual_control(tmp_path: Path):
harness.configure_approval_store(str(tmp_path / "approvals.json"))
owner = _identity(3001)
peer = _identity(3002)
p2_id, _ = _as(owner, lambda: _stage("schedule.publish"))
assert _as(peer, lambda: harness.is_confirmation_pending(p2_id)) is False
assert _as(peer, lambda: harness.take_confirmation(p2_id, approve=True)) is None
assert _as(owner, lambda: harness.is_confirmation_pending(p2_id)) is True
p3_id, _ = _as(owner, _stage)
assert [row["confirmId"] for row in _as(peer, harness.list_pending)] == [p3_id]
first = _as(owner, lambda: harness.take_confirmation(p3_id, approve=True))
second = _as(peer, lambda: harness.take_confirmation(p3_id, approve=True))
assert first and first["needsSecondConfirm"] is True
assert second and second["needsSecondConfirm"] is False
assert [approval["userId"] for approval in second["approvals"]] == ["3001", "3002"]
def test_unprivileged_identity_cannot_view_or_approve(tmp_path: Path):
harness.configure_approval_store(str(tmp_path / "approvals.json"))
owner = _identity(3101)
unprivileged = IdentityContext(
3102,
"viewer-3102",
"Viewer 3102",
"platform",
roles=("viewer",),
)
confirm_id, _ = _as(owner, _stage)
_as(owner, lambda: harness.take_confirmation(confirm_id, approve=True))
assert _as(unprivileged, harness.list_pending) == []
assert _as(unprivileged, harness.list_approval_history) == []
assert _as(unprivileged, lambda: harness.is_confirmation_pending(confirm_id)) is False
assert _as(unprivileged, lambda: harness.take_confirmation(confirm_id, approve=True)) is None
with pytest.raises(PermissionError, match="角色权限"):
_as(unprivileged, _stage)
def test_approval_note_recorded_in_approvals_and_history(tmp_path: Path):
"""审批意见:批准后 approvals 记录与历史事件均携带 note。"""
ApprovalStore(str(tmp_path / "approvals.json")) # 直接路径验证(configure 接管)
harness.configure_approval_store(str(tmp_path / "approvals.json"))
token = bind_identity(_identity(4001))
try:
confirm_id, _params = _stage("schedule.publish") # P2 单重审批(owner=4001)
result = harness.take_confirmation(confirm_id, approve=True, note="同意,风险可控")
assert result is not None
assert result["needsSecondConfirm"] is False
approvals = result["approvals"]
assert approvals[-1]["note"] == "同意,风险可控"
history = harness.list_approval_history()
assert history[-1]["note"] == "同意,风险可控"
assert history[-1]["status"] == "APPROVED"
finally:
reset_identity(token)
def test_approval_note_on_reject(tmp_path: Path):
"""驳回意见:历史事件携带 note。"""
harness.configure_approval_store(str(tmp_path / "approvals.json"))
token = bind_identity(_identity(4001))
try:
confirm_id, _params = _stage("schedule.publish") # P2 单重审批(owner=4001)
result = harness.take_confirmation(confirm_id, approve=False, note="数据不齐,驳回")
assert result is not None and result["needsSecondConfirm"] is False
history = harness.list_approval_history()
assert history[-1]["note"] == "数据不齐,驳回"
assert history[-1]["status"] == "REJECTED"
finally:
reset_identity(token)
def test_history_records_dwell_time(tmp_path: Path):
store = ApprovalStore(str(tmp_path / "approvals.json"))
store.append_history(
{
"confirmId": "confirm-dwell",
"createdAtEpoch": 100.0,
"createdAt": "1970-01-01T00:01:40+00:00",
},
status="APPROVED",
at_epoch=101.25,
)
assert store.history[-1]["dwellMs"] == 1250
def test_concurrent_distinct_approvers_issue_exactly_one_grant(tmp_path: Path):
harness.configure_approval_store(str(tmp_path / "approvals.json"))
confirm_id, _params = _stage()
barrier = threading.Barrier(2)
results: list[dict | None] = []
def approve(identity: IdentityContext) -> None:
token = bind_identity(identity)
try:
barrier.wait()
results.append(harness.take_confirmation(confirm_id, approve=True))
finally:
reset_identity(token)
threads = [
threading.Thread(target=approve, args=(_identity(4001),)),
threading.Thread(target=approve, args=(_identity(4002),)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
grants = [result["executionGrant"] for result in results if result and result.get("executionGrant")]
assert len(grants) == 1
assert harness.is_confirmation_pending(confirm_id) is False
assert len(harness._execution_grants) == 1
def test_multiprocess_second_approval_issues_exactly_one_grant(tmp_path: Path):
path = str(tmp_path / "approvals.json")
harness.configure_approval_store(path)
confirm_id, _params = _stage()
first = harness.take_confirmation(confirm_id, approve=True)
assert first and first["needsSecondConfirm"] is True
context = multiprocessing.get_context("spawn")
barrier = context.Barrier(2)
output = context.Queue()
processes = [
context.Process(target=_approve_in_process, args=(path, confirm_id, user_id, barrier, output))
for user_id in (5001, 5002)
]
for process in processes:
process.start()
results = [output.get(timeout=20) for _ in processes]
for process in processes:
process.join(timeout=20)
assert process.exitcode == 0
grants = [result["executionGrant"] for result in results if result and result.get("executionGrant")]
assert len(grants) == 1
harness.configure_approval_store(path)
assert len(harness._execution_grants) == 1
def test_multiprocess_execution_grant_is_consumed_exactly_once(tmp_path: Path):
path = str(tmp_path / "approvals.json")
harness.configure_approval_store(path)
confirm_id, params = _stage()
harness.take_confirmation(confirm_id, approve=True)
approved = _as(_identity(6001), lambda: harness.take_confirmation(confirm_id, approve=True))
assert approved and approved["executionGrant"]
grant = str(approved["executionGrant"])
context = multiprocessing.get_context("spawn")
barrier = context.Barrier(2)
output = context.Queue()
processes = [
context.Process(
target=_consume_in_process,
args=(path, grant, confirm_id, params, barrier, output),
)
for _ in range(2)
]
for process in processes:
process.start()
results = [output.get(timeout=20) for _ in processes]
for process in processes:
process.join(timeout=20)
assert process.exitcode == 0
assert sorted(results) == [False, True]
store = harness.configure_approval_store(path)
assert store.grants == {}
assert [row["status"] for row in store.history].count("EXECUTED") == 1
def test_store_loader_fails_closed_on_corrupt_file(tmp_path: Path):
path = tmp_path / "approvals.json"
path.write_text("{not-json", encoding="utf-8")
with pytest.warns(RuntimeWarning, match="moved to"):
store = ApprovalStore(str(path))
assert store.pending == {}
assert store.grants == {}
assert store.history == []
assert not path.exists()
assert len(list(tmp_path.glob("approvals.json.corrupt-*"))) == 1
def test_store_loader_quarantines_invalid_expiration_record(tmp_path: Path):
path = tmp_path / "approvals.json"
path.write_text(json.dumps({
"schemaVersion": 1,
"pending": {
"bad-expiry": {
"action": "mes.dispatch",
"tenantUuid": "platform",
"expiresAtEpoch": "bad",
},
},
"grants": {},
"history": [],
}), encoding="utf-8")
with pytest.warns(RuntimeWarning, match="invalid.*record"):
store = ApprovalStore(str(path))
assert store.pending == {}
assert store.expire() is False
assert len(list(tmp_path.glob("approvals.json.corrupt-*"))) == 1
def test_staged_params_are_frozen_before_persistence(tmp_path: Path):
path = str(tmp_path / "approvals.json")
harness.configure_approval_store(path)
params = {"versionId": 12, "nested": {"value": "approved"}}
block = harness.stage_confirmation("freeze", "schedule.publish", params, "freeze", ["test"])
params["nested"]["value"] = "mutated"
harness.configure_approval_store(path)
pending = harness.list_pending()[0]
assert pending["confirmId"] == block.props["confirmId"]
assert pending["params"]["nested"]["value"] == "approved"
def test_governance_api_projects_persistent_pending_and_history(tmp_path: Path, monkeypatch):
import server.gateway.app as gateway_module
import server.state.projects as projects_module
from tests.auth_provider import install_test_auth
class ProjectStore:
def active_world_key(self) -> str:
return "default"
harness.configure_approval_store(str(tmp_path / "approvals.json"))
monkeypatch.setattr(projects_module, "get_project_store", lambda: ProjectStore())
provider = install_test_auth(monkeypatch, "tenant-approval-api")
owner = provider._identity("planner")
confirm_id, _params = _as(owner, _stage)
client = TestClient(gateway_module.create_app())
assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200
pending_response = client.get("/api/gov/pending")
assert pending_response.status_code == 200
pending = pending_response.json()["pending"][0]
assert pending["confirmId"] == confirm_id
assert pending["power"] == "P3"
assert pending["expiresAt"]
assert pending["requiredApprovals"] == 2
_as(owner, lambda: harness.take_confirmation(confirm_id, approve=True))
history_response = client.get("/api/gov/approval-history?limit=10")
assert history_response.status_code == 200
history = history_response.json()["history"]
assert history[-1]["status"] == "PARTIALLY_APPROVED"
assert history[-1]["decidedBy"]["username"] == "planner"
assert history[-1]["dwellMs"] >= 0