diff --git a/backend/README.md b/backend/README.md index aa42fc0b..c1fc9ce1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -142,6 +142,7 @@ bash scripts/live_migration_smoke.sh - `GET /api/v1/detection/models` - `GET /api/v1/detection/model-assets` - `POST /api/v1/detection/run` + - `POST /api/v1/detection/run-async` (production browser path) - `GET /api/v1/detection/runs/{analysis_run_id}` - `GET /api/v1/detection/runs/{analysis_run_id}/detections` - YOLO/PyTorch real inference is not enabled in Sprint 8. @@ -184,6 +185,7 @@ bash scripts/live_migration_smoke.sh - Added segmentation endpoints: - `GET /api/v1/segmentation/models` - `POST /api/v1/segmentation/run` + - `POST /api/v1/segmentation/run-async` (production browser path) - `GET /api/v1/segmentation/runs` - `GET /api/v1/segmentation/runs/{analysis_run_id}` - `GET /api/v1/segmentation/runs/{analysis_run_id}/segmentations` @@ -191,6 +193,11 @@ bash scripts/live_migration_smoke.sh - `POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference` - Real SAM and YOLO-seg inference are not enabled in Sprint 9. - Mask paths are provenance/debug artifacts; persisted PostGIS geometry is authoritative for QA, map display and GeoJSON. +- Current configured detection and segmentation run through the async analysis + worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`) and are followed through + `GET /api/v1/projects/{project_id}/jobs/{job_id}`. The Unraid profile sets + `YOLO_REQUIRE_CUDA=true`, so both pipelines fail closed instead of silently + falling back from NVIDIA CUDA to CPU. ## Sprint 17 additions - Added export foundation backed by the existing `exports` table. diff --git a/backend/app/api/guest_scope.py b/backend/app/api/guest_scope.py new file mode 100644 index 00000000..1baada3a --- /dev/null +++ b/backend/app/api/guest_scope.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import Request + +from app.core.errors import AppError + + +def guest_project_scope(request: Request) -> UUID | None: + principal = getattr(request.state, "auth_principal", None) + if getattr(principal, "role", None) != "guest": + return None + project_id = getattr(principal, "project_id", None) + if isinstance(project_id, UUID): + return project_id + raise AppError( + code="GUEST_PROJECT_SCOPE_REQUIRED", + message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.", + status_code=403, + ) + + +def assert_guest_project_scope(request: Request, project_id: UUID) -> None: + guest_project_id = guest_project_scope(request) + if guest_project_id is not None and project_id != guest_project_id: + raise AppError( + code="GUEST_PROJECT_SCOPE_REQUIRED", + message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.", + status_code=403, + ) + + +def guest_scoped_project_filter( + request: Request, + requested_project_id: UUID | None, +) -> UUID | None: + guest_project_id = guest_project_scope(request) + if guest_project_id is None: + return requested_project_id + if requested_project_id is not None: + assert_guest_project_scope(request, requested_project_id) + return guest_project_id diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index c61e0915..5ea713d8 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -2,9 +2,14 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session +from app.api.guest_scope import ( + assert_guest_project_scope, + guest_project_scope, + guest_scoped_project_filter, +) from app.db.session import get_db from app.schemas import ( AnalysisQaResponse, @@ -25,6 +30,7 @@ from app.schemas import ( YoloPreflightResponse, ) from app.services.detection_comparison_service import DetectionComparisonService +from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_registry_service import ModelRegistryService @@ -60,7 +66,12 @@ def get_yolo_preflight( @router.post("/run", response_model=Envelope[DetectionRunResponse]) -def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: +def run_detection( + payload: DetectionRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: + assert_guest_project_scope(request, payload.project_id) result = DetectionService.run_detection( db=db, project_id=payload.project_id, @@ -76,7 +87,11 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) - @router.post("/run-async", response_model=Envelope[JobRead]) -def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: +def queue_detection( + payload: DetectionRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: """Queue a detection run for the background worker. Tiled GPU inference takes minutes; ``POST /detection/run`` performs it @@ -84,6 +99,7 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) ``GET /jobs/{id}`` for the queued run instead. """ + assert_guest_project_scope(request, payload.project_id) job = DetectionService.enqueue_detection( db=db, project_id=payload.project_id, @@ -100,12 +116,14 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) @router.get("/runs", response_model=Envelope[DetectionRunListResponse]) def list_detection_runs( + request: Request, project_id: UUID | None = None, dataset_id: UUID | None = None, limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + project_id = guest_scoped_project_filter(request, project_id) return envelope( DetectionService.list_runs( db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset @@ -114,8 +132,14 @@ def list_detection_runs( @router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead]) -def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(DetectionService.get_run(db, analysis_run_id).model_dump()) +def get_detection_run( + analysis_run_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) + return envelope(run.model_dump()) @router.get( @@ -124,6 +148,7 @@ def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> d ) def list_detection_run_detections( analysis_run_id: UUID, + request: Request, dataset_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -136,6 +161,9 @@ def list_detection_run_detections( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.list_detections( db, @@ -155,6 +183,7 @@ def list_detection_run_detections( ) def list_dataset_detections( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -167,6 +196,9 @@ def list_dataset_detections( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.list_detections( db, @@ -181,8 +213,14 @@ def list_dataset_detections( @router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead]) -def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(DetectionService.get_detection(db, detection_id).model_dump()) +def get_detection( + detection_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + detection = DetectionService.get_detection(db, detection_id) + assert_guest_project_scope(request, detection.project_id) + return envelope(detection.model_dump()) @router.get( @@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: ) def get_detection_run_geojson( analysis_run_id: UUID, + request: Request, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( @@ -201,6 +240,9 @@ def get_detection_run_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.detections_to_geojson( db, @@ -218,6 +260,7 @@ def get_detection_run_geojson( ) def get_dataset_detection_geojson( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -229,6 +272,9 @@ def get_dataset_detection_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.detections_to_geojson( db, @@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De def compare_detection_run_with_reference( analysis_run_id: UUID, payload: DetectionQaRequest, + request: Request, db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.compare_detections_with_reference( db=db, diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py index 399122c8..bab2be34 100644 --- a/backend/app/api/routes/exports.py +++ b/backend/app/api/routes/exports.py @@ -2,10 +2,11 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import FileResponse from sqlalchemy.orm import Session +from app.api.guest_scope import assert_guest_project_scope, guest_project_scope from app.core.errors import AppError from app.db.session import get_db from app.schemas import Envelope @@ -20,13 +21,30 @@ from app.schemas.export import ( ReportExportRequest, ) from app.services.export_service import ExportService +from app.services.dataset_service import DatasetService +from app.services.detection_service import DetectionService +from app.services.segmentation_service import SegmentationService from app.utils.response import envelope router = APIRouter(prefix="/exports", tags=["exports"]) @router.post("/geojson", response_model=Envelope[ExportCreateResponse]) -def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)): +def export_geojson( + payload: GeoJsonExportRequest, + request: Request, + db: Session = Depends(get_db), +): + if guest_project_scope(request) is not None: + if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None: + dataset = DatasetService.get_dataset(db, payload.dataset_id) + assert_guest_project_scope(request, dataset.project_id) + elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None: + run = DetectionService.get_run(db, payload.analysis_run_id) + assert_guest_project_scope(request, run.project_id) + elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None: + run = SegmentationService.get_run(db, payload.analysis_run_id) + assert_guest_project_scope(request, run.project_id) if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None: return envelope( ExportService.export_vector_selection_geojson( @@ -61,17 +79,32 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)) @router.post("/metadata", response_model=Envelope[ExportCreateResponse]) -def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)): +def export_project_metadata( + payload: MetadataExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json")) @router.post("/report", response_model=Envelope[ExportCreateResponse]) -def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)): +def export_project_report( + payload: ReportExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json")) @router.post("/map-result", response_model=Envelope[ExportCreateResponse]) -def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)): +def export_map_result( + payload: MapResultExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json")) @@ -81,25 +114,35 @@ def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get ) def list_project_exports( project_id: UUID, + request: Request, limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): + assert_guest_project_scope(request, project_id) return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json")) @router.get("/{export_id}", response_model=Envelope[ExportRead]) -def get_export(export_id: UUID, db: Session = Depends(get_db)): - return envelope(ExportService.get_export(db, export_id).model_dump(mode="json")) +def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)): + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) + return envelope(export.model_dump(mode="json")) @router.get("/{export_id}/download") -def download_export(export_id: UUID, db: Session = Depends(get_db)): +def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)): + if guest_project_scope(request) is not None: + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) path = ExportService.get_export_download_path(db, export_id) media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json" return FileResponse(path, filename=path.name, media_type=media_type) @router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse]) -def get_export_content(export_id: UUID, db: Session = Depends(get_db)): +def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)): + if guest_project_scope(request) is not None: + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json")) diff --git a/backend/app/api/routes/segmentation.py b/backend/app/api/routes/segmentation.py index aba33bb7..7113bb9d 100644 --- a/backend/app/api/routes/segmentation.py +++ b/backend/app/api/routes/segmentation.py @@ -2,9 +2,14 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session +from app.api.guest_scope import ( + assert_guest_project_scope, + guest_project_scope, + guest_scoped_project_filter, +) from app.db.session import get_db from app.schemas import ( AnalysisQaResponse, @@ -21,6 +26,7 @@ from app.schemas import ( SegmentationRunResponse, ) from app.services.model_registry_service import ModelRegistryService +from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.segmentation_service import SegmentationService from app.utils.response import envelope @@ -34,7 +40,12 @@ def list_segmentation_models() -> dict: @router.post("/run", response_model=Envelope[SegmentationRunResponse]) -def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict: +def run_segmentation( + payload: SegmentationRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: + assert_guest_project_scope(request, payload.project_id) result = SegmentationService.run_segmentation( db=db, project_id=payload.project_id, @@ -49,13 +60,18 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_ @router.post("/run-async", response_model=Envelope[JobRead]) -def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict: +def queue_segmentation( + payload: SegmentationRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: """Queue a segmentation run for the background worker. Configured segmentation walks the same tile manifest as detection and is just as unsuited to running inside the request. Poll ``GET /jobs/{id}``. """ + assert_guest_project_scope(request, payload.project_id) job = SegmentationService.enqueue_segmentation( db=db, project_id=payload.project_id, @@ -71,12 +87,14 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge @router.get("/runs", response_model=Envelope[SegmentationRunListResponse]) def list_segmentation_runs( + request: Request, project_id: UUID | None = None, dataset_id: UUID | None = None, limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + project_id = guest_scoped_project_filter(request, project_id) return envelope( SegmentationService.list_runs( db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset @@ -85,8 +103,14 @@ def list_segmentation_runs( @router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead]) -def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump()) +def get_segmentation_run( + analysis_run_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) + return envelope(run.model_dump()) @router.get( @@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) - ) def list_segmentation_run_outputs( analysis_run_id: UUID, + request: Request, dataset_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -107,6 +132,9 @@ def list_segmentation_run_outputs( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.list_segmentations( db, @@ -126,6 +154,7 @@ def list_segmentation_run_outputs( ) def list_dataset_segmentations( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -138,6 +167,9 @@ def list_dataset_segmentations( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( SegmentationService.list_segmentations( db, @@ -152,8 +184,14 @@ def list_dataset_segmentations( @router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead]) -def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump()) +def get_segmentation( + segmentation_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + segmentation = SegmentationService.get_segmentation(db, segmentation_id) + assert_guest_project_scope(request, segmentation.project_id) + return envelope(segmentation.model_dump()) @router.get( @@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di ) def get_segmentation_run_geojson( analysis_run_id: UUID, + request: Request, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( @@ -172,6 +211,9 @@ def get_segmentation_run_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.segmentations_to_geojson( db, @@ -189,6 +231,7 @@ def get_segmentation_run_geojson( ) def get_dataset_segmentation_geojson( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( SegmentationService.segmentations_to_geojson( db, @@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson( def compare_segmentation_run_with_reference( analysis_run_id: UUID, payload: SegmentationQaRequest, + request: Request, db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.compare_segmentations_with_reference( db=db, diff --git a/backend/app/main.py b/backend/app/main.py index 0ce07d41..f9fcd5f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -264,7 +264,9 @@ def create_app() -> FastAPI: } guest_scoped_analysis_post_paths = { f"{settings.api_prefix}/detection/run", + f"{settings.api_prefix}/detection/run-async", f"{settings.api_prefix}/segmentation/run", + f"{settings.api_prefix}/segmentation/run-async", f"{settings.api_prefix}/qa/detections-vs-reference", f"{settings.api_prefix}/exports/geojson", f"{settings.api_prefix}/exports/metadata", diff --git a/backend/app/schemas/segmentation.py b/backend/app/schemas/segmentation.py index 42e5f3ce..a90155c7 100644 --- a/backend/app/schemas/segmentation.py +++ b/backend/app/schemas/segmentation.py @@ -102,4 +102,9 @@ class SegmentationRead(BaseModel): class SegmentationListResponse(BaseModel): items: list[SegmentationRead] + # ``total`` describes the complete filtered population; ``items`` is one + # stable confidence-ranked page of it. total: int + limit: int | None = None + offset: int = 0 + truncated: bool = False diff --git a/backend/app/services/outbound_request_guard.py b/backend/app/services/outbound_request_guard.py index b8f0cdf4..ec205965 100644 --- a/backend/app/services/outbound_request_guard.py +++ b/backend/app/services/outbound_request_guard.py @@ -18,7 +18,7 @@ import socket from collections.abc import Callable from typing import Any from urllib.parse import urlparse -from urllib.request import HTTPRedirectHandler, build_opener, urlopen +from urllib.request import HTTPRedirectHandler, build_opener from app.core.errors import AppError @@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler): return None +class _ValidatedRedirects(HTTPRedirectHandler): + """Validate a redirect target before urllib opens the next connection.""" + + def __init__(self, expected_url: str) -> None: + super().__init__() + self.expected_url = expected_url + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102 + assert_same_origin_redirect(self.expected_url, newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + def no_redirect_opener(): """An opener that will not follow a redirect anywhere.""" return build_opener(_RejectRedirects()) +def validated_redirect_opener(expected_url: str): + """An opener that validates each redirect before following it.""" + + return build_opener(_ValidatedRedirects(expected_url)) + + def _reject(code: str, message: str, **details: Any) -> AppError: return AppError(code=code, message=message, details=details or None, status_code=502) @@ -88,6 +106,20 @@ def assert_public_http_url(url: str) -> None: host = parsed.hostname if not host: raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url) + if parsed.username is not None or parsed.password is not None: + raise _reject( + "OUTBOUND_URL_NOT_ALLOWED", + "Bounded acquisition refuses credentials embedded in an outbound URL.", + host=host, + ) + try: + parsed.port + except ValueError as error: + raise _reject( + "OUTBOUND_URL_NOT_ALLOWED", + "Outbound request contains an invalid port.", + host=host, + ) from error literal = host.strip("[]") candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host) @@ -133,15 +165,28 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None: "The official endpoint redirected from HTTPS to an unprotected scheme.", redirect_scheme=final.scheme, ) + # This catches embedded credentials, invalid ports and non-public + # resolutions before the redirect handler can construct the next request. assert_public_http_url(final_url) + original_port = original.port or (443 if original.scheme == "https" else 80) + final_port = final.port or (443 if final.scheme == "https" else 80) + same_scheme_port = final.scheme == original.scheme and final_port == original_port + safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443 + if not (same_scheme_port or safe_https_upgrade): + raise _reject( + "OUTBOUND_REDIRECT_NOT_ALLOWED", + "The official endpoint redirected to a different network origin.", + expected_port=original_port, + redirect_port=final_port, + ) def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]: - """An ``urlopen`` replacement that verifies where the response came from. + """An ``urlopen`` replacement that keeps redirects on the expected origin. - ``urlopen`` has already followed the redirect chain by the time it returns, - so the check is on ``response.url``: the body is still unread, and raising - here means nothing off-origin is ever parsed or persisted. + Redirect targets are validated by the handler *before* urllib opens the + next connection. The final response URL is checked again as a defensive + invariant for injected/custom transports. ``allow_redirect=False`` refuses any redirect at all, which is what the paged OGC feature readers want: a page URL they built themselves should be @@ -151,7 +196,11 @@ def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callabl assert_public_http_url(expected_url) - default_transport = urlopen if allow_redirect else no_redirect_opener().open + default_transport = ( + validated_redirect_opener(expected_url).open + if allow_redirect + else no_redirect_opener().open + ) def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any: response = (_transport or default_transport)(request, *args, **kwargs) diff --git a/backend/app/services/segmentation_adapter.py b/backend/app/services/segmentation_adapter.py index 73b4b1bc..5d33510d 100644 --- a/backend/app/services/segmentation_adapter.py +++ b/backend/app/services/segmentation_adapter.py @@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase: message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].", status_code=503, ) + self.validate_runtime() + + def validate_runtime(self) -> None: + """Fail closed when the deployment contract requires NVIDIA CUDA. + + Detection and segmentation share ``YOLO_DEVICE`` and + ``YOLO_REQUIRE_CUDA``. Without this check segmentation could advertise + a GPU job while Ultralytics silently used CPU or failed only after the + model had already been loaded. + """ + + if not self.settings.yolo_require_cuda: + return + try: + import torch + except Exception as exc: + raise AppError( + code="SEGMENTATION_ACCELERATOR_UNAVAILABLE", + message="NVIDIA CUDA is required for configured segmentation, but PyTorch is not importable.", + status_code=503, + ) from exc + if not torch.cuda.is_available(): + raise AppError( + code="SEGMENTATION_ACCELERATOR_UNAVAILABLE", + message="NVIDIA CUDA is required for configured segmentation, but no CUDA device is available.", + details={"configured_device": self.settings.yolo_device}, + status_code=503, + ) + if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")): + raise AppError( + code="SEGMENTATION_ACCELERATOR_MISCONFIGURED", + message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.", + details={"configured_device": self.settings.yolo_device}, + status_code=503, + ) def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]: if not tile_path.exists() or not tile_path.is_file(): diff --git a/backend/app/services/segmentation_service.py b/backend/app/services/segmentation_service.py index be3052a9..62031149 100644 --- a/backend/app/services/segmentation_service.py +++ b/backend/app/services/segmentation_service.py @@ -272,6 +272,8 @@ class SegmentationService: dataset_id: uuid.UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, + limit: int | None = None, + offset: int = 0, ) -> SegmentationListResponse: if analysis_run_id is not None: run = db.get(AnalysisRun, analysis_run_id) @@ -284,8 +286,19 @@ class SegmentationService: class_name=class_name, min_confidence=min_confidence, ) - items = [SegmentationRead.model_validate(row) for row in rows] - return SegmentationListResponse(items=items, total=len(items)) + resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit) + page, total, truncated = DetectionService.paginate( + rows, + limit=resolved_limit, + offset=offset, + ) + return SegmentationListResponse( + items=[SegmentationRead.model_validate(row) for row in page], + total=total, + limit=resolved_limit, + offset=max(0, int(offset)), + truncated=truncated, + ) @staticmethod def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead: @@ -847,7 +860,6 @@ class SegmentationService: "suppressed_segmentation_count": len(candidates) - len(filtered_candidates), "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold), - "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()), "runtime_model_provenance": runtime_model_provenance.as_dict(), } diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index 6b94cd30..70b9dfe2 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = { ), "shell": ( "App.tsx", + "WorkbenchApp.tsx", "components/shell/WorkbenchNavigation.tsx", "components/shell/SecondaryDisplay.tsx", "components/inspector/WorkbenchInspector.tsx", diff --git a/backend/tests/test_guest_resource_scope.py b/backend/tests/test_guest_resource_scope.py new file mode 100644 index 00000000..3a719e34 --- /dev/null +++ b/backend/tests/test_guest_resource_scope.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import get_settings +from app.db.session import get_db +from app.main import create_app +from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation +from app.schemas import ( + DetectionRunListResponse, + DetectionRunResponse, + SegmentationRunListResponse, + SegmentationRunResponse, +) +from app.services.auth_service import AuthService +from app.services.detection_service import DetectionService +from app.services.segmentation_service import SegmentationService + + +GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123") +OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999") +DATASET_ID = UUID("00000000-0000-0000-0000-000000000201") +DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202") +SEGMENTATION_RUN_ID = UUID("00000000-0000-0000-0000-000000000203") +DETECTION_ID = UUID("00000000-0000-0000-0000-000000000204") +SEGMENTATION_ID = UUID("00000000-0000-0000-0000-000000000205") +EXPORT_ID = UUID("00000000-0000-0000-0000-000000000206") +JOB_ID = UUID("00000000-0000-0000-0000-000000000207") + + +class FakeSession: + def __init__(self, objects: dict[tuple[type, UUID], object]) -> None: + self.objects = objects + + def get(self, model, row_id): + return self.objects.get((model, row_id)) + + +def _guest_client(monkeypatch, db: FakeSession) -> TestClient: + password_hash = AuthService.hash_password( + "operator-password", + salt=b"guest-scope-test-salt", + iterations=100_000, + ) + monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true") + monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator") + monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash) + monkeypatch.setenv( + "GEOINTEL_AUTH_SESSION_SECRET", + "guest-scope-test-session-secret-value", + ) + monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true") + monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast") + + client = TestClient(create_app()) + + def fake_db(): + yield db + + client.app.dependency_overrides[get_db] = fake_db + token = AuthService.create_session_token( + "Gast", + get_settings(), + role="guest", + project_id=GUEST_PROJECT_ID, + ) + client.cookies.set("geointel_session", token) + return client + + +def _project_objects(project_id: UUID, export_path: Path) -> dict[tuple[type, UUID], object]: + return { + (Dataset, DATASET_ID): Dataset( + id=DATASET_ID, + project_id=project_id, + name="scope-test.tif", + dataset_type="raster", + source="fixture", + ), + (AnalysisRun, DETECTION_RUN_ID): AnalysisRun( + id=DETECTION_RUN_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_type="detection", + status="success", + parameters_json={}, + ), + (AnalysisRun, SEGMENTATION_RUN_ID): AnalysisRun( + id=SEGMENTATION_RUN_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_type="segmentation", + status="success", + parameters_json={}, + ), + (Detection, DETECTION_ID): Detection( + id=DETECTION_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_run_id=DETECTION_RUN_ID, + model_name="fixture-detector", + class_name="building", + confidence=0.9, + geometry="SRID=4326;POINT (5 51)", + ), + (Segmentation, SEGMENTATION_ID): Segmentation( + id=SEGMENTATION_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_run_id=SEGMENTATION_RUN_ID, + model_name="fixture-segmenter", + class_name="building", + confidence=0.9, + geometry="SRID=4326;MULTIPOLYGON (((5 51, 5.1 51, 5.1 51.1, 5 51)))", + ), + (Export, EXPORT_ID): Export( + id=EXPORT_ID, + project_id=project_id, + export_type="dataset_geojson", + storage_path=str(export_path), + metadata_json={}, + ), + } + + +@pytest.mark.parametrize( + "path", + [ + f"/api/v1/detection/runs/{DETECTION_RUN_ID}", + f"/api/v1/detection/runs/{DETECTION_RUN_ID}/detections", + f"/api/v1/detection/runs/{DETECTION_RUN_ID}/geojson", + f"/api/v1/detection/datasets/{DATASET_ID}/detections", + f"/api/v1/detection/datasets/{DATASET_ID}/geojson", + f"/api/v1/detection/detections/{DETECTION_ID}", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/segmentations", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/geojson", + f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", + f"/api/v1/segmentation/datasets/{DATASET_ID}/geojson", + f"/api/v1/segmentation/segmentations/{SEGMENTATION_ID}", + f"/api/v1/exports/{EXPORT_ID}", + f"/api/v1/exports/{EXPORT_ID}/content", + f"/api/v1/exports/{EXPORT_ID}/download", + f"/api/v1/exports/projects/{OTHER_PROJECT_ID}/exports", + ], +) +def test_matching_guest_query_cannot_authorize_another_projects_resource( + path: str, + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "other-project.geojson" + artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact))) + + response = client.get(f"{path}?project_id={GUEST_PROJECT_ID}") + + assert response.status_code == 403 + assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + + +@pytest.mark.parametrize( + ("path", "payload"), + [ + ( + "/api/v1/detection/run", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/detection/run-async", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/segmentation/run", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/segmentation/run-async", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/detection/runs/{run_id}/qa/reference".format(run_id=DETECTION_RUN_ID), + {"reference_dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/segmentation/runs/{run_id}/qa/reference".format(run_id=SEGMENTATION_RUN_ID), + {"reference_dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "dataset", "dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "detection_run", "analysis_run_id": str(DETECTION_RUN_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "segmentation_run", "analysis_run_id": str(SEGMENTATION_RUN_ID)}, + ), + ( + "/api/v1/exports/metadata", + {"project_id": str(OTHER_PROJECT_ID)}, + ), + ( + "/api/v1/exports/report", + {"project_id": str(OTHER_PROJECT_ID)}, + ), + ( + "/api/v1/exports/map-result", + { + "project_id": str(OTHER_PROJECT_ID), + "mode": "current", + "dataset_id": str(DATASET_ID), + "bbox": {"min_x": 5.0, "min_y": 51.0, "max_x": 5.1, "max_y": 51.1, "crs": "EPSG:4326"}, + }, + ), + ], +) +def test_matching_guest_query_cannot_override_post_body_or_target_scope( + path: str, + payload: dict, + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "other-project.geojson" + artifact.write_text("{}", encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact))) + + response = client.post(f"{path}?project_id={GUEST_PROJECT_ID}", json=payload) + + assert response.status_code == 403 + assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + + +def test_guest_can_still_read_and_download_its_own_resources( + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "demo.geojson" + artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact))) + suffix = f"?project_id={GUEST_PROJECT_ID}" + + detection = client.get(f"/api/v1/detection/runs/{DETECTION_RUN_ID}{suffix}") + segmentation = client.get(f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}{suffix}") + export = client.get(f"/api/v1/exports/{EXPORT_ID}{suffix}") + download = client.get(f"/api/v1/exports/{EXPORT_ID}/download{suffix}") + + assert detection.status_code == 200 + assert segmentation.status_code == 200 + assert export.status_code == 200 + assert download.status_code == 200 + assert download.json()["type"] == "FeatureCollection" + + +def test_guest_run_lists_and_new_runs_remain_bound_to_the_session_project( + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "demo.geojson" + artifact.write_text("{}", encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact))) + observed: list[UUID] = [] + + def detection_list(_db, *, project_id, **_kwargs): + observed.append(project_id) + return DetectionRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False) + + def segmentation_list(_db, *, project_id, **_kwargs): + observed.append(project_id) + return SegmentationRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False) + + def detection_run(**kwargs): + observed.append(kwargs["project_id"]) + return DetectionRunResponse( + analysis_run_id=DETECTION_RUN_ID, + job_id=JOB_ID, + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + model_id=kwargs["model_id"], + status="success", + detection_count=0, + message="Demo run completed", + ) + + def segmentation_run(**kwargs): + observed.append(kwargs["project_id"]) + return SegmentationRunResponse( + analysis_run_id=SEGMENTATION_RUN_ID, + job_id=JOB_ID, + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + model_id=kwargs["model_id"], + status="success", + segmentation_count=0, + message="Demo run completed", + ) + + monkeypatch.setattr(DetectionService, "list_runs", detection_list) + monkeypatch.setattr(SegmentationService, "list_runs", segmentation_list) + monkeypatch.setattr(DetectionService, "run_detection", detection_run) + monkeypatch.setattr(SegmentationService, "run_segmentation", segmentation_run) + + def enqueue_detection(**kwargs): + observed.append(kwargs["project_id"]) + return Job( + id=JOB_ID, + job_type="detection.run", + status="queued", + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + parameters_json={}, + ) + + monkeypatch.setattr(DetectionService, "enqueue_detection", enqueue_detection) + + def enqueue_segmentation(**kwargs): + observed.append(kwargs["project_id"]) + return Job( + id=JOB_ID, + job_type="segmentation.run", + status="queued", + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + parameters_json={}, + ) + + monkeypatch.setattr(SegmentationService, "enqueue_segmentation", enqueue_segmentation) + query = f"?project_id={GUEST_PROJECT_ID}" + payload = {"project_id": str(GUEST_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"} + + responses = [ + client.get(f"/api/v1/detection/runs{query}"), + client.get(f"/api/v1/segmentation/runs{query}"), + client.post(f"/api/v1/detection/run{query}", json=payload), + client.post(f"/api/v1/detection/run-async{query}", json=payload), + client.post(f"/api/v1/segmentation/run{query}", json=payload), + client.post(f"/api/v1/segmentation/run-async{query}", json=payload), + ] + + assert all(response.status_code == 200 for response in responses) + assert observed == [GUEST_PROJECT_ID] * 6 diff --git a/backend/tests/test_outbound_request_guard.py b/backend/tests/test_outbound_request_guard.py index c0bc3e68..f866cd5f 100644 --- a/backend/tests/test_outbound_request_guard.py +++ b/backend/tests/test_outbound_request_guard.py @@ -18,8 +18,10 @@ import pytest from app.core.errors import AppError from app.services.outbound_request_guard import ( + _ValidatedRedirects, assert_public_http_url, assert_same_origin_redirect, + validated_redirect_opener, ) @@ -89,6 +91,30 @@ class TestRedirects: def test_an_upgrade_to_https_stays_allowed(self) -> None: assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs") + def test_a_redirect_to_another_port_is_refused(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_same_origin_redirect( + "https://geo.api.vlaanderen.be/wcs", + "https://geo.api.vlaanderen.be:8443/wcs", + ) + + assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED" + + def test_embedded_credentials_are_refused(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_public_http_url("https://operator:secret@geo.example.be/wcs") + + assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED" + + def test_a_redirect_with_an_invalid_port_fails_closed(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_same_origin_redirect( + "https://geo.api.vlaanderen.be/wcs", + "https://geo.api.vlaanderen.be:not-a-port/wcs", + ) + + assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED" + def test_the_guard_opener_refuses_a_cross_host_redirect() -> None: """The opener is what the acquisition services actually call.""" @@ -249,6 +275,26 @@ def test_a_refused_redirect_is_never_requested() -> None: assert "_RejectRedirects" in handlers +def test_the_default_guard_validates_before_following_a_redirect() -> None: + opener = validated_redirect_opener("https://geo.api.vlaanderen.be/wcs") + handlers = [type(handler).__name__ for handler in opener.handlers] + + assert "_ValidatedRedirects" in handlers + + handler = _ValidatedRedirects("https://geo.api.vlaanderen.be/wcs") + with pytest.raises(AppError) as exc_info: + handler.redirect_request( + None, + None, + 302, + "Found", + {}, + "http://169.254.169.254/latest/meta-data/", + ) + + assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED" + + def test_the_rejecting_handler_returns_no_new_request() -> None: from app.services.outbound_request_guard import _RejectRedirects diff --git a/backend/tests/test_segmentation_adapter_runtime.py b/backend/tests/test_segmentation_adapter_runtime.py new file mode 100644 index 00000000..f5d2d10b --- /dev/null +++ b/backend/tests/test_segmentation_adapter_runtime.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.core.config import Settings +from app.core.errors import AppError +from app.services.segmentation_adapter import YoloSegmentationAdapter + + +def _settings(*, require_cuda: bool, device: str) -> Settings: + return Settings( + _env_file=None, + YOLO_REQUIRE_CUDA=require_cuda, + YOLO_DEVICE=device, + ) + + +def test_segmentation_runtime_allows_cpu_only_when_cuda_is_not_required() -> None: + adapter = YoloSegmentationAdapter(_settings(require_cuda=False, device="cpu")) + + adapter.validate_runtime() + + +def test_segmentation_runtime_rejects_missing_cuda(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0")) + + with pytest.raises(AppError) as exc_info: + adapter.validate_runtime() + + assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_UNAVAILABLE" + + +def test_segmentation_runtime_rejects_cpu_device_when_cuda_is_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cpu")) + + with pytest.raises(AppError) as exc_info: + adapter.validate_runtime() + + assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_MISCONFIGURED" + + +def test_segmentation_runtime_accepts_configured_cuda(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0")) + + adapter.validate_runtime() diff --git a/backend/tests/test_segmentation_result_pagination.py b/backend/tests/test_segmentation_result_pagination.py new file mode 100644 index 00000000..b449001f --- /dev/null +++ b/backend/tests/test_segmentation_result_pagination.py @@ -0,0 +1,151 @@ +"""Regression coverage for bounded, stable segmentation result listings.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.routes import segmentation as segmentation_routes +from app.db.session import get_db +from app.schemas.segmentation import SegmentationListResponse +from app.services.segmentation_service import SegmentationService + + +RUN_ID = UUID("00000000-0000-0000-0000-000000000101") +DATASET_ID = UUID("00000000-0000-0000-0000-000000000102") +PROJECT_ID = UUID("00000000-0000-0000-0000-000000000103") + + +def _segmentation(index: int) -> SimpleNamespace: + return SimpleNamespace( + id=UUID(int=index + 1), + project_id=PROJECT_ID, + dataset_id=DATASET_ID, + analysis_run_id=RUN_ID, + job_id=None, + model_name="segmentation-test-model", + model_version="1", + class_name="building", + confidence=0.99 - index / 100, + bbox_json=None, + area_m2=float(index + 1), + mask_path=None, + source_tile_path=None, + tile_index=index, + properties_json={}, + provenance_json={}, + created_at=datetime(2026, 8, 23, tzinfo=UTC), + ) + + +class _Session: + def get(self, _model, identifier): + if identifier == RUN_ID: + return SimpleNamespace(analysis_type="segmentation") + return None + + +def test_service_returns_one_stable_page_with_complete_metadata(monkeypatch) -> None: + rows = [_segmentation(index) for index in range(5)] + monkeypatch.setattr( + SegmentationService, + "_query_segmentation_rows", + staticmethod(lambda _db, **_filters: rows), + ) + + result = SegmentationService.list_segmentations( + _Session(), + analysis_run_id=RUN_ID, + dataset_id=DATASET_ID, + limit=2, + offset=1, + ) + + assert [item.id for item in result.items] == [rows[1].id, rows[2].id] + assert result.total == 5 + assert result.limit == 2 + assert result.offset == 1 + assert result.truncated is True + + +def test_service_pages_cover_the_stably_ordered_population_once(monkeypatch) -> None: + rows = [_segmentation(index) for index in range(5)] + monkeypatch.setattr( + SegmentationService, + "_query_segmentation_rows", + staticmethod(lambda _db, **_filters: rows), + ) + + seen = [] + for offset in (0, 2, 4): + result = SegmentationService.list_segmentations( + _Session(), + dataset_id=DATASET_ID, + limit=2, + offset=offset, + ) + seen.extend(item.id for item in result.items) + assert result.total == len(rows) + assert result.offset == offset + + assert seen == [row.id for row in rows] + + +@pytest.mark.parametrize( + ("path", "expected_run_id", "expected_dataset_id"), + [ + (f"/api/v1/segmentation/runs/{RUN_ID}/segmentations", RUN_ID, None), + (f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", None, DATASET_ID), + ], +) +def test_both_listing_routes_forward_the_page_window_and_return_it( + monkeypatch, + path: str, + expected_run_id: UUID | None, + expected_dataset_id: UUID | None, +) -> None: + calls: list[dict] = [] + + def _list(_db, analysis_run_id=None, **parameters): + calls.append({"analysis_run_id": analysis_run_id, **parameters}) + return SegmentationListResponse( + items=[], + total=9, + limit=2, + offset=4, + truncated=True, + ) + + monkeypatch.setattr(SegmentationService, "list_segmentations", staticmethod(_list)) + app = FastAPI() + app.include_router(segmentation_routes.router, prefix="/api/v1") + app.dependency_overrides[get_db] = lambda: object() + + response = TestClient(app).get( + path, + params={"limit": 2, "offset": 4, "class_name": "building", "min_confidence": 0.5}, + ) + + assert response.status_code == 200 + assert response.json()["data"] == { + "items": [], + "total": 9, + "limit": 2, + "offset": 4, + "truncated": True, + } + assert calls == [ + { + "analysis_run_id": expected_run_id, + "limit": 2, + "offset": 4, + "dataset_id": expected_dataset_id, + "class_name": "building", + "min_confidence": 0.5, + } + ] diff --git a/backend/tests/test_sprint104_ai_lab_action_guardrails.py b/backend/tests/test_sprint104_ai_lab_action_guardrails.py index 264d67c0..340f5032 100644 --- a/backend/tests/test_sprint104_ai_lab_action_guardrails.py +++ b/backend/tests/test_sprint104_ai_lab_action_guardrails.py @@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action() assert "detectionRunBlockedReason" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Klaar om gebouwen te zoeken" in lab - assert "disabled={runningDetection || !detectionRunReady}" in lab + assert "disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}" in lab + assert "detectionJob?.status === 'queued' || detectionJob?.status === 'running'" in lab def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action() -> None: @@ -26,7 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action assert "segmentationRunBlockedReason" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Analyse" in lab - assert "disabled={runningSegmentation || !segmentationRunReady}" in lab + assert "disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}" in lab + assert "segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'" in lab def test_ai_lab_guardrail_styles_remain_compact() -> None: diff --git a/backend/tests/test_sprint123_raster_detection_handoff_operational.py b/backend/tests/test_sprint123_raster_detection_handoff_operational.py index cfaf7140..938e2a02 100644 --- a/backend/tests/test_sprint123_raster_detection_handoff_operational.py +++ b/backend/tests/test_sprint123_raster_detection_handoff_operational.py @@ -28,7 +28,12 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None: def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None: - app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + app = "\n".join( + ( + (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8"), + (ROOT / "frontend" / "src" / "WorkbenchApp.tsx").read_text(encoding="utf-8"), + ) + ) lab = "\n".join( ( (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"), @@ -40,7 +45,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app assert "setSelectedDetectionModelId('yolo-configured')" in app assert "setDetectionConfidenceThreshold(0.25)" in app - assert "loadYoloPreflight(manifestPath).catch(() => null)" in app + assert "loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole'))" in app assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))] assert "Gekoppelde beeldtegels" in lab assert "Gekoppelde beeldtegels" in lab diff --git a/backend/tests/test_sprint195_guided_detection_workflow.py b/backend/tests/test_sprint195_guided_detection_workflow.py index 0f8b4221..59a4e87e 100644 --- a/backend/tests/test_sprint195_guided_detection_workflow.py +++ b/backend/tests/test_sprint195_guided_detection_workflow.py @@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None: assert "effectiveModelId" in hook assert "effectiveModelAssetId" in hook assert "await loadDetectionResults(result.analysis_run_id)" in hook - assert "model_id: selectedDetectionModelId" in hook - assert "model_asset_id: selectedModelAssetId || null" in hook + assert "model_id: modelId" in hook + assert "model_asset_id: modelAssetId || null" in hook + assert "effectiveModelId" in hook + assert "effectiveModelAssetId" in hook def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None: @@ -63,7 +65,8 @@ def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None: assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab assert "als kwaliteitscontrole in de database bewaard" in lab assert "detectionApi.compareWithReference" in hook - assert "await loadQualityChecks(selectedProjectId)" in hook + assert "await loadQualityChecks(projectId)" in hook + assert "detectionQaRequestSequence.current" in hook assert "Minimale IoU voor een match" in lab assert "detectionQaResult.iou_threshold.toFixed(2)" in lab diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 122216c6..2a94b0a8 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1528,7 +1528,10 @@ Response: ## Detection Lab -Sprint 8 implements Detection Lab foundation only. YOLO/PyTorch real inference is not enabled, no model is downloaded, and fixture detections require explicit fixture mode. +Detection Lab exposes the governed local YOLO/PyTorch runtime only when model, +dependencies and the configured NVIDIA accelerator pass preflight. GeoIntel +never downloads a model implicitly; fixture detections still require explicit +fixture mode and are not production inference. ### Guided browser orchestration @@ -1537,11 +1540,17 @@ The current frontend offers one guided building-analysis action, but does not ad 1. optional explicit `POST /api/v1/projects/{project_id}/datasets/upload` for a georeferenced GeoTIFF; 2. `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/tile` with 512 px tiles and 64 px overlap; 3. `GET /api/v1/detection/yolo/preflight` with the returned manifest and selected local model asset; -4. `POST /api/v1/detection/run` only after successful preflight; -5. persisted run, Detection list and Detection GeoJSON reads; -6. optional persisted reference QA through the existing detection QA endpoint. +4. `POST /api/v1/detection/run-async` only after successful preflight; +5. project-bound polling through + `GET /api/v1/projects/{project_id}/jobs/{job_id}` until a terminal state; +6. persisted run, Detection list and Detection GeoJSON reads; +7. optional persisted reference QA through the existing detection QA endpoint. -The strict `POST /api/v1/detection/run` contract still requires `tile_manifest_path` for configured YOLO. The frontend does not create fake tiles, bypass tile limits, fetch external imagery or download model weights. +The strict async request contract still requires `tile_manifest_path` for +configured YOLO. The production frontend does not fall back to the synchronous +inference route, create fake tiles, bypass tile limits, fetch external imagery +or download model weights. A zero-count success remains a completed inference, +not proof that the selected area contains no objects. ### GET `/api/v1/detection/models` @@ -1758,8 +1767,12 @@ rejected immediately rather than by a job that fails minutes later. Queued jobs are executed by the background analysis worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval `GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching -it so the same run is never started twice. Poll `GET /api/v1/jobs/{id}` for -progress. `POST /api/v1/segmentation/run-async` behaves identically. +it so the same run is never started twice. Poll the project-bound +`GET /api/v1/projects/{project_id}/jobs/{job_id}` endpoint for progress. +`POST /api/v1/segmentation/run-async` behaves identically. Guest sessions may +queue and read analysis only for the project id embedded in their signed +session; query parameters never authorize a run, result or export belonging to +another project. Unavailable model response: @@ -2032,7 +2045,12 @@ Same pattern as object detection, but output includes masks and polygonized geom ## Segmentation Lab -Sprint 9 implements Segmentation Lab foundation only. Real SAM and YOLO-seg inference are not enabled, no model is downloaded, and fixture segmentations require explicit fixture mode. +Segmentation Lab exposes a configured local YOLO-seg or SAM runtime when its +model file, immutable runtime provenance and dependencies validate. No model is +downloaded. On the NVIDIA server, `YOLO_REQUIRE_CUDA=true` makes both configured +segmentation adapters fail closed when CUDA is absent or `YOLO_DEVICE` selects +CPU. Fixture segmentations remain explicit test-only data and the production +browser never queues that model. ### GET `/api/v1/segmentation/models` @@ -2043,6 +2061,9 @@ Returns segmentation model capability descriptors: - `yolo-seg-configured`: `not_configured` - `sam-configured`: `not_configured` +The two configured entries become `configured` only when their corresponding +enable flag, local model file and provenance sidecar validate. + ### POST `/api/v1/segmentation/run` Creates a segmentation job and segmentation analysis run. If the requested model is unavailable, the job and analysis run are marked `failed` with `SEGMENTATION_MODEL_UNAVAILABLE`. @@ -2063,6 +2084,16 @@ Request: Fixture segmenter mode is test/demo-only. It persists only explicit `parameters_json.fixture_segmentations` entries when `parameters_json.fixture_mode=true`; it is never invoked automatically and does not represent production inference. +The production frontend uses `POST /api/v1/segmentation/run-async`, then polls +`GET /api/v1/projects/{project_id}/jobs/{job_id}` and reconciles the terminal +job with its persisted `AnalysisRun` and polygon records. It does not fall back +to the synchronous route. A configured model requires an existing +`tile_manifest_path`; missing CUDA fails with +`SEGMENTATION_ACCELERATOR_UNAVAILABLE` or +`SEGMENTATION_ACCELERATOR_MISCONFIGURED` when CUDA is required. A valid +zero-polygon run is shown as an empty model result, never as proof that the AOI +contains no relevant objects. + Validation errors: - `INVALID_DATASET_TYPE` when the dataset is not raster. @@ -2086,6 +2117,11 @@ Returns persisted segmentation records for a segmentation analysis run. Optional - `dataset_id` - `class_name` - `min_confidence` +- `limit` (`0` means every matching record, otherwise capped at `50000`) +- `offset` + +The response reports `total`, `limit`, `offset` and `truncated`; clients must +not present a truncated page as the complete polygon population. ### GET `/api/v1/segmentation/datasets/{dataset_id}/segmentations` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 5d7ef2e5..1eae78b1 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -12867,3 +12867,71 @@ Open: - Browser emulation covers responsive layout and interaction; certification on physical touch hardware and with a screen reader remains a separate human QA activity. + +## 2026-08-23 - Sol Ultra product-, runtime- en betrouwbaarheidsronde + +### Delivered + +- Split the public landing foundation from the lazy workbench and MapLibre + styles. The initial production CSS payload dropped from roughly 219 kB to + 38.38 kB while the authenticated workbench keeps its complete styling. +- Extended the reproducible browser audit to cover the landing and workbench at + 390 x 844, 1366 x 768 and 2560 x 1080, including mobile navigation, + keyboard tabs, loading state, advanced map flow and every guest workspace. +- Corrected the smartphone shell hierarchy: topbar, guest banner and page + heading no longer overlap, and the live Selecteer/Bronnen/Verwerk/Controleer + rail now sits below the map actions instead of behind the fixed navigation. +- Kept full workspace titles for headings and accessible names while shortening + the two mobile navigation labels to `AI-beeld` and `Export`; the browser gate + now rejects any visible sidebar label whose text box is clipped. +- Made map-analysis failures outrank empty states and added an explicit retry; + new selections clear stale coverage immediately. +- Replaced synchronous browser inference with governed async detection and + segmentation queues, project-bound job polling and persisted-run + reconciliation. Detection has NVIDIA preflight; segmentation now fails + closed under the same server CUDA contract. Zero-result runs are communicated + without claiming that the AOI is object-free. +- Bound guest detection, segmentation and export reads/writes to the signed + demo project at the resource level. Matching query parameters can no longer + authorize another project's run, dataset, result or download. +- Closed stale-response races in temporal comparison and the local GeoAI + assistant, plus detection/segmentation run, result and QA flows across project + switches. Previously visited workspaces no longer reload together after every + navigation change. +- Added keyboard-complete pipeline tabs and React-driven model-dialog state, + initial focus and trigger-focus restoration. Landing scrolling now respects + `prefers-reduced-motion`. +- Hardened outbound acquisition redirects before the redirected request is + opened, including origin/port and embedded-credential rejection, and fixed + bounded pagination for segmentation result lists. +- Fixed segmentation readiness and section status: configured production + models now require a real tile manifest before queueing, fixture mode is + visibly test-only, and queued/running NVIDIA work has an explicit live state. +- Localised known model registrations and availability states in the Dutch UI; + raw English backend placeholder copy no longer leaks into the primary model + selector or readiness guidance. + +### Verification + +- Frontend TypeScript check and production build passed. +- Complete frontend suite: 36 files / 151 tests passed. +- Relevant backend release set: 121 tests passed, covering async analysis jobs, + atomic claims, guest/resource isolation, redirect policy, segmentation + pagination, NVIDIA runtime enforcement and current AI-lab contracts. +- Ruff passed over every changed backend Python module and test. +- Browser evidence passed across three landing and three authenticated + workbench viewports with zero horizontal overflow, console errors or failed + API requests in `.codex-artifacts/sol-ultra-final-l/manifest.json`; focused + AI-workspace and segmentation screenshots are stored beside it. +- Production build passed. Initial landing CSS remains 38.38 kB (8.22 kB + gzip); the lazy workbench JS is 479.76 kB (129.25 kB gzip) and MapLibre stays + isolated in its own lazy chunk. + +### Boundaries + +- This pass improves runtime correctness and presentation; it does not invent a + new accuracy claim or promote a model checkpoint. Existing governed model + evidence and regional release gates remain authoritative. +- Physical touch-device and screen-reader certification remain human QA. No + commit or deployment was performed because the active execution brief + explicitly forbids committing unless requested. diff --git a/docs/TODO.md b/docs/TODO.md index df77f3c9..9b5a1cd1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1109,6 +1109,32 @@ This file now starts with the current implementation status. Older preparation/b - [x] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening. - [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels. - [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie. + +# Sprint 237 - Sol Ultra productupgrade (2026-08-23) + +- [x] Splits publieke landing-CSS en MapLibre van de lazy werkbankbundel. +- [x] Valideer landing en werkbank op 390, 1366 en 2560 px zonder overflow, + consolefouten of mislukte API-requests. +- [x] Herstel mobiele topbar/banner/kop- en procesrailbotsingen. +- [x] Voorkom afgekapte mobiele navigatielabels met korte zichtlabels en een + automatische clipping-gate. +- [x] Toon analysefouten vóór lege states en bied een herhaalactie. +- [x] Wis oude dekkingsdata zodra een nieuwe AOI wordt opgelost. +- [x] Sluit stale-response races in tijdvergelijking en AI-vragen. +- [x] Sluit late detectie-/segmentatiejobs, resultaten en QA na een + werkruimtewissel uit. +- [x] Voer productie-detectie uitsluitend via async NVIDIA/GPU-jobs uit en + verzoen het resultaat met de bewaarde AnalysisRun. +- [x] Voer productie-segmentatie uitsluitend via async serverjobs uit, eis een + tegelmanifest en laat de NVIDIA-runtime fail-closed valideren. +- [x] Bind gast-detecties, segmentaties en downloads aan het gesigneerde + demoproject op resourceniveau. +- [x] Valideer redirects vóór netwerktoegang en begrens segmentatieresultaten. +- [x] Maak pipeline-tabs en modeldialoog volledig toetsenbordbedienbaar. +- [x] Lokaliseer bekende modelnamen en beschikbaarheidsmeldingen in de primaire + Nederlandse AI-flow. +- [ ] Voer vóór formele toegankelijkheidscertificatie nog fysieke touch- en + screenreader-QA uit; browseremulatie en automatische naamcontrole zijn groen. - [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze. - [x] 51 frontendtests en productiebuild groen. - [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing. diff --git a/frontend/e2e/uxAudit.mjs b/frontend/e2e/uxAudit.mjs index 8a3202ae..02a5145d 100644 --- a/frontend/e2e/uxAudit.mjs +++ b/frontend/e2e/uxAudit.mjs @@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) { return unnamed.length } +async function prepareAuditSession(page, baseUrl) { + const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`) + assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`) + const sessionEnvelope = await sessionResponse.json() + const session = sessionEnvelope?.data + + if (!session?.authentication_required || session.authenticated) return session + assert.equal( + session.guest_access_enabled, + true, + 'UX audit needs an authenticated session or enabled guest access', + ) + + const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`) + assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`) + const guestEnvelope = await guestResponse.json() + return guestEnvelope?.data +} + async function layoutEvidence(page) { return page.evaluate(() => { const root = document.documentElement + const rect = (selector) => { + const bounds = document.querySelector(selector)?.getBoundingClientRect() + return bounds + ? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height } + : null + } const main = document.querySelector('.workbench-main')?.getBoundingClientRect() const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect() const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect() @@ -75,6 +100,11 @@ async function layoutEvidence(page) { document_width: root.scrollWidth, body_width: document.body.scrollWidth, horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth), + shell_navigation: rect('.workbench-sidebar'), + topbar: rect('.workbench-topbar'), + guest_banner: rect('.guest-mode-banner'), + explorer_header: rect('.geo-explorer-header'), + live_analysis_journey: rect('.live-analysis-journey'), main: main ? { left: main.left, right: main.right, width: main.width } : null, map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null, theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null, @@ -82,6 +112,57 @@ async function layoutEvidence(page) { }) } +async function runLandingViewport(browser, baseUrl, outputDir, viewport) { + const page = await browser.newPage({ viewport }) + const consoleErrors = [] + const failedRequests = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => consoleErrors.push(error.message)) + page.on('requestfailed', (request) => { + if (request.url().startsWith(baseUrl)) { + failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`) + } + }) + try { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) + await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 }) + await auditInteractiveNames(page, `${viewport.width}px landing`) + const horizontalOverflow = await page.evaluate(() => ( + Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth) + )) + assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`) + assert.equal( + await page.getByRole('heading', { level: 1 }).count(), + 1, + `${viewport.width}px landing needs one clear primary heading`, + ) + + if (viewport.width <= 760) { + const menu = page.locator('.landing-menu-toggle') + assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen') + await menu.click() + assert.equal(await menu.getAttribute('aria-expanded'), 'true') + await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Navigatie sluiten' }).click() + } + + await page.screenshot({ + path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`), + fullPage: true, + }) + return { + viewport, + horizontal_overflow_px: horizontalOverflow, + console_errors: consoleErrors, + failed_requests: failedRequests, + } + } finally { + await page.close() + } +} + async function runViewport(browser, baseUrl, outputDir, viewport) { const page = await browser.newPage({ viewport }) const consoleErrors = [] @@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) { } }) try { + await prepareAuditSession(page, baseUrl) const startedAt = Date.now() await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 }) const readyMs = Date.now() - startedAt await auditInteractiveNames(page, `${viewport.width}px map explorer`) const layout = await layoutEvidence(page) + const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels + .filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1) + .map((label) => label.textContent?.trim() || '')) assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`) + assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`) assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`) + if (layout.topbar && layout.guest_banner) { + assert( + layout.topbar.bottom <= layout.guest_banner.top + 1, + `${viewport.width}px topbar overlaps the guest access banner`, + ) + } + if (layout.guest_banner && layout.explorer_header) { + assert( + layout.guest_banner.bottom <= layout.explorer_header.top + 1, + `${viewport.width}px guest access banner overlaps the explorer heading`, + ) + } + if (layout.shell_navigation && layout.live_analysis_journey) { + const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom) + - Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top) + const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right) + - Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left) + assert( + verticalOverlap <= 1 || horizontalOverlap <= 1, + `${viewport.width}px navigation overlaps the live analysis journey`, + ) + } const currentTab = page.getByRole('tab', { name: 'Laatste toestand' }) const evolutionTab = page.getByRole('tab', { name: 'Evolutie' }) @@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) { viewport, ready_ms: readyMs, layout, + clipped_navigation_labels: clippedNavigationLabels, console_errors: consoleErrors, failed_requests: failedRequests, } @@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { await route.continue() }) try { + const auditSession = await prepareAuditSession(page, baseUrl) await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }) const loadingStatus = page.getByRole('status', { name: '' }).filter({ hasText: 'Databronnen worden gecontroleerd', @@ -175,10 +285,30 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') }) const auditedWorkspaces = [] - for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) { + const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview'] + if (auditSession?.role === 'guest') { + assert.equal( + await page.getByTestId('workspace-nav-system').count(), + 0, + 'Guest session exposes operator-only system settings', + ) + } else { + workspaceKeys.push('system') + } + for (const workspace of workspaceKeys) { await page.getByTestId(`workspace-nav-${workspace}`).click() await page.waitForTimeout(100) await auditInteractiveNames(page, `${workspace} workspace`) + if (workspace === 'ai') { + await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true }) + const segmentationDisclosure = page.locator('.segmentation-disclosure') + await segmentationDisclosure.scrollIntoViewIfNeeded() + await segmentationDisclosure.locator('summary').first().click() + await page.waitForTimeout(150) + await auditInteractiveNames(page, 'open segmentation lab') + await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded() + await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') }) + } auditedWorkspaces.push(workspace) } @@ -203,21 +333,27 @@ async function main() { schema_version: 1, base_url: args.baseUrl, started_at: new Date().toISOString(), + landing_viewports: [], viewports: [], bootstrap: null, status: 'running', } try { - for (const viewport of [ + const viewports = [ { width: 390, height: 844 }, { width: 1366, height: 768 }, { width: 2560, height: 1080 }, - ]) { + ] + for (const viewport of viewports) { + evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport)) + } + for (const viewport of viewports) { evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport)) } evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir) - const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors) - const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests) + const auditedPages = [...evidence.landing_viewports, ...evidence.viewports] + const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors) + const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests) assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors') assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests') evidence.status = 'passed' diff --git a/frontend/src/WorkbenchApp.tsx b/frontend/src/WorkbenchApp.tsx index 7f953679..438ae313 100644 --- a/frontend/src/WorkbenchApp.tsx +++ b/frontend/src/WorkbenchApp.tsx @@ -84,8 +84,8 @@ const workspaceNavItems: WorkspaceNavigationItem[] = [ { key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' }, { key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' }, { key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' }, - { key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' }, - { key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' }, + { key: 'ai', label: 'Beeldanalyse', navigationLabel: 'AI-beeld', description: 'Gebouwen herkennen op luchtbeelden' }, + { key: 'exports', label: 'Downloads', navigationLabel: 'Export', description: 'Resultaten bewaren en delen' }, { key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' }, ] @@ -364,6 +364,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA detectionTileManifestPath, detectionConfidenceThreshold, runningDetection, + detectionJob, detectionRunResult, detectionRunError, detectionRuns, @@ -438,11 +439,14 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA segmentationTileManifestPath, segmentationConfidenceThreshold, runningSegmentation, + segmentationJob, segmentationRunResult, segmentationRunError, segmentationRuns, selectedSegmentationRunId, segmentationItems, + segmentationTotal, + segmentationTruncated, segmentationGeoJson, segmentationClassFilter, segmentationMinConfidenceFilter, @@ -986,7 +990,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA