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
|