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