126 lines
6.8 KiB
Python
126 lines
6.8 KiB
Python
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.v1.endpoints import operational_tasks as endpoints
|
|
from app.auth.project_dependencies import ProjectContext
|
|
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
|
from app.domain.schemas.operational_task import EvidenceRecord
|
|
from app.domain.schemas.operational_task import TaskPresentationEventCommand
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
def _service_context():
|
|
return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test",
|
|
project_role="member", system_role="service")
|
|
|
|
|
|
def test_dynamic_investigation_routes_are_in_the_public_contract():
|
|
paths = app.openapi()["paths"]
|
|
assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests"]
|
|
assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision"]
|
|
assert "patch" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft"]
|
|
assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"]
|
|
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
|
|
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
|
|
assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
|
|
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"]
|
|
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
|
|
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
|
from app.domain.domain_errors import DomainError
|
|
|
|
service = SimpleNamespace(
|
|
publish_presentation_event=AsyncMock(side_effect=DomainError("SESSION_MISMATCH", "stale session", status=409)),
|
|
presentation_cursor=AsyncMock(return_value={"task_id": str(uuid4()), "execution_generation": 1,
|
|
"active_run": None, "event_cursor": 17}),
|
|
)
|
|
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
|
with pytest.raises(HTTPException) as error:
|
|
await endpoints.publish_operational_task_presentation_event(
|
|
uuid4(), TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="stale",
|
|
command={"action": "clear_highlights"}),
|
|
ctx=_service_context(), conn=object(),
|
|
)
|
|
assert error.value.status_code == 409
|
|
assert error.value.detail["code"] == "SESSION_MISMATCH"
|
|
result = await endpoints.get_operational_task_presentation_cursor(uuid4(), ctx=_service_context(), conn=object())
|
|
assert result["event_cursor"] == 17 and result["active_run"] is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_data_query_requires_task_grant_before_source_request(monkeypatch):
|
|
service = SimpleNamespace(can_read_source=AsyncMock(return_value=False))
|
|
source = AsyncMock()
|
|
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
|
monkeypatch.setattr(endpoints, "read_task_data", source)
|
|
with pytest.raises(HTTPException) as error:
|
|
await endpoints.query_operational_task_data(
|
|
uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT",
|
|
ctx=_service_context(), conn=object(),
|
|
)
|
|
assert error.value.status_code == 403
|
|
source.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch):
|
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
|
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="scada_devices:sha256:test",
|
|
queried_at=now, origin="system_data", data={"items": []}, truncated=False)
|
|
evidence = EvidenceRecord(evidence_id=snapshot.source_ref, kind="system_data",
|
|
summary="SCADA 设备数据快照", source_label="scada_devices",
|
|
source_ref=snapshot.source_ref, observed_at=now, recorded_at=now,
|
|
recorded_by="service", has_snapshot=True)
|
|
service = SimpleNamespace(can_read_source=AsyncMock(return_value=True),
|
|
record_data_snapshot=AsyncMock(return_value=(evidence, snapshot)))
|
|
source = AsyncMock(return_value=snapshot)
|
|
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
|
monkeypatch.setattr(endpoints, "read_task_data", source)
|
|
result = await endpoints.query_operational_task_data(
|
|
uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT",
|
|
ctx=_service_context(), conn=object(),
|
|
)
|
|
assert source.await_args.kwargs["delegated"] is False
|
|
assert result.evidence.evidence_id == snapshot.source_ref
|
|
service.record_data_snapshot.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_report_endpoints_preserve_interactive_blocks_and_version_context(monkeypatch):
|
|
from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReportRevision
|
|
from tests.unit.test_report_ui import rich_report
|
|
|
|
report = rich_report()
|
|
snapshot = InvestigationReportRevision(
|
|
revision=2, execution_generation=1, source_session_id="session-1",
|
|
published_at="2026-10-20T08:00:00Z", published_by="agent", report=report,
|
|
)
|
|
service = SimpleNamespace(report=AsyncMock(return_value=snapshot),
|
|
list_reports=AsyncMock(return_value=([snapshot], None)),
|
|
publish_artifacts=AsyncMock(return_value={"report_revision": 2}))
|
|
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
|
task_id = uuid4()
|
|
command = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=report)
|
|
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
|
|
assert service.publish_artifacts.await_args.args[1].report.ui_blocks[0].block.block_id == "pressure"
|
|
result = await endpoints.get_investigation_report(task_id, 2, ctx=_service_context(), conn=object())
|
|
restored = InvestigationReportRevision.model_validate_json(result.model_dump_json())
|
|
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
|
|
assert restored.revision == 2
|
|
schemas = app.openapi()["components"]["schemas"]
|
|
assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"]
|
|
assert "ui_blocks" in schemas["InvestigationReport"]["properties"]
|