aps-agent/tests/golden/test_evidence_contract.py

250 lines
11 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.

# 证据链统一协议(§3.4 证据链 v1)黄金测试
# ============================================================
# 覆盖:stage 冻结证据(evidenceRefs/beforeSnapshot/beforeFingerprint)、
# execute_confirmed 统一强校验(缺证据拒绝、版本失效拒绝、版本替换拒绝)、
# 全部 P2/P3 写审计携带 beforeSnapshot/evidenceRefs、mes.dispatch 强绑定回归。
from __future__ import annotations
import pytest
from server.agent_core import harness
from server.aps_domain.workflow import execute_confirmed
from server.auth.context import IdentityContext, bind_identity, reset_identity
class _AuditStore:
# world_key 与 _PLANNER(user 2001) 的 _effective_world_key 派生一致,
# 使 resolve_confirmation_store 回退到本 store 而不创建真实 scoped store。
def __init__(self, world_key: str = "personal-2001", tenant_uuid: str = "platform"):
self.world_key = world_key
self.tenant_uuid = tenant_uuid
self.data: dict = {"auditEvents": [], "scheduleVersions": [], "flexScheduleVersions": []}
def next_id(self, _kind: str) -> int:
return len(self.data["auditEvents"]) + 1
def save(self) -> None:
pass
class _CheckpointStore:
def __init__(self) -> None:
self.pairs: list[dict] = []
def create(self, *_args, **_kwargs) -> dict:
pair = {"pairId": f"pair-{len(self.pairs) + 1}"}
self.pairs.append(pair)
return pair
def get(self, pair_id: str) -> dict | None:
return next((p for p in self.pairs if p["pairId"] == pair_id), None)
class _ProjectStore:
def active_world_key(self) -> str:
return "default"
def require_active_write(self) -> None:
pass
@pytest.fixture(autouse=True)
def _clear_pending():
harness._approval_store.clear()
yield
harness._approval_store.clear()
_PLANNER = IdentityContext(2001, "planner", "Planner", "platform",
roles=("planner", "approver", "admin"))
def _stage(action: str, *, extra: dict | None = None, evidence_refs: list[str] | None = None) -> str:
params = {"versionId": 1, **(extra or {})}
token = bind_identity(_PLANNER)
try:
block = harness.stage_confirmation(
"session-evidence",
action,
params,
title="测试审批",
summary_lines=["证据链统一协议测试"],
evidence_refs=evidence_refs,
)
finally:
reset_identity(token)
return str(block.props["confirmId"])
def _approve(store, confirm_id: str, actor: str = "planner") -> str:
token = bind_identity(IdentityContext(2001, actor, "Planner", "platform",
roles=("planner", "approver", "admin")))
try:
return execute_confirmed(store, confirm_id, approve=True, actor=actor)
finally:
reset_identity(token)
def test_stage_freeze_records_evidence_contract():
"""stage_confirmation 冻结证据:显式 evidenceRefs 与 versionId 派生一致。"""
confirm_id = _stage("mes.dispatch", evidence_refs=["schedule-version:1"])
import time as _time
pending = harness._approval_store.pending_record(
confirm_id, allowed=lambda r: True, now_epoch=_time.time(),
)
assert pending is not None
assert pending["evidenceRefs"] == ["schedule-version:1"]
assert pending["params"]["versionId"] == 1
# 版本证据协议:versionId 派生
assert harness.evidence_refs_for({"versionId": 7}) == ["schedule-version:7"]
assert harness.evidence_refs_for({}) == []
def test_world_fingerprint_stable_across_append_only_journals():
"""世界指纹排除 append-only 日志/审计/同步流水;业务变化则改变指纹。"""
world = {"scheduleVersions": [{"id": 1}], "salesOrders": [], "auditEvents": [{"a": 1}]}
fp1 = harness.world_fingerprint(world)
world["auditEvents"].append({"a": 2}) # 审计增长不误报漂移
assert harness.world_fingerprint(world) == fp1
world["salesOrders"].append({"orderNo": "SO-1"}) # 业务变化触发漂移
assert harness.world_fingerprint(world) != fp1
def test_verify_pending_evidence_rejects_world_fingerprint_drift():
world = {"auditEvents": [], "logs": [], "materials": []}
pending = {
"params": {},
"evidenceRefs": [f"masterdata-world:{harness.world_fingerprint(world)}"],
}
world["materials"].append({"id": 999, "code": "DRIFT"})
with pytest.raises(PermissionError, match="输入世界已漂移"):
harness.verify_pending_evidence(pending, current_world=world)
def test_verify_pending_evidence_missing_version_evidence_rejected():
"""缺版本证据:versionId 存在但 evidenceRefs 不含 schedule-version:<id> → 拒绝。"""
pending = {
"params": {"versionId": 3},
"evidenceRefs": ["other:ref"],
}
with pytest.raises(PermissionError, match="缺少版本证据"):
harness.verify_pending_evidence(pending)
def test_verify_pending_evidence_missing_snapshot_rejected():
"""beforeSnapshot 指向的快照不存在 → 拒绝。"""
pending = {
"params": {"versionId": 1},
"evidenceRefs": ["schedule-version:1"],
"beforeSnapshot": "missing-pair",
}
cp_store = _CheckpointStore()
with pytest.raises(PermissionError, match="前置快照不存在"):
harness.verify_pending_evidence(pending, checkpoint_store=cp_store)
def test_verify_pending_evidence_version_unresolvable_rejected():
"""版本不可解析(世界带版本表但找不到 versionId)→ 拒绝。"""
pending = {"params": {"versionId": 99}, "evidenceRefs": ["schedule-version:99"]}
world = {"scheduleVersions": [{"id": 1}]}
with pytest.raises(PermissionError, match="版本不存在或已失效"):
harness.verify_pending_evidence(pending, version_lookup=lambda vid: any(v["id"] == vid for v in world["scheduleVersions"]))
def test_execute_confirmed_p2_audit_carries_before_snapshot_and_evidence():
"""P2 动作(schedule.publish)执行后审计携带 beforeSnapshot + evidenceRefs。"""
store = _AuditStore()
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
"poCount": 0, "woCount": 0, "conflictCount": 0,
"totalTardiness": 0})
store.data["productionOrders"] = []
confirm_id = _stage("schedule.publish")
_cp = _CheckpointStore()
from unittest import mock
with mock.patch("server.aps_domain.workflow.get_checkpoints", lambda: _cp):
msg = _approve(store, confirm_id)
assert "已发布" in msg
event = store.data["auditEvents"][-1]
assert event["action"] == "schedule.publish"
assert event["beforeSnapshot"] is not None and event["beforeSnapshot"].startswith("pair-")
assert event["evidenceRefs"] == ["schedule-version:1"]
def test_execute_confirmed_rejects_missing_version_evidence():
"""统一强校验 fail closed:版本证据缺失 → 拒绝且无世界写入。"""
store = _AuditStore()
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
"poCount": 0, "woCount": 0, "conflictCount": 0,
"totalTardiness": 0})
store.data["productionOrders"] = []
confirm_id = _stage("schedule.publish", evidence_refs=["wrong:ref"])
msg = _approve(store, confirm_id)
assert "证据校验未通过" in msg
assert all(e["action"] != "schedule.publish" or e["result"] == "DENIED"
for e in store.data["auditEvents"])
assert store.data["scheduleVersions"][0]["status"] == "DRAFT" # 未发布
def test_execute_confirmed_rejects_version_substitution():
"""版本替换:证据指向版本 1,但执行端版本已失效/被替换 → 拒绝。"""
store = _AuditStore()
store.data["scheduleVersions"].append({"id": 1, "versionNo": "V1", "status": "DRAFT",
"poCount": 0, "woCount": 0, "conflictCount": 0,
"totalTardiness": 0})
store.data["productionOrders"] = []
confirm_id = _stage("schedule.publish", evidence_refs=["schedule-version:1"])
# 出卡后版本被替换:原版本 1 被删除(世界带版本表但不可解析)
store.data["scheduleVersions"] = [{"id": 2, "versionNo": "V2"}]
msg = _approve(store, confirm_id)
assert "证据校验未通过" in msg or "版本不存在" in msg
def test_flex_time_update_audit_carries_before_snapshot():
"""B1 回归:flex.time.update 写世界前必须有写前快照,审计携带 beforeSnapshot。"""
store = _AuditStore()
store.data["flexRoutings"] = [
{"operationCode": "OP-1", "productCode": "P-1", "stdTimePerUnit": 5.0, "stdTimeSource": "实测"},
]
store.data["flexOperations"] = [{"code": "OP-1", "stdTimeMin": 5.0}]
params = {"operationCode": "OP-1", "productCode": "P-1", "stdMin": 9.0, "source": "实测"}
token = bind_identity(_PLANNER)
try:
block = harness.stage_confirmation(
"session-evidence", "flex.time.update", params,
title="测试审批", summary_lines=["工时维护快照绑定"],
)
finally:
reset_identity(token)
confirm_id = str(block.props["confirmId"])
_cp = _CheckpointStore()
from unittest import mock
with mock.patch("server.aps_domain.workflow.get_checkpoints", lambda: _cp):
msg = _approve(store, confirm_id)
assert "已更新" in msg
event = store.data["auditEvents"][-1]
assert event["action"] == "flex.time.update"
assert event["beforeSnapshot"] is not None and event["beforeSnapshot"].startswith("pair-")
assert event["evidenceRefs"] == []
assert store.data["flexRoutings"][0]["stdTimePerUnit"] == 9.0
def test_mes_dispatch_strong_binding_regression():
"""mes.dispatch 强绑定回归:证据与版本一致时走确认流程且无回归。"""
_AuditStore()
confirm_id = _stage("mes.dispatch", extra={"track": "flex"}, evidence_refs=["schedule-version:1"])
# P3 第一次批准不执行
from server.auth.context import bind_identity, reset_identity
token = bind_identity(IdentityContext(2001, "planner", "Planner", "platform", roles=("planner",)))
try:
first = harness.take_confirmation(confirm_id, approve=True)
finally:
reset_identity(token)
assert first is not None and first["needsSecondConfirm"] is True
# 第二次批准由不同用户完成
token2 = bind_identity(IdentityContext(2002, "peer", "Peer", "platform", roles=("planner",)))
try:
second = harness.take_confirmation(confirm_id, approve=True)
finally:
reset_identity(token2)
assert second is not None and second["needsSecondConfirm"] is False