refactor(storage): route project DSNs and remove legacy backends

This commit is contained in:
2026-08-18 18:29:09 +08:00
parent b21eaffe40
commit 6b09662de6
51 changed files with 542 additions and 10951 deletions
+46
View File
@@ -10,6 +10,8 @@ from app.auth.metadata_dependencies import (
get_current_metadata_admin,
get_metadata_repository,
)
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
from app.infra.db.project_routing import get_project_pgconn_string
from tests.conftest import build_test_app
@@ -98,3 +100,47 @@ def test_model_import_rejects_non_inp_file(monkeypatch):
assert response.status_code == 400
assert response.json()["detail"] == "Only .inp model files are accepted"
model_import.log_audit_event.assert_not_awaited()
def test_model_update_uses_project_business_routing(monkeypatch):
project_id = uuid4()
project = SimpleNamespace(id=project_id, code="demo", status="active")
repo = SimpleNamespace(
session=object(),
get_project_by_id=AsyncMock(return_value=project),
get_project_db_routing=AsyncMock(
return_value=ProjectDbRouting(
project_id=project_id,
db_role="biz_data",
db_type="postgresql",
dsn="postgresql://user:password@biz.example/routed_business",
pool_min_size=1,
pool_max_size=5,
)
),
)
captured: dict[str, str] = {}
async def fake_apply_model_update(content: bytes, project_code: str) -> None:
assert content == VALID_INP
captured["project_code"] = project_code
captured["dsn"] = get_project_pgconn_string(project_code)
monkeypatch.setattr(model_import, "_apply_model_update", fake_apply_model_update)
monkeypatch.setattr(model_import, "log_audit_event", AsyncMock())
client = _client(
admin=SimpleNamespace(id=uuid4(), role="admin", is_superuser=False),
repo=repo,
)
response = client.patch(
f"/api/v1/admin/projects/{project_id}/model-imports",
files={"file": ("desktop-model.inp", VALID_INP)},
)
assert response.status_code == 200
assert captured == {
"project_code": "demo",
"dsn": "postgresql://user:password@biz.example/routed_business",
}
repo.get_project_db_routing.assert_awaited_once_with(project_id, "biz_data")
+68 -26
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
import inspect
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock
from uuid import uuid4
import pytest
@@ -12,15 +12,44 @@ from fastapi.testclient import TestClient
from app.api.v1.endpoints import schemes as schemes_endpoint
from app.api.v1.endpoints import simulation as simulation_endpoint
from app.api.v1.endpoints import cache as cache_endpoint
from app.api.pagination import PaginatedList
from app.api.v1.rest_router import api_router, build_rest_router
from app.api.v1.router import api_router as source_api_router
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.auth.project_dependencies import (
ProjectContext,
get_project_business_routing,
get_project_context,
get_project_simulation_routing,
)
from app.infra.db.project_routing import (
ActiveProjectRouting,
get_project_pgconn_string,
get_project_timescale_pgconn_string,
)
from scripts.check_openapi import current_contract_bytes, validate
def _override_project_routing(
app: FastAPI,
project_context: ProjectContext,
) -> None:
app.dependency_overrides[get_project_context] = lambda: project_context
business = ActiveProjectRouting(
project_code=project_context.project_code,
business_dsn=f"postgresql://user:password@biz/{project_context.project_code}",
)
simulation = ActiveProjectRouting(
project_code=project_context.project_code,
business_dsn=business.business_dsn,
timescale_dsn=(
f"postgresql://user:password@timescale/{project_context.project_code}"
),
)
app.dependency_overrides[get_project_business_routing] = lambda: business
app.dependency_overrides[get_project_simulation_routing] = lambda: simulation
def test_rest_router_preserves_every_distinct_source_operation() -> None:
skipped_names = {"fastapi_get_json", "fastapi_test_dict"}
source_names = {
@@ -44,6 +73,16 @@ def test_rest_router_has_unique_method_path_pairs() -> None:
assert len(pairs) == len(set(pairs))
def test_removed_redis_management_routes_are_not_published() -> None:
published_paths = {
route.path for route in api_router.routes if isinstance(route, APIRoute)
}
assert published_paths.isdisjoint(
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis"}
)
def test_rest_router_rejects_duplicate_method_path_pairs() -> None:
first = APIRoute(
"/duplicate",
@@ -176,6 +215,21 @@ def test_valve_isolation_route_uses_the_isolation_handler() -> None:
assert route.name == "valve_isolation_endpoint"
def test_open_project_route_requires_business_and_timescale_routing() -> None:
route = next(
route
for route in api_router.routes
if isinstance(route, APIRoute)
and route.path == "/projects/current"
and route.methods == {"POST"}
)
routing_parameter = inspect.signature(route.endpoint).parameters[
"_rest_project_routing"
]
assert routing_parameter.default.dependency is get_project_simulation_routing
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
captured: dict[str, object] = {}
@@ -184,6 +238,8 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
network=network,
accident_element=accident_element,
disabled_valves=disabled_valves,
business_dsn=get_project_pgconn_string(network),
timescale_dsn=get_project_timescale_pgconn_string(network),
)
return {"isolatable": True, "must_close_valves": ["V-1"]}
@@ -194,12 +250,13 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
)
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
app.dependency_overrides[get_project_context] = lambda: ProjectContext(
project_context = ProjectContext(
project_id=uuid4(),
project_code="fengyang",
user_id=uuid4(),
project_role="member",
)
_override_project_routing(app, project_context)
response = TestClient(app, raise_server_exceptions=False).post(
"/api/v1/valve-isolation-analyses",
@@ -216,6 +273,8 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
"network": "fengyang",
"accident_element": ["P-1", "P-2"],
"disabled_valves": ["V-9"],
"business_dsn": "postgresql://user:password@biz/fengyang",
"timescale_dsn": "postgresql://user:password@timescale/fengyang",
}
@@ -247,6 +306,7 @@ def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None:
network=network,
scheme_type=scheme_type,
query_date=query_date,
business_dsn=get_project_pgconn_string(network),
)
return [{"scheme_name": "burst_case", "scheme_type": scheme_type}]
@@ -263,7 +323,7 @@ def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None:
user_id=uuid4(),
project_role="viewer",
)
app.dependency_overrides[get_project_context] = lambda: project_context
_override_project_routing(app, project_context)
response = TestClient(app, raise_server_exceptions=False).get(
"/api/v1/schemes",
@@ -275,6 +335,7 @@ def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None:
"network": "fengyang",
"scheme_type": "burst_analysis",
"query_date": None,
"business_dsn": "postgresql://user:password@biz/fengyang",
}
assert response.json()["items"] == [
{"scheme_name": "burst_case", "scheme_type": "burst_analysis"}
@@ -324,12 +385,13 @@ def test_sensor_placement_body_uses_authenticated_project_and_user(
)
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
app.dependency_overrides[get_project_context] = lambda: ProjectContext(
project_context = ProjectContext(
project_id=uuid4(),
project_code="project_a",
user_id=uuid4(),
project_role="member",
)
_override_project_routing(app, project_context)
app.dependency_overrides[get_current_metadata_user] = lambda: type(
"User", (), {"username": "alice"}
)()
@@ -353,26 +415,6 @@ def test_sensor_placement_body_uses_authenticated_project_and_user(
}
def test_cache_management_requires_environment_permission(monkeypatch) -> None:
flushdb = Mock(return_value=True)
monkeypatch.setattr(cache_endpoint.redis_client, "flushdb", flushdb)
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
app.dependency_overrides[get_project_context] = lambda: ProjectContext(
project_id=uuid4(),
project_code="project_a",
user_id=uuid4(),
project_role="member",
)
response = TestClient(app, raise_server_exceptions=False).delete(
"/api/v1/all-redis"
)
assert response.status_code == 403
flushdb.assert_not_called()
def test_rest_runtime_json_encodes_untyped_datetime_response() -> None:
source_router = APIRouter()
@@ -6,7 +6,10 @@ from uuid import uuid4
import pytest
from cryptography.fernet import InvalidToken
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.metadb.repositories.metadata_repository import (
MetadataRepository,
_normalize_postgres_dsn,
)
class _DummyResult:
@@ -124,6 +127,12 @@ def test_encrypted_dsn_decrypts_without_migration(monkeypatch):
session.commit.assert_not_awaited()
def test_psycopg_sqlalchemy_dsn_is_normalized_for_direct_psycopg_clients():
assert _normalize_postgres_dsn(
"postgresql+psycopg://user:secret@db.example/project"
) == "postgresql://user:secret@db.example/project"
def test_upsert_project_database_config_encrypts_plaintext_dsn(monkeypatch):
project_id = uuid4()
session = SimpleNamespace(
+85
View File
@@ -0,0 +1,85 @@
import pytest
from psycopg.conninfo import conninfo_to_dict
from app.infra.db.project_routing import (
ActiveProjectRouting,
activate_project_routing,
get_active_project_routing,
get_project_pgconn_string,
get_project_timescale_pgconn_string,
)
def _routing(project_code: str = "project_a") -> ActiveProjectRouting:
return ActiveProjectRouting(
project_code=project_code,
business_dsn=(
"postgresql://biz_user:biz_password@biz.example:5432/biz_database"
"?sslmode=require"
),
timescale_dsn=(
"postgresql://ts_user:ts_password@timescale.example:5433/ts_database"
"?sslmode=require"
),
)
def test_project_database_uses_exact_routing_dsn_for_project_code() -> None:
routing = _routing()
with activate_project_routing(routing):
assert get_project_pgconn_string("project_a") == routing.business_dsn
assert (
get_project_timescale_pgconn_string("project_a")
== routing.timescale_dsn
)
def test_business_template_keeps_server_and_timescale_ignores_legacy_db_name() -> None:
with activate_project_routing(_routing()):
business = conninfo_to_dict(get_project_pgconn_string("project_a_template"))
timescale = conninfo_to_dict(
get_project_timescale_pgconn_string("temporary_scheme")
)
assert business == {
"user": "biz_user",
"password": "biz_password",
"dbname": "project_a_template",
"host": "biz.example",
"port": "5432",
"sslmode": "require",
}
assert timescale == {
"user": "ts_user",
"password": "ts_password",
"dbname": "ts_database",
"host": "timescale.example",
"port": "5433",
"sslmode": "require",
}
def test_project_routing_is_nested_and_request_local() -> None:
first = _routing("project_a")
second = _routing("project_b")
assert get_active_project_routing() is None
with activate_project_routing(first):
assert get_active_project_routing() is first
with activate_project_routing(second):
assert get_active_project_routing() is second
assert get_active_project_routing() is first
assert get_active_project_routing() is None
def test_timescale_access_requires_iot_routing_in_project_request() -> None:
business_only = _routing()
business_only = ActiveProjectRouting(
project_code=business_only.project_code,
business_dsn=business_only.business_dsn,
)
with activate_project_routing(business_only):
with pytest.raises(RuntimeError, match="TimescaleDB routing is not configured"):
get_project_timescale_pgconn_string()
+3 -1
View File
@@ -35,7 +35,9 @@ class _FakeConnection:
def test_query_scheme_list_pushes_scheme_type_into_sql(monkeypatch):
cursor = _FakeCursor()
monkeypatch.setattr(
scheme_management, "get_pgconn_string", lambda db_name=None: "postgres://test"
scheme_management,
"get_project_pgconn_string",
lambda db_name=None: "postgres://test",
)
monkeypatch.setattr(
scheme_management.psycopg, "connect", lambda _conn_string: _FakeConnection(cursor)
+26 -2
View File
@@ -45,9 +45,11 @@ class _FakeConnection:
@pytest.fixture(autouse=True)
def clear_native_connections():
connection.g_conn_dict.clear()
connection.g_conninfo_dict.clear()
connection._project_locks.clear()
yield
connection.g_conn_dict.clear()
connection.g_conninfo_dict.clear()
connection._project_locks.clear()
@@ -61,6 +63,10 @@ def test_is_project_open_drops_closed_cached_connection():
def test_open_connection_reuses_healthy_cached_connection(monkeypatch):
cached = _FakeConnection()
connection.g_conn_dict["fengyang"] = cached
connection.g_conninfo_dict["fengyang"] = "dbname=fengyang"
monkeypatch.setattr(
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
)
def fail_connect(*, conninfo, autocommit):
raise AssertionError("cached connection should be reused")
@@ -84,7 +90,7 @@ def test_read_all_reopens_closed_cached_connection(monkeypatch):
monkeypatch.setattr(connection.pg, "connect", fake_connect)
monkeypatch.setattr(
connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}"
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
)
rows = database.read_all("fengyang", "select * from times")
@@ -99,6 +105,7 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
stale = _FakeConnection(fail_ping=True)
fresh = _FakeConnection(rows=[{"scheme_name": "base"}])
connection.g_conn_dict["fengyang"] = stale
connection.g_conninfo_dict["fengyang"] = "dbname=fengyang"
opened = []
@@ -108,7 +115,7 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
monkeypatch.setattr(connection.pg, "connect", fake_connect)
monkeypatch.setattr(
connection, "get_pgconn_string", lambda db_name: f"dbname={db_name}"
connection, "get_project_pgconn_string", lambda db_name: f"dbname={db_name}"
)
rows = database.read_all("fengyang", "select * from scheme_list")
@@ -119,3 +126,20 @@ def test_read_all_reopens_cached_connection_when_health_check_fails(monkeypatch)
assert opened == [("dbname=fengyang", True)]
assert connection.g_conn_dict["fengyang"] is fresh
assert fresh.executed == ["select * from scheme_list"]
def test_open_connection_replaces_cache_when_project_dsn_changes(monkeypatch):
cached = _FakeConnection()
fresh = _FakeConnection()
connection.g_conn_dict["fengyang"] = cached
connection.g_conninfo_dict["fengyang"] = "host=old dbname=fengyang"
monkeypatch.setattr(
connection,
"get_project_pgconn_string",
lambda db_name: f"host=new dbname={db_name}",
)
monkeypatch.setattr(connection.pg, "connect", lambda **_kwargs: fresh)
assert connection.open_connection("fengyang") is fresh
assert cached.close_calls == 1
assert connection.g_conninfo_dict["fengyang"] == "host=new dbname=fengyang"