feat(scada): require complete authorized investigation evidence
Generic Container CI/CD / test-build-publish (push) Successful in 5m52s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m52s

This commit is contained in:
2026-11-07 21:15:01 +08:00
parent 2552a9b40d
commit 06d8539aa8
12 changed files with 498 additions and 30 deletions
+74 -16
View File
@@ -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)