aps-agent/tests/golden/test_approval_store.py

410 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.

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