feat(api): standardize REST contracts and auth

This commit is contained in:
2026-07-30 20:38:51 +08:00
parent 9bff6a556a
commit 91f25c8747
77 changed files with 54018 additions and 658 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ def _build_client(user, repo) -> TestClient:
def test_access_context_returns_global_admin_permissions_without_project():
client = _build_client(_user(role="admin"), SimpleNamespace())
response = client.get("/api/v1/access/context")
response = client.get("/api/v1/access-context")
assert response.status_code == 200
payload = response.json()
@@ -62,7 +62,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)},
)
+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("/admin/me" in r for r in routes), "缺少元数据管理认证路由"
assert any("/audit" in r for r in routes), "缺少审计日志路由 (/audit)"
assert "/agent-auth-context" in routes, "缺少 Agent 认证上下文路由"
assert "/admin/users/me" in routes, "缺少元数据管理认证路由"
assert "/audit-logs" in routes, "缺少审计日志路由"
except Exception as e:
pytest.fail(f"路由配置检查失败: {e}")
+2 -4
View File
@@ -6,9 +6,7 @@ from app.api.v1.endpoints import burst_detection as burst_detection_endpoint
def _build_client() -> TestClient:
app = FastAPI()
app.include_router(
burst_detection_endpoint.router, prefix="/api/v1/burst-detection"
)
app.include_router(burst_detection_endpoint.router, prefix="/api/v1")
app.dependency_overrides[
burst_detection_endpoint.get_current_keycloak_username
] = lambda: "tester"
@@ -36,7 +34,7 @@ def test_detect_burst_forwards_target_mode_fields(monkeypatch):
)
response = _build_client().post(
"/api/v1/burst-detection/detect/",
"/api/v1/burst-detections",
json={
"network": "demo",
"scheme_name": "burst-history",
+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
@@ -43,7 +43,7 @@ def test_system_admin_can_import_model_without_project_membership(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.inp", VALID_INP)},
)
@@ -61,7 +61,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)},
)
+255
View File
@@ -0,0 +1,255 @@
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")
assert validate(app.openapi()) == []
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"}
]
+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",