from __future__ import annotations from pathlib import Path from types import SimpleNamespace from uuid import UUID, uuid4 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.schemas.demo import DemoWorkflowResponse from app.services.auth_service import AuthService from app.services.change_detection_service import ChangeDetectionService from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.demo_workflow_service import DemoWorkflowService from app.services.raster_operations_service import RasterOperationsService from app.services.segmentation_service import SegmentationService from app.services.job_service import JobService def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient: password_hash = AuthService.hash_password( "correct horse battery staple", salt=b"geointel-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", "test-session-secret-that-is-long-enough") monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true" if guest_access else "false") monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast") monkeypatch.setenv("GEOINTEL_GUEST_SESSION_TTL_SECONDS", "7200") return TestClient(create_app()) def test_guest_access_defaults_on_when_operator_authentication_is_enabled(monkeypatch) -> None: password_hash = AuthService.hash_password( "correct horse battery staple", salt=b"geointel-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", "test-session-secret-that-is-long-enough") monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False) client = TestClient(create_app()) session = client.get("/api/v1/auth/session") assert session.status_code == 200 assert session.json()["data"]["authentication_required"] is True assert session.json()["data"]["guest_access_enabled"] is True def test_guest_default_is_inactive_but_valid_when_operator_authentication_is_disabled(monkeypatch) -> None: monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "false") monkeypatch.delenv("GEOINTEL_AUTH_USERNAME", raising=False) monkeypatch.delenv("GEOINTEL_AUTH_PASSWORD_HASH", raising=False) monkeypatch.delenv("GEOINTEL_AUTH_SESSION_SECRET", raising=False) monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False) client = TestClient(create_app()) session = client.get("/api/v1/auth/session") assert session.status_code == 200 assert session.json()["data"]["authentication_required"] is False assert session.json()["data"]["authenticated"] is True assert session.json()["data"]["guest_access_enabled"] is False def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None: client = auth_client(monkeypatch) session = client.get("/api/v1/auth/session") protected = client.get("/api/v1/protected-probe") health = client.get("/health/live") assert session.status_code == 200 assert session.json()["data"] == { "authentication_required": True, "authenticated": False, "username": None, "expires_at": None, "role": None, "guest_access_enabled": False, "authentik_enabled": False, "guest_project_id": None, } assert protected.status_code == 401 assert protected.json()["error"] == "AUTHENTICATION_REQUIRED" assert health.status_code == 200 def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(monkeypatch) -> None: client = auth_client(monkeypatch, guest_access=True) invalid = client.post( "/api/v1/auth/login", json={"username": "operator", "password": "wrong"}, ) login = client.post( "/api/v1/auth/login", json={"username": "operator", "password": "correct horse battery staple"}, ) authenticated = client.get("/api/v1/auth/session") protected_after_login = client.get("/api/v1/protected-probe") logout = client.post("/api/v1/auth/logout") protected_after_logout = client.get("/api/v1/protected-probe") assert invalid.status_code == 401 assert invalid.json()["error"] == "INVALID_CREDENTIALS" assert login.status_code == 200 assert login.json()["data"] == { "authentication_required": True, "authenticated": True, "username": "operator", "expires_at": login.json()["data"]["expires_at"], "role": "operator", "guest_access_enabled": True, "authentik_enabled": False, "guest_project_id": None, } cookie = login.headers["set-cookie"].lower() assert "httponly" in cookie assert "samesite=strict" in cookie assert authenticated.json()["data"]["authenticated"] is True assert authenticated.json()["data"]["role"] == "operator" assert protected_after_login.status_code == 404 assert logout.status_code == 200 assert logout.json()["data"]["guest_access_enabled"] is True assert protected_after_logout.status_code == 401 def test_guest_login_exposes_models_but_rejects_management_and_cross_project_requests(monkeypatch) -> None: project_id = UUID("00000000-0000-0000-0000-000000000123") demo = DemoWorkflowResponse( project_id=project_id, area_id=UUID("00000000-0000-0000-0000-000000000124"), reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"), candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"), raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"), quality_check_id=UUID("00000000-0000-0000-0000-000000000128"), metric_count=6, status="ok", message="Demo ready", created=False, ) monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo)) client = auth_client(monkeypatch, guest_access=True) def fake_db(): yield object() client.app.dependency_overrides[get_db] = fake_db guest_login = client.post("/api/v1/auth/guest") guest_session = client.get("/api/v1/auth/session") mutation = client.post("/api/v1/projects", json={"name": "Not allowed"}) other_project = client.get("/api/v1/projects/00000000-0000-0000-0000-000000000999") detection_models = client.get("/api/v1/detection/models") segmentation_models = client.get("/api/v1/segmentation/models") global_source_registry = client.get("/api/v1/source-registry/grb") cross_project_runs = client.get( "/api/v1/detection/runs?project_id=00000000-0000-0000-0000-000000000999" ) cross_project_coverage = client.post( "/api/v1/external/coverage/resolve", json={ "project_id": "00000000-0000-0000-0000-000000000999", "bbox": {"minx": 4.9, "miny": 51.0, "maxx": 5.0, "maxy": 51.1}, "themes": [], }, ) bounded_acquisition = client.post( f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire", json={}, ) cross_project_acquisition = client.post( "/api/v1/projects/00000000-0000-0000-0000-000000000999/datasets/orthophoto/acquire", json={}, ) bounded_derived_selection = client.post( f"/api/v1/projects/{project_id}/datasets/{demo.candidate_dataset_id}/vector/select/derive", json={}, ) assert guest_login.status_code == 200 assert guest_login.json()["data"]["role"] == "guest" assert guest_login.json()["data"]["username"] == "Gast" assert guest_login.json()["data"]["guest_project_id"] == str(project_id) assert "httponly" in guest_login.headers["set-cookie"].lower() assert guest_session.json()["data"]["role"] == "guest" assert mutation.status_code == 403 assert mutation.json()["error"] == "GUEST_READ_ONLY" assert other_project.status_code == 403 assert other_project.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert detection_models.status_code == 200 assert detection_models.json()["data"]["models"] assert segmentation_models.status_code == 200 assert global_source_registry.status_code == 403 assert global_source_registry.json()["error"] == "GUEST_ROUTE_NOT_AVAILABLE" assert cross_project_runs.status_code == 403 assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert cross_project_coverage.status_code == 403 assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert bounded_acquisition.status_code == 422 assert bounded_acquisition.json()["error"] != "GUEST_READ_ONLY" assert cross_project_acquisition.status_code == 403 assert cross_project_acquisition.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert bounded_derived_selection.status_code == 422 assert bounded_derived_selection.json()["error"] != "GUEST_READ_ONLY" def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None: project_id = UUID("00000000-0000-0000-0000-000000000123") other_project_id = UUID("00000000-0000-0000-0000-000000000999") source_dataset_id = UUID("00000000-0000-0000-0000-000000000125") target_dataset_id = UUID("00000000-0000-0000-0000-000000000126") cross_project_dataset_id = UUID("00000000-0000-0000-0000-000000000998") demo = DemoWorkflowResponse( project_id=project_id, area_id=UUID("00000000-0000-0000-0000-000000000124"), reference_dataset_id=source_dataset_id, candidate_dataset_id=target_dataset_id, raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"), quality_check_id=UUID("00000000-0000-0000-0000-000000000128"), metric_count=6, status="ok", message="Demo ready", created=False, ) monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo)) class FakeDb: def get(self, _model, dataset_id): bound_project_id = other_project_id if dataset_id == cross_project_dataset_id else project_id return SimpleNamespace(id=dataset_id, project_id=bound_project_id, dataset_type="vector") validated_datasets: list[tuple[UUID, UUID, str]] = [] def validate_dataset(_db, dataset_id, requested_project_id, label): validated_datasets.append((dataset_id, requested_project_id, label)) return SimpleNamespace(id=dataset_id, project_id=requested_project_id, dataset_type="vector") monkeypatch.setattr( ChangeDetectionService, "_get_project_vector_dataset", staticmethod(validate_dataset), ) monkeypatch.setattr( JobService, "run_sync_job", staticmethod( lambda **kwargs: SimpleNamespace( id=uuid4(), job_type=kwargs["job_type"], status="success", project_id=kwargs["project_id"], dataset_id=source_dataset_id, input_dataset_id=source_dataset_id, output_dataset_id=None, parameters_json=kwargs["parameters"], result_json={}, error_message=None, created_at=None, started_at=None, finished_at=None, ) ), ) client = auth_client(monkeypatch, guest_access=True) def fake_db(): yield FakeDb() client.app.dependency_overrides[get_db] = fake_db assert client.post("/api/v1/auth/guest").status_code == 200 accepted = client.post( "/api/v1/analysis/change-detection", json={ "source_dataset_id": str(source_dataset_id), "target_dataset_id": str(target_dataset_id), }, ) rejected = client.post( "/api/v1/analysis/change-detection", json={ "source_dataset_id": str(cross_project_dataset_id), "target_dataset_id": str(target_dataset_id), }, ) assert accepted.status_code == 200 assert accepted.json()["data"]["project_id"] == str(project_id) assert validated_datasets == [ (source_dataset_id, project_id, "Source"), (target_dataset_id, project_id, "Target"), ] assert rejected.status_code == 403 assert rejected.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None: project_id = UUID("00000000-0000-0000-0000-000000000123") raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127") manifest_path = "/app/storage/tiles/demo/manifest.json" demo = DemoWorkflowResponse( project_id=project_id, area_id=UUID("00000000-0000-0000-0000-000000000124"), reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"), candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"), raster_dataset_id=raster_dataset_id, quality_check_id=UUID("00000000-0000-0000-0000-000000000128"), metric_count=6, status="ok", message="Demo ready", created=False, ) monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo)) monkeypatch.setattr( DatasetService, "get_dataset", staticmethod(lambda _db, _dataset_id: SimpleNamespace(project_id=project_id)), ) def job(*, job_type: str, result_json: dict | None = None): return SimpleNamespace( id=uuid4(), job_type=job_type, status="success" if result_json else "queued", project_id=project_id, dataset_id=raster_dataset_id, input_dataset_id=raster_dataset_id, output_dataset_id=None, parameters_json={}, result_json=result_json, error_message=None, created_at=None, started_at=None, finished_at=None, ) tile_parameters: dict = {} def tile(_db, _dataset_id, **kwargs): tile_parameters.update(kwargs) return {"manifest_path": manifest_path} monkeypatch.setattr(RasterOperationsService, "tile", staticmethod(tile)) monkeypatch.setattr( "app.api.routes.datasets._run_job_sync", lambda **kwargs: job(job_type="raster.tile", result_json=kwargs["operation"]()), ) queued_parameters: dict = {} def enqueue_detection(**kwargs): queued_parameters.update(kwargs) return job(job_type="detection.run") monkeypatch.setattr(DetectionService, "enqueue_detection", staticmethod(enqueue_detection)) queued_segmentation_parameters: dict = {} def enqueue_segmentation(**kwargs): queued_segmentation_parameters.update(kwargs) return job(job_type="segmentation.run") monkeypatch.setattr(SegmentationService, "enqueue_segmentation", staticmethod(enqueue_segmentation)) client = auth_client(monkeypatch, guest_access=True) def fake_db(): yield object() client.app.dependency_overrides[get_db] = fake_db assert client.post("/api/v1/auth/guest").status_code == 200 tile_response = client.post( f"/api/v1/projects/{project_id}/datasets/{raster_dataset_id}/raster/tile", json={"tile_size": 512, "overlap": 64}, ) detection_response = client.post( f"/api/v1/detection/run-async?project_id={project_id}", json={ "project_id": str(project_id), "dataset_id": str(raster_dataset_id), "model_id": "yolo-configured", "model_asset_id": "active-model", "confidence_threshold": 0.15, "tile_manifest_path": manifest_path, "parameters_json": {}, }, ) segmentation_response = client.post( f"/api/v1/segmentation/run-async?project_id={project_id}", json={ "project_id": str(project_id), "dataset_id": str(raster_dataset_id), "model_id": "sam-configured", "confidence_threshold": 0.5, "tile_manifest_path": manifest_path, "parameters_json": {}, }, ) assert tile_response.status_code == 201 assert tile_response.json()["data"]["result_json"]["manifest_path"] == manifest_path assert detection_response.status_code == 200 assert detection_response.json()["data"]["status"] == "queued" assert segmentation_response.status_code == 200 assert segmentation_response.json()["data"]["status"] == "queued" assert queued_parameters["project_id"] == project_id assert queued_parameters["dataset_id"] == raster_dataset_id assert queued_parameters["tile_manifest_path"] == manifest_path assert queued_segmentation_parameters["project_id"] == project_id assert queued_segmentation_parameters["dataset_id"] == raster_dataset_id assert queued_segmentation_parameters["tile_manifest_path"] == manifest_path assert tile_parameters["max_tiles"] == get_settings().yolo_max_tiles def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None: auth_client(monkeypatch, guest_access=True) settings = get_settings() try: AuthService.create_session_token("Gast", settings, role="guest") except ValueError as error: assert "demo project" in str(error) else: # pragma: no cover - defensive assertion raise AssertionError("An unscoped guest token should not be created") def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None: client = auth_client(monkeypatch) login = client.post( "/api/v1/auth/login", json={"username": "operator", "password": "correct horse battery staple"}, ) token = login.cookies.get("geointel_session") assert token client.cookies.set("geointel_session", f"{token}tampered") session = client.get("/api/v1/auth/session") assert session.status_code == 200 assert session.json()["data"]["authenticated"] is False def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None: root = Path(__file__).resolve().parents[2] runner = (root / "deploy/unraid/run-dockerman-container.sh").read_text(encoding="utf-8") example = (root / "deploy/unraid/geointel.env.example").read_text(encoding="utf-8") browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8") assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner assert '-e GEOINTEL_AUTHENTIK_CLIENT_SECRET="$GEOINTEL_AUTHENTIK_CLIENT_SECRET"' in runner assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example assert "GEOINTEL_AUTHENTIK_CLIENT_SECRET=" in example assert "GEOINTEL_AUTH_PASSWORD=" not in runner assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner assert "/api/v1/auth/session" in browser_smoke