feat(api): standardize REST contracts and auth

This commit is contained in:
2026-07-30 20:38:51 +08:00
parent ae1a657554
commit ba947b616b
86 changed files with 54193 additions and 990 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ def test_access_context_returns_global_admin_permissions_without_project():
repo = SimpleNamespace()
client = _build_client(user, repo)
response = client.get("/api/v1/access/context")
response = client.get("/api/v1/access-context")
assert response.status_code == 200
payload = response.json()
@@ -64,7 +64,7 @@ def test_access_context_returns_project_member_permissions():
client = _build_client(user, repo)
response = client.get(
"/api/v1/access/context",
"/api/v1/access-context",
headers={"X-Project-Id": str(project_id)},
)
+2 -2
View File
@@ -41,7 +41,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
),
)
response = client.get("/api/v1/agent/auth/context")
response = client.get("/api/v1/agent-auth-context")
assert response.status_code == 200
assert response.json() == {
@@ -90,7 +90,7 @@ def test_agent_auth_context_propagates_project_auth_failures():
app.dependency_overrides[get_current_keycloak_payload] = lambda: {"exp": 1781183400}
client = TestClient(app)
response = client.get("/api/v1/agent/auth/context")
response = client.get("/api/v1/agent-auth-context")
assert response.status_code == 403
assert response.json()["detail"] == "No access to project"
+3 -3
View File
@@ -48,9 +48,9 @@ def test_router_configuration():
routes = [r.path for r in api_router.routes if hasattr(r, "path")]
# 验证基础路径是否存在
assert any("/agent/auth/context" in r for r in routes), "缺少 Agent 认证上下文路由"
assert any("/meta" in r for r in routes), "缺少 Metadata 路由"
assert any("/audit" in r for r in routes), "缺少审计日志路由 (/audit)"
assert "/agent-auth-context" in routes, "缺少 Agent 认证上下文路由"
assert "/projects/current/metadata" in routes, "缺少 Metadata 路由"
assert "/audit-logs" in routes, "缺少审计日志路由"
except Exception as e:
pytest.fail(f"路由配置检查失败: {e}")
+4 -4
View File
@@ -17,7 +17,7 @@ def _build_client(
metadata_admin=None,
metadata_user=None,
) -> TestClient:
app = build_test_app(audit_endpoint.router, "/audit")
app = build_test_app(audit_endpoint.router, "/api/v1")
app.dependency_overrides[audit_endpoint.get_audit_repository] = lambda: repo
if metadata_admin is not None:
app.dependency_overrides[get_current_metadata_admin] = lambda: metadata_admin
@@ -38,7 +38,7 @@ def test_get_audit_logs_passes_filters():
client = _build_client(repo, metadata_admin=object())
response = client.get(
"/audit/logs",
"/api/v1/audit-logs",
params={
"action": "LOGIN",
"resource_type": "user",
@@ -68,7 +68,7 @@ def test_get_audit_logs_count_returns_count_payload():
)()
client = _build_client(repo, metadata_admin=object())
response = client.get("/audit/logs/count", params={"action": "DELETE_USER"})
response = client.get("/api/v1/audit-logs/count", params={"action": "DELETE_USER"})
assert response.status_code == 200
assert response.json() == {"count": 7}
@@ -88,7 +88,7 @@ def test_get_my_audit_logs_forces_current_user_id():
)()
client = _build_client(repo, metadata_user=current_user)
response = client.get("/audit/logs/my", params={"limit": 3})
response = client.get("/api/v1/audit-logs/mine", params={"limit": 3})
assert response.status_code == 200
repo.get_logs.assert_awaited_once()
+2 -2
View File
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import leakage as leakage_endpoint
def _build_client() -> TestClient:
app = FastAPI()
app.include_router(leakage_endpoint.router, prefix="/api/v1/leakage")
app.include_router(leakage_endpoint.router, prefix="/api/v1")
app.dependency_overrides[leakage_endpoint.get_current_keycloak_username] = (
lambda: "tester"
)
@@ -23,7 +23,7 @@ def test_identify_leakage_success(monkeypatch):
)
client = _build_client()
response = client.post(
"/api/v1/leakage/identify/",
"/api/v1/leakage-identifications",
json={
"network": "demo",
"scada_start": "2026-01-01T00:00:00+08:00",
+2 -2
View File
@@ -59,7 +59,7 @@ def test_meta_project_returns_map_extent(monkeypatch):
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
client = TestClient(app)
response = client.get("/api/v1/meta/project")
response = client.get("/api/v1/projects/current/metadata")
assert response.status_code == 200
assert response.json()["map_extent"] == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
@@ -81,7 +81,7 @@ def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
app.dependency_overrides[module.get_project_timescale_connection] = lambda: DummyTimescaleConnection()
client = TestClient(app)
response = client.get("/api/v1/meta/db/health")
response = client.get("/api/v1/projects/current/database-health")
assert response.status_code == 503
assert response.json()["detail"] == "Project PostgreSQL health check failed: pg unavailable"
+3 -3
View File
@@ -44,7 +44,7 @@ def test_system_admin_can_import_model_without_project_membership(
client = _client(admin=admin, repo=repo)
response = client.post(
f"/api/v1/admin/projects/{project_id}/model/import",
f"/api/v1/admin/projects/{project_id}/model-imports",
files={"file": ("desktop-model.inp", VALID_INP)},
)
@@ -64,7 +64,7 @@ def test_non_admin_is_denied_model_import():
client = TestClient(app)
response = client.post(
f"/api/v1/admin/projects/{uuid4()}/model/import",
f"/api/v1/admin/projects/{uuid4()}/model-imports",
files={"file": ("desktop-model.inp", VALID_INP)},
)
@@ -91,7 +91,7 @@ def test_model_import_rejects_non_inp_file(monkeypatch):
)
response = client.post(
f"/api/v1/admin/projects/{project_id}/model/import",
f"/api/v1/admin/projects/{project_id}/model-imports",
files={"file": ("desktop-model.txt", VALID_INP)},
)
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.routing import APIRoute
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.rest_router import api_router, build_rest_router
from app.api.v1.router import api_router as source_api_router
from app.auth.project_dependencies import ProjectContext, get_project_context
from scripts.check_openapi import current_contract_bytes, validate
def test_rest_router_preserves_every_distinct_source_operation() -> None:
skipped_names = {"fastapi_get_json", "fastapi_test_dict"}
source_names = {
route.name
for route in source_api_router.routes
if isinstance(route, APIRoute) and route.name not in skipped_names
}
rest_names = {
route.name for route in api_router.routes if isinstance(route, APIRoute)
}
assert rest_names == source_names
def test_rest_router_has_unique_method_path_pairs() -> None:
pairs: list[tuple[str, str]] = []
for route in api_router.routes:
if not isinstance(route, APIRoute):
continue
pairs.extend((method, route.path) for method in route.methods or set())
assert len(pairs) == len(set(pairs))
def test_rest_router_rejects_duplicate_method_path_pairs() -> None:
first = APIRoute(
"/duplicate",
lambda: None,
methods={"POST"},
name="first_endpoint",
)
second = APIRoute(
"/duplicate",
lambda: None,
methods={"POST"},
name="second_endpoint",
)
with pytest.raises(RuntimeError, match="REST route collision"):
build_rest_router([first, second])
def test_handler_router_defines_only_the_public_rest_operations() -> None:
source_operations = {
(method, route.path)
for route in source_api_router.routes
if isinstance(route, APIRoute)
for method in route.methods or set()
}
public_operations = {
(method, route.path)
for route in api_router.routes
if isinstance(route, APIRoute)
for method in route.methods or set()
}
assert source_operations == public_operations
assert ("POST", "/burst-analysis") not in source_operations
assert ("GET", "/getpipeproperties/") not in source_operations
def test_rest_openapi_satisfies_contract_invariants() -> None:
from fastapi import FastAPI
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
errors = validate(app.openapi())
assert errors == []
def test_openapi_snapshot_matches_current_application() -> None:
contract = Path(__file__).resolve().parents[2] / "contracts/server-v1.openapi.json"
assert contract.read_bytes() == current_contract_bytes()
def test_rest_contract_uses_header_project_context() -> None:
from fastapi import FastAPI
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
document = app.openapi()
for path_item in document["paths"].values():
for operation in path_item.values():
if not isinstance(operation, dict):
continue
query_names = {
parameter["name"]
for parameter in operation.get("parameters", [])
if parameter.get("in") == "query"
}
assert "network" not in query_names
assert "network_name" not in query_names
for name, schema in document["components"]["schemas"].items():
if name.endswith("Rest"):
assert "network" not in schema.get("properties", {})
assert "network_name" not in schema.get("properties", {})
assert "/api/v1/burst-analysis" not in document["paths"]
assert "/api/v1/getpipeproperties/" not in document["paths"]
pipes_collection = document["paths"]["/api/v1/pipes"]["get"]
query_names = {
parameter["name"]
for parameter in pipes_collection["parameters"]
if parameter["in"] == "query"
}
assert {"limit", "offset"} <= query_names
assert "204" in document["paths"]["/api/v1/pipes"]["delete"]["responses"]
def test_side_effecting_analysis_routes_are_post() -> None:
methods_by_path = {
route.path: route.methods
for route in api_router.routes
if isinstance(route, APIRoute)
}
assert methods_by_path["/burst-analyses"] == {"POST"}
assert methods_by_path["/flushing-analyses"] == {"POST"}
assert methods_by_path["/contaminant-simulations"] == {"POST"}
def test_valve_isolation_route_uses_the_isolation_handler() -> None:
route = next(
route
for route in api_router.routes
if isinstance(route, APIRoute)
and route.path == "/valve-isolation-analyses"
and route.methods == {"POST"}
)
assert route.name == "valve_isolation_endpoint"
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
captured: dict[str, object] = {}
def fake_analyze_valve_isolation(network, accident_element, disabled_valves):
captured.update(
network=network,
accident_element=accident_element,
disabled_valves=disabled_valves,
)
return {"isolatable": True, "must_close_valves": ["V-1"]}
monkeypatch.setattr(
simulation_endpoint,
"analyze_valve_isolation",
fake_analyze_valve_isolation,
)
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="fengyang",
user_id=uuid4(),
project_role="member",
)
response = TestClient(app, raise_server_exceptions=False).post(
"/api/v1/valve-isolation-analyses",
params=[
("accident_element", "P-1"),
("accident_element", "P-2"),
("disabled_valves", "V-9"),
],
)
assert response.status_code == 200
assert response.json() == {"isolatable": True, "must_close_valves": ["V-1"]}
assert captured == {
"network": "fengyang",
"accident_element": ["P-1", "P-2"],
"disabled_valves": ["V-9"],
}
def test_scada_cleaning_runs_are_post() -> None:
methods_by_path = {
route.path: route.methods
for route in api_router.routes
if isinstance(route, APIRoute)
}
assert methods_by_path["/timeseries/scada-cleaning-runs"] == {"POST"}
def test_sensor_placement_excel_export_is_post() -> None:
methods_by_path = {
route.path: route.methods
for route in api_router.routes
if isinstance(route, APIRoute)
}
assert methods_by_path[
"/sensor-placement-schemes/{scheme_id}/exports/excel"
] == {"POST"}
def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None:
captured: dict[str, object] = {}
def fake_get_all_schemes(network, scheme_type=None, query_date=None):
captured.update(
network=network,
scheme_type=scheme_type,
query_date=query_date,
)
return [{"scheme_name": "burst_case", "scheme_type": scheme_type}]
monkeypatch.setattr(
schemes_endpoint,
"get_all_schemes",
fake_get_all_schemes,
)
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
project_context = ProjectContext(
project_id=uuid4(),
project_code="fengyang",
user_id=uuid4(),
project_role="viewer",
)
app.dependency_overrides[get_project_context] = lambda: project_context
response = TestClient(app, raise_server_exceptions=False).get(
"/api/v1/schemes",
params={"scheme_type": "burst_analysis"},
)
assert response.status_code == 200
assert captured == {
"network": "fengyang",
"scheme_type": "burst_analysis",
"query_date": None,
}
assert response.json()["items"] == [
{"scheme_name": "burst_case", "scheme_type": "burst_analysis"}
]
+14 -8
View File
@@ -78,7 +78,7 @@ def test_project_info_returns_404_when_missing(monkeypatch):
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
client = TestClient(app)
response = client.get("/api/v1/project_info/", params={"network": "missing"})
response = client.get("/api/v1/projects/current", params={"network": "missing"})
assert response.status_code == 404
assert response.json()["detail"] == "Project missing not found"
@@ -100,7 +100,7 @@ def test_project_info_returns_project_workspace(monkeypatch):
app.dependency_overrides[module.get_metadata_repository] = lambda: repo
client = TestClient(app)
response = client.get("/api/v1/project_info/", params={"network": "demo"})
response = client.get("/api/v1/projects/current", params={"network": "demo"})
assert response.status_code == 200
payload = response.json()
@@ -121,7 +121,7 @@ def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch)
monkeypatch.setattr(module, "get_pg_db", failing_get_pg_db)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post("/api/v1/openproject/", params={"network": "demo"})
response = client.post("/api/v1/projects/current", params={"network": "demo"})
assert response.status_code == 200
assert response.json() == "demo"
@@ -133,11 +133,17 @@ def test_project_lock_lifecycle(monkeypatch):
module.lockedPrjs.clear()
client = TestClient(build_test_app(module.router, "/api/v1"))
first_lock = client.post("/api/v1/lockproject/", params={"network": "demo"})
second_lock = client.post("/api/v1/lockproject/", params={"network": "demo"})
locked_by_me = client.get("/api/v1/isprojectlockedbyme/", params={"network": "demo"})
unlock = client.post("/api/v1/unlockproject/", params={"network": "demo"})
locked = client.get("/api/v1/isprojectlocked/", params={"network": "demo"})
first_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
second_lock = client.post("/api/v1/projects/current/lock", params={"network": "demo"})
locked_by_me = client.get(
"/api/v1/projects/current/lock/ownership",
params={"network": "demo"},
)
unlock = client.delete(
"/api/v1/projects/current/lock",
params={"network": "demo"},
)
locked = client.get("/api/v1/projects/current/lock", params={"network": "demo"})
assert first_lock.json() == 0
assert second_lock.json() == 1
+5 -5
View File
@@ -92,8 +92,8 @@ def test_calculate_service_area_contract_uses_only_network(monkeypatch):
)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.get(
"/api/v1/calculateservicearea/",
response = client.post(
"/api/v1/service-area-calculations",
params={"network": "demo", "time_index": 5},
)
schema = client.get("/openapi.json").json()
@@ -103,7 +103,7 @@ def test_calculate_service_area_contract_uses_only_network(monkeypatch):
assert calls == ["demo"]
parameter_names = [
item["name"]
for item in schema["paths"]["/api/v1/calculateservicearea/"]["get"]["parameters"]
for item in schema["paths"]["/api/v1/service-area-calculations"]["post"]["parameters"]
]
assert parameter_names == ["network"]
@@ -121,7 +121,7 @@ def test_add_district_metering_area_converts_boundary_to_tuples(monkeypatch):
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/adddistrictmeteringarea/",
"/api/v1/district-metering-areas",
params={"network": "demo"},
json={"id": "dma-1", "boundary": [[1, 2], [3, 4], [1, 2]]},
)
@@ -145,7 +145,7 @@ def test_generate_virtual_district_reads_centers_from_body(monkeypatch):
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/generatevirtualdistrict/",
"/api/v1/virtual-district-generation-runs",
params={"network": "demo", "inflate_delta": 0.75},
json={"centers": ["J1", "J2"]},
)
+8 -8
View File
@@ -154,7 +154,7 @@ def test_optimize_returns_created_scheme(monkeypatch):
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "北区测压点",
@@ -173,7 +173,7 @@ def test_optimize_returns_created_scheme(monkeypatch):
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "北区测流点",
@@ -190,7 +190,7 @@ def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
def test_optimize_rejects_network_outside_project_context(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "other_project",
"scheme_name": "越权方案",
@@ -207,7 +207,7 @@ def test_optimize_rejects_network_outside_project_context(monkeypatch):
def test_optimize_rejects_network_path_traversal(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "../other_project",
"scheme_name": "非法路径",
@@ -224,7 +224,7 @@ def test_optimize_rejects_network_path_traversal(monkeypatch):
def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "超大方案",
@@ -241,7 +241,7 @@ def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
def test_optimize_rejects_viewer_project_role(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module, project_role="viewer").post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "只读成员方案",
@@ -262,7 +262,7 @@ def test_optimize_rejects_viewer_project_role(monkeypatch):
def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role):
module = _load_module(monkeypatch)
response = _client(module, project_role=project_role).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": f"{project_role}方案",
@@ -284,7 +284,7 @@ def test_optimize_maps_running_project_job_to_409(monkeypatch):
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "并发方案",
+15 -15
View File
@@ -118,7 +118,7 @@ def test_run_project_endpoint_returns_plain_text(monkeypatch):
monkeypatch.setattr(module, "run_project", lambda network: f"report::{network}")
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.get("/api/v1/runproject/", params={"network": "demo"})
response = client.post("/api/v1/project-runs", params={"network": "demo"})
assert response.status_code == 200
assert response.text == "report::demo"
@@ -143,7 +143,7 @@ def test_scheduling_analysis_maps_request_body(monkeypatch):
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/scheduling_analysis/",
"/api/v1/scheduling-analyses",
json={
"network": "demo",
"start_time": "2025-01-01T08:00:00+08:00",
@@ -177,7 +177,7 @@ def test_project_management_maps_named_arguments(monkeypatch):
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/project_management/",
"/api/v1/project-managements",
json={
"network": "demo",
"start_time": "2025-01-01T08:00:00+08:00",
@@ -260,7 +260,7 @@ def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/runsimulationmanuallybydate/",
"/api/v1/simulation-runs",
json={
"name": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
@@ -280,7 +280,7 @@ def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypat
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/runsimulationmanuallybydate/",
"/api/v1/simulation-runs",
json={
"name": "demo",
"start_time": "2025-01-02T03:04:05",
@@ -302,8 +302,8 @@ def test_valve_close_endpoint_passes_scheme_name(monkeypatch):
monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.get(
"/api/v1/valve_close_analysis/",
response = client.post(
"/api/v1/valve-closure-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
@@ -334,8 +334,8 @@ def test_burst_endpoint_passes_current_username(monkeypatch):
monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis)
client = _build_authenticated_client(module)
response = client.get(
"/api/v1/burst_analysis/",
response = client.post(
"/api/v1/burst-analyses",
params={
"network": "demo",
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
@@ -362,8 +362,8 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
client = _build_authenticated_client(module)
response = client.get(
"/api/v1/flushing_analysis/",
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
@@ -401,8 +401,8 @@ def test_contaminant_endpoint_passes_current_username(monkeypatch):
monkeypatch.setattr(module, "contaminant_simulation", fake_contaminant_simulation)
client = _build_authenticated_client(module)
response = client.get(
"/api/v1/contaminant_simulation/",
response = client.post(
"/api/v1/contaminant-simulations",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
@@ -422,8 +422,8 @@ def test_contaminant_endpoint_requires_scheme_name(monkeypatch):
module = _load_simulation_module(monkeypatch)
client = _build_authenticated_client(module)
response = client.get(
"/api/v1/contaminant_simulation/",
response = client.post(
"/api/v1/contaminant-simulations",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",