2026-08-11 00:54:05 +08:00
|
|
|
|
# 证据链统一协议(§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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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
|