feat(scada): require complete authorized investigation evidence
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.mcp import query_service, scada_readings
|
||||
from app.mcp.catalog import TOOLS
|
||||
from app.mcp.models import ScadaInput
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.services.operational_scada_coverage import require_complete_scada_evidence
|
||||
|
||||
|
||||
START = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _args(**extra):
|
||||
return ScadaInput(context_handle="a" * 43, start=START, end=START + timedelta(hours=1), **extra)
|
||||
|
||||
|
||||
def test_all_scada_reads_every_catalogue_and_observation_page_with_full_attributes(monkeypatch):
|
||||
devices = [{"device_id": f"D{i}", "device_type": ["pressure", "pipe_flow", "quality"][i % 3],
|
||||
"measurement_unit": ["m", "m3/h", "mg/L"][i % 3], "node_id": f"J{i}",
|
||||
"longitude": 121.5, "latitude": 31.0, "reliability": 1} for i in range(503)]
|
||||
rows = [{"device_id": f"D{i % 502}", "time": START.isoformat(),
|
||||
"monitored_value": float(i), "cleaned_value": None} for i in range(1205)]
|
||||
|
||||
async def catalog(connection, *, limit, offset):
|
||||
return devices[offset:offset + limit]
|
||||
|
||||
async def series(connection, **args):
|
||||
assert args["ids"] == [device["device_id"] for device in devices]
|
||||
assert args["field"] is None
|
||||
return rows[args["offset"]:args["offset"] + args["limit"]]
|
||||
|
||||
catalog_reader, series_reader = AsyncMock(side_effect=catalog), AsyncMock(side_effect=series)
|
||||
monkeypatch.setattr(scada_readings.reads, "scada_devices", catalog_reader)
|
||||
monkeypatch.setattr(scada_readings.reads, "series_rows", series_reader)
|
||||
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
|
||||
|
||||
@asynccontextmanager
|
||||
async def timescale(): yield object()
|
||||
|
||||
scope = SimpleNamespace(connection=object(), timescale=timescale)
|
||||
result = asyncio.run(query_service.execute_query(scope, SimpleNamespace(replay_context=None),
|
||||
TOOLS["query_scada_readings"], _args()))
|
||||
assert result.data["devices"] == devices and result.data["readings"] == rows
|
||||
assert [call.kwargs["offset"] for call in catalog_reader.await_args_list] == [0, 500]
|
||||
assert [call.kwargs["offset"] for call in series_reader.await_args_list] == [0, 1000]
|
||||
coverage = result.data["coverage"]
|
||||
assert coverage["query_complete"] is True and coverage["queried_device_count"] == 503
|
||||
assert coverage["devices_with_readings"] == 502 and coverage["missing_device_ids"] == ["D502"]
|
||||
assert coverage["reading_count"] == 1205 and len(result.units) == 503
|
||||
assert result.origin == "system_data" and result.page is None and not result.truncated
|
||||
|
||||
|
||||
def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypatch):
|
||||
devices = [{"device_id": key, "device_type": kind, "measurement_unit": unit, "node_id": key}
|
||||
for key, kind, unit in [("P1", "pressure", "m"), ("Q1", "quality", "mg/L"), ("NEW", "pressure", "m")]]
|
||||
rows = [{"device_id": "P1", "time": START.isoformat(), "monitored_value": 20.,
|
||||
"cleaned_value": 20., "quality": "valid", "reason": "冻结观测", "source_hash": "internal-hash"}]
|
||||
context = {"replay_id": str(uuid4()), "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9}
|
||||
repository = SimpleNamespace(
|
||||
get=AsyncMock(return_value={"start_time": START.isoformat(), "config": {"device_ids": ["P1", "Q1"]},
|
||||
"devices": devices[:2]}),
|
||||
detected_outputs=AsyncMock(return_value=rows),
|
||||
)
|
||||
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=devices))
|
||||
monkeypatch.setattr(scada_readings, "ScadaReplayRepository", lambda *args: repository)
|
||||
timescale = AsyncMock(side_effect=AssertionError("Frozen reads must never use live TSDB"))
|
||||
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4()), timescale=timescale)
|
||||
data, units, gaps, origin = asyncio.run(scada_readings.read_complete_scada(
|
||||
scope, SimpleNamespace(replay_context=context), _args(),
|
||||
))
|
||||
assert data["devices"] == devices
|
||||
assert data["readings"] == [{key: value for key, value in rows[0].items() if key != "source_hash"}]
|
||||
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
||||
assert data["coverage"]["missing_device_ids"] == ["Q1"]
|
||||
assert data["coverage"]["outside_frozen_scope_device_ids"] == ["NEW"]
|
||||
repository.detected_outputs.assert_awaited_once_with(
|
||||
context["replay_id"], ["P1", "Q1"], START, START + timedelta(hours=1), 9, limit=1001, offset=0,
|
||||
)
|
||||
assert origin == "synthetic" and len(gaps) == 2 and units == {"P1": "m", "Q1": "mg/L"}
|
||||
timescale.assert_not_called()
|
||||
|
||||
|
||||
def test_all_scada_empty_catalogue_does_not_query_unfiltered_timeseries(monkeypatch):
|
||||
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[]))
|
||||
timescale = AsyncMock(side_effect=AssertionError("An empty catalogue must not select every TSDB row"))
|
||||
data, units, _, _ = asyncio.run(scada_readings.read_complete_scada(
|
||||
SimpleNamespace(connection=object(), timescale=timescale), SimpleNamespace(replay_context=None), _args(),
|
||||
))
|
||||
assert data["coverage"]["queried_device_count"] == 0 and data["readings"] == [] and units == {}
|
||||
timescale.assert_not_called()
|
||||
|
||||
|
||||
def test_all_scada_rejects_offset_and_empty_explicit_selection():
|
||||
for values in ({"offset": 1}, {"device_ids": []}):
|
||||
with pytest.raises(ValueError):
|
||||
_args(**values)
|
||||
assert _args(device_ids=["P1"], offset=100).offset == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("captured,approved,explicit,direct,allowed", [
|
||||
({"scada_readings", "scada_devices"}, {"scada_readings"}, False, False, False),
|
||||
({"scada_readings"}, {"scada_readings", "scada_devices"}, False, False, False),
|
||||
({"scada_readings", "scada_devices"}, {"scada_devices"}, False, False, False),
|
||||
({"scada_readings", "scada_devices"}, {"scada_readings", "scada_devices"}, False, False, True),
|
||||
({"scada_readings"}, {"scada_readings"}, True, False, True),
|
||||
(set(), set(), False, True, True),
|
||||
])
|
||||
def test_complete_scada_requires_both_captured_and_current_source_grants(
|
||||
monkeypatch, captured, approved, explicit, direct, allowed,
|
||||
):
|
||||
@asynccontextmanager
|
||||
async def transaction():
|
||||
yield
|
||||
|
||||
can_read = AsyncMock(side_effect=lambda task_id, source: source in approved)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"scada.view"} if direct else set())
|
||||
monkeypatch.setattr(query_service, "task_service", lambda _: SimpleNamespace(can_read_source=can_read))
|
||||
binding = SimpleNamespace(
|
||||
replay_context=None, tool_cap=["query_scada_readings"],
|
||||
permission_cap=["scada.view"] if direct else [], task_id=uuid4(), task_scope_cap=captured,
|
||||
)
|
||||
scope = SimpleNamespace(project=object(), connection=SimpleNamespace(transaction=transaction))
|
||||
args = _args(**({"device_ids": ["P1"]} if explicit else {}))
|
||||
call = query_service.authorize_query(scope, binding, TOOLS["query_scada_readings"], args)
|
||||
if allowed:
|
||||
asyncio.run(call)
|
||||
else:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(call)
|
||||
assert error.value.status_code == 403
|
||||
if direct:
|
||||
can_read.assert_not_awaited()
|
||||
elif allowed:
|
||||
expected = {"scada_readings"} if explicit else {"scada_readings", "scada_devices"}
|
||||
assert {call.args[1] for call in can_read.await_args_list} == expected
|
||||
|
||||
|
||||
def test_all_scada_reports_oversize_instead_of_successfully_returning_a_partial_scan(monkeypatch):
|
||||
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[
|
||||
{"device_id": "P1", "description": "x" * 200},
|
||||
]))
|
||||
monkeypatch.setattr(scada_readings, "MAX_COMPLETE_SCADA_BYTES", 100)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(scada_readings.read_complete_scada(
|
||||
SimpleNamespace(connection=object()), SimpleNamespace(replay_context=None), _args(),
|
||||
))
|
||||
assert error.value.status_code == 413
|
||||
|
||||
|
||||
@pytest.mark.parametrize("defect", [None, "old_batch", "other_replay", "old_window", "unreferenced", "truncated"])
|
||||
def test_final_scada_evidence_must_match_the_current_frozen_scope(defect):
|
||||
replay_id = str(uuid4())
|
||||
replay = {"replay_id": replay_id, "knowledge_cutoff": START.isoformat(), "batch_sequence": 9}
|
||||
task = {"workflow": "burst_report", "source": {"replay_context": replay}}
|
||||
coverage = {"mode": "all_scada", "query_complete": True, "replay_id": replay_id, "batch_sequence": 9}
|
||||
window = {"start": (START - timedelta(hours=1)).isoformat(), "end": START.isoformat()}
|
||||
if defect == "old_batch": coverage["batch_sequence"] = 8
|
||||
if defect == "other_replay": coverage["replay_id"] = str(uuid4())
|
||||
if defect == "old_window": window["end"] = (START - timedelta(minutes=15)).isoformat()
|
||||
evidence = [{"evidence_id": "mcp:all", "data_snapshot": {
|
||||
"source": "query_scada_readings", "truncated": defect == "truncated",
|
||||
"data": {"data": {"coverage": coverage}, "window": window},
|
||||
}}]
|
||||
refs = [] if defect == "unreferenced" else ["mcp:all"]
|
||||
if defect is None:
|
||||
require_complete_scada_evidence(task, evidence, refs)
|
||||
else:
|
||||
with pytest.raises(DomainError) as error:
|
||||
require_complete_scada_evidence(task, evidence, refs)
|
||||
assert error.value.code == "SCADA_COVERAGE_REQUIRED"
|
||||
@@ -774,6 +774,34 @@ def test_scada_catalog_default_100_paginates_without_changing_other_queries(runt
|
||||
assert TOOLS["list_elements"].model(context_handle=value.context_handle, element_type="pipe").limit == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("complete", [False, True])
|
||||
def test_complete_scada_input_is_not_replaced_by_12kb_preview_but_details_stay_bounded(runtime, complete):
|
||||
app, client, _, _ = runtime
|
||||
value = binding()
|
||||
data = {"devices": [{"device_id": f"D{index}", "node_id": f"J{index}", "description": "x" * 100}
|
||||
for index in range(168)],
|
||||
"readings": [{"device_id": f"D{index}", "monitored_value": 20., "cleaned_value": 19.}
|
||||
for index in range(168)],
|
||||
"coverage": {"mode": "all_scada", "query_complete": True, "queried_device_count": 168}}
|
||||
assert len(json.dumps(data).encode()) > 12000
|
||||
app.executor = AsyncMock(return_value=QueryResult(
|
||||
source="query_scada_readings", source_ref="mcp:all-scada", queried_at=datetime.now(timezone.utc),
|
||||
semantics="observed", data=data,
|
||||
))
|
||||
arguments = {"context_handle": value.context_handle, "start": "2026-07-16T05:00:00+08:00",
|
||||
"end": "2026-07-16T08:00:00+08:00"}
|
||||
if not complete:
|
||||
arguments["device_ids"] = ["D1"]
|
||||
response = request(client, "tools/call", {"name": "query_scada_readings", "arguments": arguments}).json()["result"]
|
||||
if complete:
|
||||
assert not response.get("isError"), response
|
||||
assert response["structuredContent"]["data"] == data
|
||||
assert response["structuredContent"]["truncated"] is False
|
||||
else:
|
||||
assert response["isError"] is True
|
||||
assert response["structuredContent"]["error"]["code"] == "RESULT_TOO_LARGE"
|
||||
|
||||
|
||||
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
|
||||
app, client, _, _ = runtime
|
||||
value = binding(task=True)
|
||||
|
||||
@@ -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