1040 lines
33 KiB
Python
1040 lines
33 KiB
Python
|
|
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
|