feat(scada): require complete authorized investigation evidence
This commit is contained in:
@@ -577,7 +577,7 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_replay_investigation_can_publish_evidenced_composite_chart_but_cannot_propose_real_action():
|
||||
async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
@@ -602,35 +602,48 @@ async def test_replay_investigation_can_publish_evidenced_composite_chart_but_ca
|
||||
))
|
||||
assert result.recorded
|
||||
assert repository.events[-1]["metadata"]["command"]["chart"]["series"][1]["data"] == [None, 28.]
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="演示不得创建真实工单",
|
||||
payload={"title": "现场核查", "description": "核查", "work_order_type": "general"},
|
||||
))
|
||||
assert caught.value.code == "REPLAY_ACTION_DISABLED"
|
||||
_seed_concluded_report(repository, now)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="候选管段待现场核验",
|
||||
evidence_refs=[evidence.evidence_id],
|
||||
payload={"title": "现场核查", "description": "核查", "work_order_type": "general"},
|
||||
))
|
||||
assert action.approval_status == "pending"
|
||||
assert action.execution_status == "not_submitted"
|
||||
assert repository.action["action_id"] == str(action.action_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("operation", ["draft", "approve", "submit"])
|
||||
async def test_replay_cannot_edit_approve_or_submit_preexisting_real_actions(operation):
|
||||
@pytest.mark.parametrize("operation", ["propose", "approve", "submit"])
|
||||
async def test_replay_cannot_propose_approve_or_submit_device_commands(operation):
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task["source"]["replay_context"] = {
|
||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||
}
|
||||
action = HumanApprovalAction(
|
||||
action_id=uuid4(), task_id=task["task_id"], version=1, kind="issue_command",
|
||||
reason="关阀", payload={"command_type": "close_valve", "target": {"kind": "valve", "id": "V1"},
|
||||
"parameters": {}, "impact_summary": "隔离候选管段", "preconditions": ["现场核验"],
|
||||
"expires_at": now + timedelta(minutes=5)},
|
||||
evidence_refs=[], required_approval_permission="command.approve",
|
||||
required_execution_permission="command.execute", approval_status="pending",
|
||||
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
||||
)
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
repository.action = action.model_dump(mode="json")
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
||||
args = {"request_id": uuid4(), "expected_version": 1}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
if operation == "draft":
|
||||
await service.update_work_order_draft(task["task_id"], uuid4(), WorkOrderDraftUpdateCommand(
|
||||
**args, payload={"title": "现场核查", "description": "核查", "work_order_type": "general"}))
|
||||
if operation == "propose":
|
||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="issue_command", reason=action.reason, payload=action.payload))
|
||||
elif operation == "approve":
|
||||
await service.decide_action(task["task_id"], uuid4(), ActionDecisionCommand(**args, decision="approved"))
|
||||
await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(**args, decision="approved"))
|
||||
else:
|
||||
await service.submit_action(task["task_id"], uuid4(), ActionExecuteCommand(**args), dispatcher, None)
|
||||
await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(**args), dispatcher, None)
|
||||
assert caught.value.code == "REPLAY_ACTION_DISABLED"
|
||||
dispatcher.dispatch.assert_not_awaited()
|
||||
|
||||
@@ -965,6 +978,36 @@ def _current_report():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("complete", [False, True])
|
||||
async def test_scada_final_report_requires_complete_current_input_not_selected_device_evidence(complete):
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task["workflow"] = "scada_anomaly"
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
||||
"mode": "all_scada", "query_complete": True,
|
||||
}} if complete else [{"device_id": "P1", "monitored_value": 20.}]
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="query_scada_readings", source_ref="mcp:scada-coverage", queried_at=now,
|
||||
origin="system_data", truncated=False,
|
||||
data={"data": data, "window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()}},
|
||||
))
|
||||
command = AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
report={**_current_report(), "evidence_refs": [evidence.evidence_id]},
|
||||
)
|
||||
if complete:
|
||||
result = await service.publish_artifacts(task["task_id"], command)
|
||||
assert result.task.latest_report.report.stage == "final"
|
||||
else:
|
||||
with pytest.raises(DomainError) as error:
|
||||
await service.publish_artifacts(task["task_id"], command)
|
||||
assert error.value.code == "SCADA_COVERAGE_REQUIRED" and not repository.reports
|
||||
|
||||
|
||||
def _insufficient_report():
|
||||
return {
|
||||
**_current_report(), "outcome": "insufficient_data",
|
||||
@@ -1684,10 +1727,15 @@ async def test_completed_gate_requires_access_request_decision():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_order_draft_edit_creates_new_review_version():
|
||||
@pytest.mark.parametrize("replay", [False, True])
|
||||
async def test_work_order_draft_edit_creates_new_review_version(replay):
|
||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
if replay:
|
||||
task["source"]["replay_context"] = {
|
||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||
}
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
||||
_seed_concluded_report(repository, now)
|
||||
@@ -1710,10 +1758,15 @@ async def test_work_order_draft_edit_creates_new_review_version():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch():
|
||||
@pytest.mark.parametrize("is_replay", [False, True])
|
||||
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch(is_replay):
|
||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
if is_replay:
|
||||
task["source"]["replay_context"] = {
|
||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||
}
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, {
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
@@ -1732,6 +1785,11 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
||||
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
|
||||
|
||||
dispatcher = Dispatcher()
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await service.submit_action(task["task_id"], action.action_id,
|
||||
ActionExecuteCommand(request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||
assert caught.value.code == "INVALID_TRANSITION"
|
||||
assert dispatcher.calls == 0
|
||||
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
|
||||
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
|
||||
command, dispatcher, None)
|
||||
|
||||
Reference in New Issue
Block a user