aps-agent/tests/golden/test_sap_outbound_evidence.py

1040 lines
33 KiB
Python
Raw Normal View History

from __future__ import annotations
import copy
import inspect
from pathlib import Path
from types import SimpleNamespace
import pytest
from server.agent_core import harness
from server.aps_domain import sap_sync
from server.integrations.sap_stub import reset_sap_client
class _MemStore:
def __init__(self, data: dict):
self.data = data
self.saved = 0
def next_id(self, kind: str) -> int:
key = f"_counter_{kind}"
self.data[key] = int(self.data.get(key) or 0) + 1
return self.data[key]
def save(self) -> None:
self.saved += 1
class _NoPushClient:
def __init__(self) -> None:
self.push_calls = 0
def status(self) -> dict:
return {"system": "MOCK-SAP", "plant": "TEST", "connected": True}
def push_receipt(self, *_args, **_kwargs):
self.push_calls += 1
raise AssertionError("preview/stage must not call push_receipt")
def _outbound_world(candidate_count: int = 51) -> dict:
orders = []
work_orders = []
for index in range(candidate_count):
order_no = f"FO-{index:03d}"
orders.append(
{
"id": index + 1,
"orderNo": order_no,
"externalAufnr": f"AUF-{index:03d}",
"quantity": index + 10,
}
)
work_orders.append(
{
"id": f"WO-{index:03d}",
"versionId": 7,
"orderNo": order_no,
"flexOrderNo": order_no,
"operationCode": f"OP-{index:03d}",
"equipmentCode": f"EQ-{index % 3}",
"plannedStartTime": f"2026-08-{(index % 28) + 1:02d} 08:00:00",
"plannedEndTime": f"2026-08-{(index % 28) + 1:02d} 09:00:00",
"frozen": False,
}
)
return {
"scheduleVersions": [
{"id": 7, "versionNo": "SV-0007", "status": "PUBLISHED"}
],
"flexOrders": orders,
"flexWorkOrders": work_orders,
"sapLinks": [],
"sapSyncJournal": [],
"auditEvents": [],
}
def test_outbound_projection_is_pure_deterministic_and_detects_51st_candidate_drift(
monkeypatch,
):
builder = getattr(sap_sync, "build_outbound_projection", None)
assert callable(builder), "Round 68 must expose build_outbound_projection(world, *, limit=50)"
monkeypatch.setattr(
sap_sync,
"get_sap_client",
lambda: (_ for _ in ()).throw(AssertionError("projection must not touch SAP client")),
)
world = _outbound_world(51)
before = copy.deepcopy(world)
first = builder(world, limit=50)
assert world == before
reordered = copy.deepcopy(world)
reordered["flexOrders"].reverse()
reordered["flexWorkOrders"].reverse()
second = builder(reordered, limit=50)
for key in (
"items",
"idemKeys",
"payloadDigest",
"worldFingerprint",
"fullCandidateDigest",
"totalCount",
"limit",
):
assert first[key] == second[key]
assert first["totalCount"] == 51
assert first["limit"] == 50
assert len(first["items"]) == 50
selected_ids = {item["woId"] for item in first["items"]}
hidden = next(wo for wo in world["flexWorkOrders"] if wo["id"] not in selected_ids)
drifted = copy.deepcopy(world)
next(wo for wo in drifted["flexWorkOrders"] if wo["id"] == hidden["id"])[
"equipmentCode"
] = "EQ-DRIFT"
changed = builder(drifted, limit=50)
assert changed["payloadDigest"] == first["payloadDigest"]
assert changed["fullCandidateDigest"] != first["fullCandidateDigest"]
assert changed["worldFingerprint"] != first["worldFingerprint"]
def test_apply_outbound_requires_complete_keyword_only_p3_envelope(tmp_path: Path):
required = {
"actor",
"confirm_id",
"execution_grant",
"approval_params",
"requester",
"approvals",
"before_snapshot",
"checkpoint_store",
}
signature = inspect.signature(sap_sync.apply_outbound)
missing = required - set(signature.parameters)
assert not missing, f"apply_outbound missing required P3 envelope fields: {sorted(missing)}"
for name in required:
parameter = signature.parameters[name]
assert parameter.kind is inspect.Parameter.KEYWORD_ONLY
assert parameter.default is inspect.Parameter.empty
reset_sap_client(tmp_path / "sap-mirror.json")
with pytest.raises(TypeError):
sap_sync.apply_outbound(_MemStore(_outbound_world(1)))
def test_outbound_stage_freezes_checkpoint_and_canonical_p3_params_without_push(
monkeypatch,
):
client = _NoPushClient()
store = _MemStore(_outbound_world(2))
captured: dict = {}
class _CheckpointStore:
def create(self, world, **kwargs):
captured["checkpoint_world"] = copy.deepcopy(world)
captured["checkpoint_kwargs"] = kwargs
return {"pairId": "pair-stage-001"}
def fake_stage(session_id, action, params, title, summary_lines, **kwargs):
captured.update(
session_id=session_id,
action=action,
params=copy.deepcopy(params),
title=title,
summary_lines=list(summary_lines),
stage_kwargs=copy.deepcopy(kwargs),
)
return SimpleNamespace(props={"confirmId": "confirm-stage-001"})
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: client)
monkeypatch.setattr("server.state.checkpoints.get_checkpoints", lambda: _CheckpointStore())
monkeypatch.setattr(harness, "stage_confirmation", fake_stage)
monkeypatch.setattr("server.agent_core.audit.write_audit", lambda *_a, **_k: None)
result = sap_sync.stage_sap_sync(
store, "outbound", session_id="session-r68", actor="requester"
)
assert result["staged"] is True
assert client.push_calls == 0
assert harness.power_of("sap.sync.outbound") == "P3"
assert captured["action"] == "sap.sync.outbound"
params = captured["params"]
assert params["direction"] == "outbound"
assert params["beforeSnapshot"] == "pair-stage-001"
for key in (
"versionId",
"versionNo",
"items",
"idemKeys",
"payloadDigest",
"worldFingerprint",
"fullCandidateDigest",
"totalCount",
"limit",
"evidenceRefs",
):
assert key in params
assert captured["stage_kwargs"]["before_snapshot"] == "pair-stage-001"
assert captured["stage_kwargs"]["evidence_refs"] == params["evidenceRefs"]
class _MemoryCheckpointStore:
def __init__(self) -> None:
self.records: dict[str, dict] = {}
def create(self, world: dict, **metadata) -> dict:
pair_id = f"pair-r68-{len(self.records) + 1:03d}"
record = {
"pairId": pair_id,
"world": copy.deepcopy(world),
**copy.deepcopy(metadata),
}
self.records[pair_id] = record
return copy.deepcopy(record)
def get(self, pair_id: str) -> dict | None:
record = self.records.get(str(pair_id))
return copy.deepcopy(record) if record is not None else None
class _ScopedMemStore(_MemStore):
def __init__(self, data: dict):
super().__init__(data)
self.world_key = "personal-1001"
self.tenant_uuid = "tenant-r68"
class _FailingOnceSapClient:
def __init__(self, backing, *, fail_on_call: int):
self.backing = backing
self.fail_on_call = fail_on_call
self.push_calls = 0
def status(self) -> dict:
return self.backing.status()
def push_receipt(self, payload: dict, idem_key: str) -> dict:
self.push_calls += 1
if self.push_calls == self.fail_on_call:
raise RuntimeError(f"synthetic SAP failure on call {self.push_calls}")
return self.backing.push_receipt(payload, idem_key)
def _as_user(user_id: int, username: str, callback):
from server.auth.context import IdentityContext, bind_identity, reset_identity
token = bind_identity(
IdentityContext(
user_id,
username,
username,
"tenant-r68",
roles=("planner",),
)
)
try:
return callback()
finally:
reset_identity(token)
@pytest.fixture()
def _isolated_p3_runtime(tmp_path: Path, monkeypatch):
import server.aps_domain.workflow as workflow_module
class _ProjectStore:
def active_world_key(self) -> str:
return "default"
original_approval_store = harness._approval_store
harness.configure_approval_store(path=str(tmp_path / "approvals.json"))
checkpoints = _MemoryCheckpointStore()
client = reset_sap_client(tmp_path / "sap-mirror.json")
monkeypatch.setattr("server.state.checkpoints.get_checkpoints", lambda: checkpoints)
monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints)
monkeypatch.setattr("server.state.projects.get_project_store", lambda: _ProjectStore())
monkeypatch.setattr(harness, "_capture_world_fingerprint", lambda *_args: None)
monkeypatch.setattr(
"server.agent_core.plan_orchestration.stage_plan_node",
lambda **_kwargs: None,
)
monkeypatch.setattr(
"server.agent_core.plan_orchestration.decide_plan_node",
lambda **_kwargs: None,
)
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: client)
try:
yield SimpleNamespace(checkpoints=checkpoints, client=client, tmp_path=tmp_path)
finally:
harness.configure_approval_store(store=original_approval_store)
def _stage_outbound(store: _ScopedMemStore) -> tuple[str, dict]:
staged = _as_user(
1001,
"requester",
lambda: sap_sync.stage_sap_sync(
store,
"outbound",
session_id="session-r68",
actor="requester",
),
)
assert staged["staged"] is True
confirm_id = str(staged["block"].props["confirmId"])
pending = copy.deepcopy(harness._pending[confirm_id])
return confirm_id, pending
def _approve_twice(confirm_id: str) -> dict:
first = _as_user(
1001,
"requester",
lambda: harness.take_confirmation(confirm_id, approve=True),
)
assert first is not None
assert first["needsSecondConfirm"] is True
assert first["approvalStep"] == 1
final = _as_user(
2002,
"approver-2",
lambda: harness.take_confirmation(confirm_id, approve=True),
)
assert final is not None
assert final["needsSecondConfirm"] is False
assert final["approvalStep"] == 2
assert final.get("executionGrant")
return final
def _apply_decision(
store: _ScopedMemStore,
checkpoints: _MemoryCheckpointStore,
confirm_id: str,
decision: dict,
*,
approval_params: dict | None = None,
before_snapshot: str | None = None,
):
params = copy.deepcopy(approval_params or decision["params"])
snapshot = str(before_snapshot or decision["beforeSnapshot"])
return _as_user(
2002,
"approver-2",
lambda: sap_sync.apply_outbound(
store,
actor="approver-2",
confirm_id=confirm_id,
execution_grant=str(decision["executionGrant"]),
approval_params=params,
requester=copy.deepcopy(decision["requester"]),
approvals=copy.deepcopy(decision["approvals"]),
before_snapshot=snapshot,
checkpoint_store=checkpoints,
),
)
def test_real_p3_workflow_pushes_only_after_two_distinct_approvers(
_isolated_p3_runtime,
):
from server.aps_domain.workflow import execute_confirmed
store = _ScopedMemStore(_outbound_world(2))
confirm_id, _pending = _stage_outbound(store)
client = _isolated_p3_runtime.client
first = _as_user(
1001,
"requester",
lambda: execute_confirmed(
store,
confirm_id,
approve=True,
actor="requester",
),
)
assert "第一重确认" in first
assert client.status()["receiptCount"] == 0
assert _as_user(
1001,
"requester",
lambda: harness.is_confirmation_pending(confirm_id),
) is True
same_user = _as_user(
1001,
"requester",
lambda: execute_confirmed(
store,
confirm_id,
approve=True,
actor="requester",
),
)
assert "另一名用户" in same_user
assert client.status()["receiptCount"] == 0
assert _as_user(
1001,
"requester",
lambda: harness.is_confirmation_pending(confirm_id),
) is True
final = _as_user(
2002,
"approver-2",
lambda: execute_confirmed(
store,
confirm_id,
approve=True,
actor="approver-2",
),
)
assert "SAP 出站完成" in final
assert client.status()["receiptCount"] == 2
assert _as_user(
2002,
"approver-2",
lambda: harness.is_confirmation_pending(confirm_id),
) is False
execution = store.data["sapOutboundExecutions"][-1]
assert execution["status"] == "SUCCEEDED"
assert [approval["userId"] for approval in execution["approvals"]] == [
"1001",
"2002",
]
def test_51st_candidate_drift_fails_closed_without_consuming_grant(
_isolated_p3_runtime,
):
store = _ScopedMemStore(_outbound_world(51))
confirm_id, pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
approved_ids = {item["woId"] for item in pending["params"]["items"]}
hidden = next(
work_order
for work_order in store.data["flexWorkOrders"]
if work_order["id"] not in approved_ids
)
original_equipment = hidden["equipmentCode"]
hidden["equipmentCode"] = "EQ-51ST-DRIFT"
with pytest.raises(PermissionError, match="漂移"):
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
assert store.data.get("sapOutboundExecutions") in (None, [])
hidden["equipmentCode"] = original_equipment
recovered = _apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
assert recovered["execution"]["status"] == "SUCCEEDED"
assert _isolated_p3_runtime.client.status()["receiptCount"] == 50
@pytest.mark.parametrize("substitution", ["params", "evidence", "snapshot"])
def test_p3_envelope_substitution_is_rejected_before_push(
_isolated_p3_runtime,
substitution: str,
):
store = _ScopedMemStore(_outbound_world(2))
confirm_id, _pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
substituted = copy.deepcopy(decision["params"])
before_snapshot = str(decision["beforeSnapshot"])
if substitution == "params":
store.data["scheduleVersions"][-1]["status"] = "DISPATCHED"
current = sap_sync.build_outbound_projection(store.data, limit=50)
replacement_checkpoint = _isolated_p3_runtime.checkpoints.create(
store.data,
label="substituted params",
reason="test",
)
substituted = {**current, "beforeSnapshot": replacement_checkpoint["pairId"]}
before_snapshot = str(replacement_checkpoint["pairId"])
elif substitution == "evidence":
substituted["evidenceRefs"] = [
substituted["evidenceRefs"][0],
"sap-outbound-payload:substituted",
substituted["evidenceRefs"][2],
]
else:
replacement_checkpoint = _isolated_p3_runtime.checkpoints.create(
store.data,
label="substituted snapshot",
reason="test",
)
substituted["beforeSnapshot"] = replacement_checkpoint["pairId"]
before_snapshot = str(replacement_checkpoint["pairId"])
with pytest.raises(PermissionError):
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
approval_params=substituted,
before_snapshot=before_snapshot,
)
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
def test_action_substituted_execution_grant_is_rejected_before_push(
_isolated_p3_runtime,
):
store = _ScopedMemStore(_outbound_world(2))
_outbound_confirm_id, pending = _stage_outbound(store)
mes_block = _as_user(
1001,
"requester",
lambda: harness.stage_confirmation(
"session-r68-action-substitution",
"mes.dispatch",
copy.deepcopy(pending["params"]),
title="action substitution",
summary_lines=["must not authorize SAP outbound"],
evidence_refs=copy.deepcopy(pending["evidenceRefs"]),
before_snapshot=str(pending["beforeSnapshot"]),
),
)
mes_confirm_id = str(mes_block.props["confirmId"])
mes_decision = _approve_twice(mes_confirm_id)
with pytest.raises(PermissionError, match="最终批准凭据"):
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
mes_confirm_id,
mes_decision,
)
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
assert store.data["sapOutboundExecutions"][-1]["status"] == "DENIED"
def test_successful_outbound_execution_replay_is_rejected_without_second_push(
_isolated_p3_runtime,
):
store = _ScopedMemStore(_outbound_world(2))
confirm_id, _pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
first = _apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
assert first["execution"]["status"] == "SUCCEEDED"
assert _isolated_p3_runtime.client.status()["receiptCount"] == 2
with pytest.raises(PermissionError, match="重放"):
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
assert _isolated_p3_runtime.client.status()["receiptCount"] == 2
assert len(store.data["sapOutboundExecutions"]) == 1
def test_partial_failure_persists_item_states_and_new_confirmation_recovery_chain(
_isolated_p3_runtime,
monkeypatch,
):
store = _ScopedMemStore(_outbound_world(3))
confirm_id, _pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
backing = _isolated_p3_runtime.client
failing = _FailingOnceSapClient(backing, fail_on_call=2)
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: failing)
with pytest.raises(RuntimeError, match="synthetic SAP failure"):
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
failed = store.data["sapOutboundExecutions"][-1]
assert failed["status"] == "PARTIAL_FAILED"
assert [item["status"] for item in failed["items"]] == [
"PUSHED",
"FAILED",
"PENDING",
]
assert backing.status()["receiptCount"] == 1
assert store.data["auditEvents"][-1]["result"] == "FAILED"
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: backing)
recovery_confirm_id, _recovery_pending = _stage_outbound(store)
recovery_decision = _approve_twice(recovery_confirm_id)
recovered = _apply_decision(
store,
_isolated_p3_runtime.checkpoints,
recovery_confirm_id,
recovery_decision,
)
recovery = recovered["execution"]
assert recovery["previousExecutionId"] == failed["id"]
assert recovery["status"] == "SUCCEEDED"
assert [item["status"] for item in recovery["items"]] == [
"DUPLICATE",
"PUSHED",
"PUSHED",
]
assert backing.status()["receiptCount"] == 3
def test_preview_and_stage_use_temp_mirror_without_push(
_isolated_p3_runtime,
):
store = _ScopedMemStore(_outbound_world(2))
client = _isolated_p3_runtime.client
assert client.path.parent == _isolated_p3_runtime.tmp_path
assert client.status()["receiptCount"] == 0
preview = sap_sync.preview_outbound(store.data)
assert preview["selectedCount"] == 2
assert client.status()["receiptCount"] == 0
confirm_id, _pending = _stage_outbound(store)
assert confirm_id
assert client.status()["receiptCount"] == 0
_ROUND68_NATIVE_REQUIRE_CAN_INITIATE = getattr(
harness, "require_can_initiate", None
)
@pytest.fixture(autouse=True)
def _round68_permission_preflight_adapter(monkeypatch):
"""Keep deeper contract tests runnable while a missing public preflight stays visible."""
if callable(_ROUND68_NATIVE_REQUIRE_CAN_INITIATE):
yield
return
def require_can_initiate(action: str) -> None:
power = harness.power_of(action)
if not harness._can_initiate_current(action, power):
raise PermissionError("当前身份没有发起审批的角色权限")
monkeypatch.setattr(
harness,
"require_can_initiate",
require_can_initiate,
raising=False,
)
yield
def _as_identity(
user_id: int,
username: str,
roles: tuple[str, ...],
callback,
):
from server.auth.context import IdentityContext, bind_identity, reset_identity
token = bind_identity(
IdentityContext(
user_id,
username,
username,
"tenant-r68",
roles=roles,
)
)
try:
return callback()
finally:
reset_identity(token)
class _AuditedCheckpointStore(_MemoryCheckpointStore):
def __init__(self) -> None:
super().__init__()
self.created: list[str] = []
self.deleted: list[str] = []
def create(self, world: dict, **metadata) -> dict:
record = super().create(world, **metadata)
self.created.append(str(record["pairId"]))
return record
def delete(self, pair_id: str) -> bool:
pair_id = str(pair_id)
existed = pair_id in self.records
self.records.pop(pair_id, None)
self.deleted.append(pair_id)
return existed
class _ReadyWorldDriftStore(_ScopedMemStore):
def __init__(self, data: dict, drift_kind: str):
super().__init__(data)
self.drift_kind = drift_kind
self.drifted = False
def save(self) -> None:
super().save()
executions = self.data.get("sapOutboundExecutions") or []
if self.drifted or not executions or executions[-1].get("status") != "READY":
return
if self.drift_kind == "qty":
self.data["flexOrders"][0]["quantity"] += 1000
elif self.drift_kind == "aufnr":
self.data["flexOrders"][0]["externalAufnr"] = "AUF-READY-DRIFT"
else:
self.data["flexWorkOrders"][0]["operationCode"] = "OP-READY-DRIFT"
self.drifted = True
class _RemoteAcceptedSaveFailureStore(_ScopedMemStore):
def __init__(self, data: dict):
super().__init__(data)
self.failed_after_remote_accept = False
def save(self) -> None:
self.saved += 1
if self.failed_after_remote_accept:
return
executions = self.data.get("sapOutboundExecutions") or []
if not executions:
return
states = [item.get("status") for item in executions[-1].get("items") or []]
if self.data.get("sapLinks") and any(
status in {"PUSHED", "DUPLICATE"} for status in states
):
self.failed_after_remote_accept = True
raise OSError("synthetic local commit failure after SAP accepted receipt")
@pytest.mark.parametrize("field", ["orderNo", "flexOrderNo"])
def test_projection_fingerprint_captures_raw_and_flex_order_number_drift(field: str):
world = _outbound_world(1)
baseline = sap_sync.build_outbound_projection(world, limit=50)
drifted = copy.deepcopy(world)
drifted["flexWorkOrders"][0][field] = f"{field}-DRIFT"
changed = sap_sync.build_outbound_projection(drifted, limit=50)
assert changed["fullCandidateDigest"] != baseline["fullCandidateDigest"]
assert changed["worldFingerprint"] != baseline["worldFingerprint"]
if field == "orderNo":
assert changed["payloadDigest"] == baseline["payloadDigest"]
else:
assert changed["payloadDigest"] != baseline["payloadDigest"]
@pytest.mark.parametrize("drift_kind", ["qty", "aufnr", "work_order"])
def test_ready_boundary_world_drift_is_rejected_before_grant_consume_and_push(
_isolated_p3_runtime,
monkeypatch,
drift_kind: str,
):
store = _ReadyWorldDriftStore(_outbound_world(1), drift_kind)
confirm_id, _pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
original_consume = harness.consume_execution_grant
consume_calls: list[str] = []
def consume_spy(token: str, **kwargs) -> bool:
consume_calls.append(token)
return original_consume(token, **kwargs)
monkeypatch.setattr(harness, "consume_execution_grant", consume_spy)
denied: PermissionError | None = None
try:
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
except PermissionError as exc:
denied = exc
assert store.drifted is True
assert consume_calls == [], "READY 后漂移必须在 execution grant 消费前拒绝"
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
assert denied is not None
def test_remote_accept_then_local_save_failure_requires_reconciliation_state(
_isolated_p3_runtime,
):
store = _RemoteAcceptedSaveFailureStore(_outbound_world(1))
confirm_id, _pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
try:
_apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
except (OSError, RuntimeError) as exc:
assert "本地提交失败" in str(exc) or "local commit failure" in str(exc)
assert store.failed_after_remote_accept is True
assert _isolated_p3_runtime.client.status()["receiptCount"] == 1
execution = store.data["sapOutboundExecutions"][-1]
statuses = {str(execution.get("status"))}
statuses.update(str(item.get("status")) for item in execution.get("items") or [])
assert "FAILED" not in statuses
assert {
"REMOTE_ACCEPTED_LOCAL_COMMIT_FAILED",
"RECONCILE_REQUIRED",
}.issubset(statuses)
idem_key = str(execution["items"][0]["idemKey"])
outbound_events = [
event
for event in store.data.get("auditEvents") or []
if event.get("action") == "sap.sync.outbound"
]
assert outbound_events
rationale = outbound_events[-1].get("rationale") or {}
accepted = set(rationale.get("pushedIdemKeys") or [])
accepted.update(rationale.get("remoteAcceptedIdemKeys") or [])
failed = set(rationale.get("failedIdemKeys") or [])
if rationale.get("failedIdemKey"):
failed.add(str(rationale["failedIdemKey"]))
assert idem_key in accepted
assert idem_key not in failed
def test_unauthorized_outbound_stage_leaves_no_checkpoint_or_world_write(monkeypatch):
checkpoints = _AuditedCheckpointStore()
client = _NoPushClient()
store = _ScopedMemStore(_outbound_world(1))
monkeypatch.setattr(
"server.state.checkpoints.get_checkpoints",
lambda: checkpoints,
)
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: client)
if not callable(_ROUND68_NATIVE_REQUIRE_CAN_INITIATE):
monkeypatch.delattr(harness, "require_can_initiate", raising=False)
with pytest.raises(PermissionError, match="发起审批"):
_as_identity(
3003,
"viewer",
("viewer",),
lambda: sap_sync.stage_sap_sync(
store,
"outbound",
session_id="session-r68-unauthorized",
actor="viewer",
),
)
assert checkpoints.records == {}
assert checkpoints.created == []
assert checkpoints.deleted == []
assert store.saved == 0
assert client.push_calls == 0
@pytest.mark.parametrize("failure_point", ["status", "approval_stage"])
def test_outbound_stage_failure_paths_leave_no_checkpoint_pollution(
monkeypatch,
failure_point: str,
):
checkpoints = _AuditedCheckpointStore()
store = _ScopedMemStore(_outbound_world(1))
client = _NoPushClient()
monkeypatch.setattr(
"server.state.checkpoints.get_checkpoints",
lambda: checkpoints,
)
monkeypatch.setattr(sap_sync, "get_sap_client", lambda: client)
if failure_point == "status":
monkeypatch.setattr(
client,
"status",
lambda: (_ for _ in ()).throw(RuntimeError("synthetic status failure")),
)
else:
monkeypatch.setattr(
harness,
"stage_confirmation",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
RuntimeError("synthetic approval stage failure")
),
)
with pytest.raises(RuntimeError, match="synthetic"):
_as_identity(
1001,
"requester",
("planner",),
lambda: sap_sync.stage_sap_sync(
store,
"outbound",
session_id=f"session-r68-{failure_point}",
actor="requester",
),
)
assert checkpoints.records == {}
if failure_point == "status":
assert checkpoints.created == []
assert checkpoints.deleted == []
else:
assert checkpoints.created == checkpoints.deleted
assert len(checkpoints.created) == 1
assert store.saved == 0
assert client.push_calls == 0
def test_preview_outbound_preserves_sparse_world_byte_for_byte(
_isolated_p3_runtime,
):
world = {"auditEvents": [{"id": 1, "action": "existing"}]}
before = copy.deepcopy(world)
preview = sap_sync.preview_outbound(world)
assert world == before
assert preview["versionId"] is None
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
def test_success_audit_records_before_snapshot_and_grant_consumption(
_isolated_p3_runtime,
):
store = _ScopedMemStore(_outbound_world(1))
confirm_id, pending = _stage_outbound(store)
decision = _approve_twice(confirm_id)
result = _apply_decision(
store,
_isolated_p3_runtime.checkpoints,
confirm_id,
decision,
)
assert result["execution"]["status"] == "SUCCEEDED"
event = next(
event
for event in reversed(store.data["auditEvents"])
if event.get("action") == "sap.sync.outbound"
and event.get("result") == "SUCCESS"
)
assert event["beforeSnapshot"] == pending["beforeSnapshot"]
assert event["rationale"]["confirmId"] == confirm_id
assert event["rationale"].get("grantConsumed") is True
def test_http_boundary_pushes_only_after_second_distinct_approver(
_isolated_p3_runtime,
monkeypatch,
):
from fastapi.testclient import TestClient
import server.gateway.app as gateway_module
from tests.auth_provider import install_test_auth
class _WritableProjectStore:
def active_world_key(self) -> str:
return "default"
def require_active_write(self) -> None:
return None
store = _ScopedMemStore(_outbound_world(2))
checkpoints = _isolated_p3_runtime.checkpoints
install_test_auth(monkeypatch, "tenant-r68")
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: checkpoints)
monkeypatch.setattr(
"server.state.projects.get_project_store",
lambda: _WritableProjectStore(),
)
with TestClient(gateway_module.create_app()) as client:
login = client.post(
"/api/auth/login",
json={"username": "planner", "password": "test"},
)
assert login.status_code == 200, login.text
preview = client.get("/api/sap/outbound/preview")
assert preview.status_code == 200, preview.text
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
staged = client.post(
"/api/sap/outbound/stage",
json={"sessionId": "session-r68-http"},
)
assert staged.status_code == 200, staged.text
confirm_id = str(staged.json()["block"]["props"]["confirmId"])
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
first = client.post(
"/api/actions/confirm",
json={
"sessionId": "session-r68-http",
"confirmId": confirm_id,
"approve": True,
},
)
assert first.status_code == 200, first.text
assert first.json()["secondConfirmRequired"] is True
assert _isolated_p3_runtime.client.status()["receiptCount"] == 0
second_login = client.post(
"/api/auth/login",
json={"username": "collaborator", "password": "test"},
)
assert second_login.status_code == 200, second_login.text
second = client.post(
"/api/actions/confirm",
json={
"sessionId": "session-r68-http",
"confirmId": confirm_id,
"approve": True,
},
)
assert second.status_code == 200, second.text
assert second.json()["secondConfirmRequired"] is False
assert _isolated_p3_runtime.client.status()["receiptCount"] == 2