Files
jiang 1d061f6099
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s
feat: add project-scoped operations
2026-10-22 18:40:20 +08:00

378 lines
19 KiB
Python

import asyncio
from contextlib import asynccontextmanager, contextmanager
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 pydantic import ValidationError
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, QueryResult
from app.mcp import extended_service, mutation_service, query_service, scenario_service, analysis_service
HANDLE = "x" * 43
START = "2026-10-21T08:00:00+08:00"
def args(tool_name, **values):
return TOOLS[tool_name].model.model_validate({"context_handle": HANDLE, **values})
def binding(**values):
return BoundContext(context_handle=HANDLE, project_id=uuid4(), subject="actor", client_id="host",
native_session_id="session", purpose="ordinary_chat", tool_cap=list(TOOLS),
permission_cap=["webgis.edit", "webgis.view", "scada.clean", "scada.view", "simulation.run"],
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), **values)
@pytest.mark.parametrize("name,values", [
("query_network_extent", {"bounds": [120, 30, 122, 32]}),
("query_network_extent", {"bounds": [121, 31, 120, 30]}),
("query_network_extent", {"bounds": [float("nan"), 31, 122, 32]}),
("calculate_demand_allocation", {"target": "network", "demand": 10, "node_ids": ["J1"]}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "delete", "element_type": "pipe", "element_id": "P1", "dry_run": False}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "update", "element_type": "pipe", "element_id": "P1", "properties": {"id": "other"}}),
("clean_scada_readings", {"request_id": str(uuid4()), "start": START, "end": "2026-10-23T08:00:00+08:00", "device_ids": ["D1"]}),
("run_burst_simulation", {"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1, 2]}),
])
def test_bounded_schemas_reject_unsafe_inputs(name, values):
with pytest.raises(ValidationError):
args(name, **values)
def test_saved_scenario_has_no_nested_identity_and_validates_timezone():
values = dict(request_id=uuid4(), name="test", scenario="burst", parameters={
"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1],
})
valid = args("save_simulation_scenario", **values)
assert "context_handle" not in valid.parameters.model_dump()
values["parameters"]["start_time"] = "2026-10-21T08:00:00"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
values["parameters"]["start_time"] = START
values["parameters"]["network"] = "other-project"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
@pytest.mark.parametrize("purpose,role", [("task_reader", "member"), ("task_execution", "member"), ("task_collaboration", "member"), ("ordinary_chat", "viewer")])
def test_writes_never_enter_background_or_viewer_sessions(monkeypatch, purpose, role):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound = binding()
bound.purpose = purpose
scope = SimpleNamespace(project=SimpleNamespace(project_role=role))
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
assert error.value.status_code == 403
def test_write_requires_current_and_captured_permissions(monkeypatch):
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
bound = binding()
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set())
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound.permission_cap = []
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
def test_extent_runs_real_parameterized_project_query_and_pages(monkeypatch):
read = AsyncMock(return_value=[{"id": "A"}, {"id": "B"}])
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own"))
result = asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1], limit=1)))
assert result.page.has_more and result.truncated
assert result.data["items"] == [{"id": "A"}]
call = read.call_args.args
assert "ST_Intersects" in str(call[1])
assert call[2] == (121.0, 31.0, 121.1, 31.1, 2, 0)
assert call[0] is scope.connection
def test_missing_gis_propagates_unavailable_without_cross_project_retry(monkeypatch):
read = AsyncMock(side_effect=ValueError("GIS unavailable"))
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="no-gis"))
with pytest.raises(ValueError):
asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1])))
assert read.await_count == 1
def test_demand_allocation_is_paged_and_never_sets_demands(monkeypatch):
from app.services import demand_allocation
monkeypatch.setattr(demand_allocation, "calculate_demand_to_nodes", lambda *a: {"B": 6, "A": 4})
values = args("calculate_demand_allocation", target="nodes", node_ids=["A", "B"], demand=10, limit=1)
result = asyncio.run(analysis_service.calculate("calculate_demand_allocation", values, network="own", username="actor"))
assert result == {"items": [{"node_id": "A", "demand": 4}], "total": 2, "limit": 1, "offset": 0, "has_more": True, "persisted": False}
def test_burst_preview_uses_isolated_nonpersistent_adapter(monkeypatch):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, "burst_analysis", lambda **kw: calls.append(kw) or {"node_results": [{"node": "J1"}], "link_results": []})
values = args("run_burst_simulation", start_time=START, output_node_ids=["J1"], burst_pipe_ids=["P1"], orifice_areas_cm2=[2])
result = scenario_service.calculate_scenario("run_burst_simulation", values.model_dump(exclude={"context_handle", "timeout_seconds"}), network="own", username="actor")
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
assert calls[0]["burst_size"] == [2]
assert result["persisted_baseline"] is False
class Connection:
@asynccontextmanager
async def transaction(self):
yield
async def commit(self):
pass
class Journal:
records = {}
def __init__(self, *args):
pass
async def lock_receipt(self, *args):
pass
async def receipt(self, scope, key):
return self.records.get((scope, key))
async def save_receipt(self, scope, key, parameters, result):
self.records[(scope, key)] = {"parameters": parameters, "result": result}
async def finish_receipt(self, scope, key, result):
self.records[(scope, key)]["result"] = result
@pytest.fixture
def mutation(monkeypatch):
Journal.records = {}
monkeypatch.setattr(mutation_service, "McpQueryRepository", Journal)
preview = AsyncMock(return_value={"before": {"diameter": 100}, "properties": {"diameter": 200}})
apply = AsyncMock(return_value={"changed": True})
monkeypatch.setattr(mutation_service, "preview_change", preview)
monkeypatch.setattr(mutation_service, "apply_change", apply)
scope = SimpleNamespace(connection=Connection(), project=SimpleNamespace(project_id=uuid4()))
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
return scope, binding(), values, preview, apply
def test_preview_execution_stale_state_and_idempotent_replay(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
assert result.data["dry_run"] and not apply.called
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
preview.return_value = {"before": {"diameter": 150}}
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and not apply.called
assert Journal.records == {}
preview.return_value = {"before": {"diameter": 100}, "properties": {"diameter": 200}}
result = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == result
assert apply.await_count == 1
asyncio.run(run())
def test_failed_or_timed_out_write_cannot_be_automatically_reexecuted(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = TimeoutError()
with pytest.raises(TimeoutError):
await mutation_service.execute_mutation(scope, bound, tool, execution)
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409
assert apply.await_count == 1
asyncio.run(run())
def test_capacity_rejection_is_retryable_under_the_same_request_after_slots_free(mutation, monkeypatch):
from threading import BoundedSemaphore
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
writes = []
async def bounded_write(*unused):
return await analysis_service.bounded_calculation(lambda: writes.append("written") or {"changed": True})
apply.side_effect = bounded_write
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 503
record = next(iter(Journal.records.values()))
assert record["result"]["status"] == "rejected"
assert record["result"]["retryable"] is True
assert record["result"]["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert writes == []
finally:
slots.release()
slots.release()
saved = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == saved and writes == ["written"]
assert next(iter(Journal.records.values()))["result"]["status"] == "completed"
asyncio.run(run())
@pytest.mark.parametrize("failure", [TimeoutError(), asyncio.CancelledError(), RuntimeError("native failure"), HTTPException(503, "unrelated source failure")])
def test_ambiguous_write_failure_remains_started_and_cannot_be_retried(mutation, failure):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = failure
with pytest.raises(type(failure)):
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert next(iter(Journal.records.values()))["result"]["status"] == "started"
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and apply.await_count == 1
asyncio.run(run())
def test_native_model_lock_revalidates_preview_and_only_writes_allowed_fields(monkeypatch):
from app.services import tjnetwork
from app.native.wndb.core import database
state = {"id": "P1", "diameter": 100}
monkeypatch.setattr(tjnetwork, "get_pipe", lambda *a: dict(state))
monkeypatch.setattr(tjnetwork, "get_pipe_schema", lambda *a: {"id": {"readonly": True}, "diameter": {"type": "float", "readonly": False}})
@contextmanager
def lock(*a):
yield
monkeypatch.setattr(database, "model_mutation_transaction", lock)
writes = []
monkeypatch.setattr(tjnetwork, "set_pipe", lambda network, changes: writes.append(changes.operations) or changes)
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
preview = mutation_service.model_change("own", "edit_model_element", args("edit_model_element", **values), execute=False)
execution = args("edit_model_element", **values, dry_run=False, expected_digest=mutation_service.digest(preview))
state["diameter"] = 110
with pytest.raises(HTTPException):
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert not writes
state["diameter"] = 100
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert writes == [[{"id": "P1", "diameter": 200}]]
def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
result = extended_service.artifact("model.zip", b"zip-data", "application/zip")
assert result["file"]["size"] == 8
assert len(result["file"]["sha256"]) == 64
with pytest.raises(HTTPException) as error:
extended_service.artifact("model.zip", bytes(5 * 1024 * 1024 + 1), "application/zip")
assert error.value.status_code == 413
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 67
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
assert not tool.protocol_tool().annotations.read_only_hint
assert tool.protocol_tool().annotations.destructive_hint
@pytest.mark.parametrize("scenario,parameters,function", [
("burst", {"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1]}, "burst_analysis"),
("contaminant", {"source": "J2", "concentration": 1}, "contaminant_simulation"),
("valve_closure", {"valves": ["V1"]}, "valve_close_analysis"),
])
def test_formal_scenarios_bind_actor_and_persist_only_through_existing_services(monkeypatch, scenario, parameters, function):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, function, lambda **kw: calls.append(kw))
payload = {"start_time": START, "duration": 900, "output_node_ids": ["J1"], **parameters}
values = args("save_simulation_scenario", request_id=uuid4(), name="formal-test", scenario=scenario, parameters=payload)
mutation_service.saved_scenario("owned-project", "verified-user", values)
assert calls[0]["name"] == "owned-project"
assert calls[0]["username"] == "verified-user"
assert calls[0]["persist_results"] is True
assert calls[0]["scheme_name"] == "formal-test"
def test_mutation_status_is_scoped_to_bound_identity_session_and_project(monkeypatch):
calls = []
class Repository:
def __init__(self, conn, project):
calls.append(project)
async def receipt(self, scope_key, request_id):
calls.extend([scope_key, request_id])
return {"parameters": {"tool": "edit_model_element"}, "result": {"status": "started"}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
request_id = uuid4()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"], args("get_mutation_status", request_id=request_id)))
assert calls == [bound.project_id, "mutation:actor:session", str(request_id)]
assert result.data["status"] == "started"
def test_mutation_status_exposes_rejected_capacity_and_retryability(monkeypatch):
class Repository:
def __init__(self, *unused):
pass
async def receipt(self, *unused):
return {"parameters": {"tool": "save_simulation_scenario"}, "result": {
"status": "rejected", "retryable": True,
"error": {"code": "CALCULATION_CAPACITY_EXCEEDED", "message": "calculation not started"},
}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"],
args("get_mutation_status", request_id=uuid4())))
assert result.data["status"] == "rejected" and result.data["retryable"] is True
assert result.data["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
def test_scada_preview_and_journal_distinguish_omitted_fields_from_explicit_null():
values = dict(request_id=uuid4(), operation="update", provenance="operator correction",
records=[{"device_id": "D1", "time": START, "monitored_value": 1}])
original = args("write_scada_readings", **values)
values["records"][0]["cleaned_value"] = None
clearing = args("write_scada_readings", **values)
for preview in [True, False]:
left = mutation_service.mutation_arguments(original, preview=preview)
right = mutation_service.mutation_arguments(clearing, preview=preview)
assert "cleaned_value" not in left["records"][0]
assert right["records"][0]["cleaned_value"] is None
assert mutation_service.digest(left) != mutation_service.digest(right)
def test_nested_model_properties_cannot_hide_nonfinite_or_unknown_fields():
schema = {"demands": {"type": "list", "readonly": False, "element": {"demand": {"type": "float", "readonly": False}}}}
for value in [{"demand": float("nan")}, {"demand": 1, "url": "https://foreign"}]:
with pytest.raises(HTTPException) as error:
mutation_service.check_properties(schema, {"demands": [value]})
assert error.value.status_code == 422