213 lines
9.4 KiB
Python
213 lines
9.4 KiB
Python
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from psycopg.conninfo import conninfo_to_dict
|
|
|
|
from app.auth.project_dependencies import get_metadata_repository
|
|
from app.infra.db.project_routing import ActiveProjectRouting
|
|
from samples.peripheral import auth
|
|
from samples.peripheral.api import create_app
|
|
from samples.peripheral.config import SampleSettings
|
|
from samples.peripheral.errors import SampleFailure
|
|
from samples.peripheral.store import PostgresStore
|
|
|
|
|
|
@pytest.fixture
|
|
def project_api(monkeypatch):
|
|
projects = {str(uuid4()): "tjwater_v2", str(uuid4()): "fengyang_v2"}
|
|
connections = []
|
|
permissions = ["simulation.view"]
|
|
missing = set()
|
|
route_overrides = {}
|
|
|
|
class Repository:
|
|
async def get_project_db_routing(self, project_id, role):
|
|
code = projects[str(project_id)]
|
|
if (code, role) in missing:
|
|
return None
|
|
database = code + ("_history" if role == "iot_data" else "_business")
|
|
return SimpleNamespace(**({
|
|
"dsn": f"postgresql://test@localhost/{database}",
|
|
"db_type": "timescaledb" if role == "iot_data" else "postgresql",
|
|
} | route_overrides.get((code, role), {})))
|
|
|
|
class Connection:
|
|
def __init__(self, database):
|
|
self.database = database
|
|
self.info = SimpleNamespace(dbname=database)
|
|
self.rows = []
|
|
self.readonly = False
|
|
self.statements = []
|
|
|
|
def execute(self, statement, parameters=None):
|
|
self.statements.append((statement, parameters))
|
|
self.rows = []
|
|
if statement == "SET TRANSACTION READ ONLY":
|
|
self.readonly = True
|
|
if "current_database()" in statement:
|
|
self.rows = [{"name": self.database}]
|
|
elif "peripheral_sample.scenarios" in statement:
|
|
self.rows = []
|
|
elif "to_regclass('asset.scada_devices')" in statement:
|
|
self.rows = [{"table_name": "asset.scada_devices"}]
|
|
elif "FROM asset.scada_devices" in statement:
|
|
self.rows = [{"device_id": self.database, "device_type": "pressure",
|
|
"measurement_unit": "MPa", "api_query_id": "point-1"}]
|
|
elif "FROM scada.measurements" in statement:
|
|
self.rows = [{"time": datetime(2026, 10, 21, tzinfo=timezone.utc), "monitored_value": 0.3}]
|
|
elif "to_regclass('workbench.domain_event_log')" in statement:
|
|
self.rows = [{"event_log": "workbench.domain_event_log"}]
|
|
elif "RETURNING event_seq" in statement:
|
|
self.rows = [{"event_seq": 42}]
|
|
return self
|
|
|
|
def fetchone(self):
|
|
return self.rows[0] if self.rows else None
|
|
|
|
def fetchall(self):
|
|
return self.rows
|
|
|
|
@contextmanager
|
|
def connect(*, conninfo, **kwargs):
|
|
connection = Connection(conninfo_to_dict(conninfo)["dbname"])
|
|
connections.append(connection)
|
|
yield connection
|
|
|
|
monkeypatch.setattr("samples.peripheral.store.psycopg.connect", connect)
|
|
monkeypatch.setattr(auth, "next_get", lambda request, path: {
|
|
"project_id": request.headers["x-project-id"], "user_id": str(uuid4()),
|
|
"network": projects[request.headers["x-project-id"]], "permissions": permissions,
|
|
})
|
|
app = create_app(SampleSettings())
|
|
app.dependency_overrides[get_metadata_repository] = lambda: Repository()
|
|
with TestClient(app) as client:
|
|
yield SimpleNamespace(client=client, projects=projects, connections=connections,
|
|
permissions=permissions, missing=missing, route_overrides=route_overrides)
|
|
|
|
|
|
def headers(project):
|
|
return {"Authorization": "Bearer TEST_ONLY", "X-Project-Id": project}
|
|
|
|
|
|
def test_concurrent_project_reads_use_separate_metadata_databases(project_api):
|
|
api = project_api
|
|
|
|
def read(project):
|
|
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
|
|
assert response.status_code == 200, response.text
|
|
return project, response.json()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(read, list(api.projects) * 3))
|
|
for project, payload in results:
|
|
database = api.projects[project] + "_business"
|
|
assert payload["source_id"] == database + ":asset.scada_devices"
|
|
assert payload["items"][0]["point_id"] == database
|
|
assert all(connection.readonly for connection in api.connections)
|
|
|
|
|
|
def test_history_uses_separate_project_timescale_route(project_api):
|
|
api = project_api
|
|
project, code = list(api.projects.items())[1]
|
|
response = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
|
|
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
|
|
})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["items"][0]["value"] == 0.3
|
|
assert response.json()["source_id"] == code + "_history:scada.measurements"
|
|
assert [connection.database for connection in api.connections] == [code + "_business", code + "_history"]
|
|
assert all(connection.readonly for connection in api.connections)
|
|
|
|
|
|
def test_missing_routing_never_falls_back_to_default_database(project_api):
|
|
api = project_api
|
|
project, code = list(api.projects.items())[1]
|
|
api.missing.add((code, "biz_data"))
|
|
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
|
|
assert response.status_code == 503
|
|
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
|
|
assert not api.connections
|
|
|
|
|
|
def test_work_order_writes_and_events_use_the_selected_project(project_api):
|
|
api = project_api
|
|
api.permissions.append("simulation.run")
|
|
project, code = list(api.projects.items())[1]
|
|
response = api.client.post("/api/sample/v1/work-orders", headers=headers(project), json={
|
|
"request_id": str(uuid4()), "title": "routing test", "summary": "test only",
|
|
})
|
|
assert response.status_code == 201, response.text
|
|
assert response.json()["project_id"] == project
|
|
assert len(api.connections) == 1
|
|
connection = api.connections[0]
|
|
assert connection.database == code + "_business"
|
|
assert not connection.readonly
|
|
event = next(params for sql, params in connection.statements if "INSERT INTO workbench.domain_event_log" in sql)
|
|
assert event[0] == project
|
|
assert event[2] == response.json()["work_order_id"]
|
|
|
|
|
|
@pytest.mark.parametrize("override", [
|
|
{"dsn": "postgresql://test@localhost"}, {"dsn": "not-a-valid-dsn"}, {"db_type": "timescaledb"},
|
|
])
|
|
def test_invalid_project_routes_fail_without_opening_storage(project_api, override):
|
|
api = project_api
|
|
project, code = list(api.projects.items())[1]
|
|
api.route_overrides[(code, "biz_data")] = override
|
|
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
|
|
assert response.status_code == 503
|
|
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
|
|
assert not api.connections
|
|
|
|
|
|
def test_crud_reads_do_not_require_timescale_configuration(project_api):
|
|
api = project_api
|
|
project, code = list(api.projects.items())[1]
|
|
api.missing.add((code, "iot_data"))
|
|
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
|
|
assert response.status_code == 200
|
|
unavailable = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
|
|
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
|
|
})
|
|
assert unavailable.status_code == 503
|
|
|
|
|
|
@pytest.mark.parametrize("method,permissions", [("GET", []), ("POST", ["simulation.view"])])
|
|
def test_project_routing_preserves_read_and_write_permissions(project_api, method, permissions):
|
|
api = project_api
|
|
api.permissions[:] = permissions
|
|
response = api.client.request(method, "/api/sample/v1/scenario", headers=headers(next(iter(api.projects))))
|
|
assert response.status_code == 403
|
|
assert response.json()["code"] == "PERMISSION_DENIED"
|
|
assert not api.connections
|
|
|
|
|
|
def test_mismatched_identity_is_rejected_before_storage(project_api, monkeypatch):
|
|
monkeypatch.setattr(auth, "next_get", lambda request, path: {
|
|
"project_id": str(uuid4()), "user_id": str(uuid4()), "network": "fengyang_v2",
|
|
"permissions": ["simulation.view", "simulation.run"],
|
|
})
|
|
response = project_api.client.get("/api/sample/v1/context", headers=headers(next(iter(project_api.projects))))
|
|
assert response.status_code == 403
|
|
assert response.json()["code"] == "PROJECT_MISMATCH"
|
|
assert not project_api.connections
|
|
|
|
|
|
def test_routed_connection_does_not_use_legacy_settings_or_missing_timescale(project_api):
|
|
project = next(iter(project_api.projects))
|
|
routing = ActiveProjectRouting("logical-project", "dbname=physical-business", project_id=UUID(project))
|
|
store = PostgresStore(SampleSettings(db_name="unrelated"), routing=routing)
|
|
assert store.settings.db_name == "physical-business"
|
|
with store.connection(readonly=True):
|
|
pass
|
|
with pytest.raises(SampleFailure) as failure:
|
|
with store.connection(readonly=True, timeseries=True):
|
|
pass
|
|
assert failure.value.code == "PROJECT_STORAGE_NOT_CONFIGURED"
|
|
assert [connection.database for connection in project_api.connections] == ["physical-business"]
|