feat(api): standardize REST contracts and auth
This commit is contained in:
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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"]},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
THEME_SCRIPT = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "infra"
|
||||
/ "docker"
|
||||
/ "keycloak"
|
||||
/ "configure-theme.sh"
|
||||
)
|
||||
|
||||
|
||||
def test_theme_script_clears_client_login_theme_override() -> None:
|
||||
script = THEME_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "TJWATER_KEYCLOAK_CLIENT_ID" in script
|
||||
assert "sed -n '1p'" in script
|
||||
assert "--set attributes.login_theme=" in script
|
||||
assert "client ${client_id} 已改为继承 realm 登录主题。" in script
|
||||
Reference in New Issue
Block a user