Files
geointel/backend/tests/test_guest_resource_scope.py
T
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

349 lines
12 KiB
Python

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.core.public_demo import PUBLIC_DEMO_PROJECT_ID
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 = PUBLIC_DEMO_PROJECT_ID
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