Harden RC7 API response contracts
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 05:07:35 +02:00
parent 8e49b857dd
commit 0fae53a7de
36 changed files with 886 additions and 153 deletions
@@ -0,0 +1,53 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
BACKEND = ROOT / "backend"
def test_no_untyped_fastapi_response_models_remain() -> None:
route_root = BACKEND / "app" / "api" / "routes"
route_sources = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted(route_root.glob("*.py"))
)
assert "response_model=dict" not in route_sources
def test_every_json_success_response_has_a_concrete_canonical_schema() -> None:
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(BACKEND))
from app.main import create_app
from scripts.audit_api_contracts import (
ALLOWED_NON_ENVELOPE_ENDPOINTS,
_validate_response_contracts,
)
openapi = create_app().openapi()
assert _validate_response_contracts(openapi) == []
untyped_successes: set[tuple[str, str]] = set()
for path, path_item in openapi["paths"].items():
for method, operation in path_item.items():
if method.upper() not in {"GET", "POST", "PATCH", "DELETE"}:
continue
has_json_schema = any(
response.get("content", {})
.get("application/json", {})
.get("schema")
for code, response in operation.get("responses", {}).items()
if str(code).startswith("2")
)
if not has_json_schema:
untyped_successes.add((method.upper(), path))
assert untyped_successes == ALLOWED_NON_ENVELOPE_ENDPOINTS - {
("GET", "/health"),
("GET", "/health/live"),
("GET", "/health/ready"),
}
@@ -125,10 +125,15 @@ def test_quality_check_evidence_geojson_rejects_cross_project_access() -> None:
def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
quality_check_id = uuid4()
reference_dataset_id = uuid4()
payload = {
"quality_check_id": str(quality_check_id),
"project_id": str(project_id),
"candidate_dataset_id": None,
"reference_dataset_id": str(reference_dataset_id),
"analysis_run_id": None,
"feature_count": 0,
"warnings": [],
"geojson": {"type": "FeatureCollection", "features": []},
}
+35 -1
View File
@@ -495,9 +495,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
"analyze",
lambda *_args, **_kwargs: {
"dataset_id": str(output_dataset_id),
"product_key": "dtm_1m",
"surface_model": "terrain",
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
"sample_count": 100,
"summary": {"metric_value": 25.0, "metric_unit": "m TAW", "metrics": []},
"slope_sample_count": 81,
"coverage_ratio": 1.0,
"resolution_m": 5.0,
"vertical_reference": "TAW",
"summary": {
"metric_label": "Gemiddelde terreinhoogte",
"metric_value": 25.0,
"metric_unit": "m TAW",
"aggregation_method": "mean",
"primary_metric_key": "terrain_elevation_mean_m",
"metrics": [],
},
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
"limitation_message": "Terrain height is not water depth.",
"generated_at": "2026-07-18T00:00:00Z",
},
)
monkeypatch.setattr(
@@ -507,7 +523,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
"dataset_id": str(output_dataset_id),
"dataset_ids": [str(output_dataset_id)],
"partition_count": 1,
"product_key": "dtm_1m",
"surface_model": "terrain",
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
"sample_count": 100,
"slope_sample_count": 81,
"coverage_ratio": 1.0,
"resolution_m": 5.0,
"vertical_reference": "TAW",
"summary": {
"metric_label": "Gemiddelde terreinhoogte",
"metric_value": 25.0,
"metric_unit": "m TAW",
"aggregation_method": "mean",
"primary_metric_key": "terrain_elevation_mean_m",
"metrics": [],
},
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
"limitation_message": "Terrain height is not water depth.",
"generated_at": "2026-07-18T00:00:00Z",
},
)
app.dependency_overrides[get_db] = lambda: db
@@ -385,9 +385,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
"analyze",
lambda *_args, **_kwargs: {
"dataset_id": str(output_dataset_id),
"product_key": "pluviaal_current_t100",
"mechanism": "pluviaal",
"climate_context": "huidig klimaat",
"probability_class": "middelgrote kans",
"return_period_years": 100,
"selection_bbox": flood_payload().bbox.model_dump(),
"selected_cell_count": 10,
"inundated_cell_count": 4,
"summary": {"metric_value": 0.01, "metric_unit": "ha", "metrics": []},
"inundated_fraction": 0.4,
"resolution_m": 5.0,
"summary": {
"metric_label": "Overstroomde oppervlakte",
"metric_value": 0.01,
"metric_unit": "ha",
"aggregation_method": "positive_depth_area",
"primary_metric_key": "inundated_area_ha",
"metrics": [],
},
"unsupported_metrics": ["permanent_water_volume_m3"],
"limitation_message": "Scenario depth is not bathymetry.",
"generated_at": "2026-07-18T00:00:00Z",
},
)
monkeypatch.setattr(
@@ -397,7 +415,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
"dataset_id": str(output_dataset_id),
"dataset_ids": [str(output_dataset_id)],
"partition_count": 1,
"product_key": "pluviaal_current_t100",
"mechanism": "pluviaal",
"climate_context": "huidig klimaat",
"probability_class": "middelgrote kans",
"return_period_years": 100,
"selection_bbox": flood_payload().bbox.model_dump(),
"selected_cell_count": 10,
"inundated_cell_count": 4,
"inundated_fraction": 0.4,
"resolution_m": 5.0,
"summary": {
"metric_label": "Overstroomde oppervlakte",
"metric_value": 0.01,
"metric_unit": "ha",
"aggregation_method": "positive_depth_area",
"primary_metric_key": "inundated_area_ha",
"metrics": [],
},
"unsupported_metrics": ["permanent_water_volume_m3"],
"limitation_message": "Scenario depth is not bathymetry.",
"generated_at": "2026-07-18T00:00:00Z",
},
)
app.dependency_overrides[get_db] = lambda: db
@@ -514,7 +514,29 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None:
monkeypatch.setattr(
ThematicRasterAnalysisService,
"analyze",
lambda *_args, **_kwargs: {"dataset_id": str(dataset_id), "theme": "population", "summary": {"metric_value": 10.0}},
lambda *_args, **_kwargs: {
"dataset_id": str(dataset_id),
"product_key": "population_density_2019",
"theme": "population",
"metric_kind": "population_density",
"selection_bbox": payload().bbox.model_dump(),
"selected_cell_count": 10,
"valid_cell_count": 10,
"coverage_ratio": 1.0,
"resolution_m": 100.0,
"observation_year": 2019,
"summary": {
"metric_label": "Geraamd aantal inwoners",
"metric_value": 10.0,
"metric_unit": "inwoners",
"aggregation_method": "sum_density_cells",
"primary_metric_key": "estimated_inhabitants",
"metrics": [],
},
"unsupported_metrics": ["current_population"],
"limitation_message": "2019 density estimate.",
"generated_at": "2026-07-18T00:00:00Z",
},
)
app.dependency_overrides[get_db] = lambda: db
try:
@@ -8,12 +8,12 @@ def test_partitioned_raster_routes_are_canonical_and_documented() -> None:
routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8")
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
for path in (
"/datasets/raster/terrain/select",
"/datasets/raster/flood-hazard/select",
):
assert f'@router.post("{path}", response_model=dict)' in routes
assert path in contracts
assert '"/datasets/raster/terrain/select",' in routes
assert "response_model=Envelope[TerrainSelectionResponse]" in routes
assert '"/datasets/raster/flood-hazard/select",' in routes
assert "response_model=Envelope[FloodHazardSelectionResponse]" in routes
assert "/datasets/raster/terrain/select" in contracts
assert "/datasets/raster/flood-hazard/select" in contracts
assert "envelope(TerrainAnalysisService.analyze_partitions" in routes
assert "envelope(FloodHazardAnalysisService.analyze_partitions" in routes
@@ -165,7 +165,13 @@ def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
lambda: {
"source_key": "mdk_bcp_bathymetry",
"status": "tls_error",
"configured_url": "https://example.invalid/wcs",
"tls_verified": False,
"capabilities_reachable": False,
"acquisition_supported": False,
"checked_at": "2026-07-18T00:00:00Z",
"message": "TLS validation failed.",
"limitation_message": "No insecure fallback is permitted.",
},
)
app.dependency_overrides[get_db] = lambda: db