feat(api): standardize REST contracts and auth
This commit is contained in:
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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": "并发方案",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user