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