410 lines
15 KiB
Python
410 lines
15 KiB
Python
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
|