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
+179
View File
@@ -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"
+28
View File
@@ -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)
+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)