"""Mock Suite Satellite Service — FastAPI ingest stub for blackbox tests. Endpoints: POST /tiles — main ingest. Returns 202 on well-formed tile, 400 on malformed; appends to the run audit log. GET /tiles/audit — read-back of the per-run audit log (JSONL). POST /mock/config — test-time behaviour control (force 5xx, simulate downtime). GET /mock/audit — alias of /tiles/audit with optional ?run_id filter. POST /mock/reset — clears the audit log between tests for isolation. GET /mock/health — Docker healthcheck. The accepted ingest schema is the contract sketch from `_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md`. NFT-SEC-01 asserts the schema's accepted-fields match that sketch. """ from __future__ import annotations import os import time import uuid from pathlib import Path from typing import Annotated, Literal import orjson from fastapi import FastAPI, HTTPException, Query from fastapi.responses import ORJSONResponse, PlainTextResponse from pydantic import BaseModel, Field, ValidationError AUDIT_ROOT = Path(os.environ.get("MOCK_SUITE_SAT_AUDIT_PATH", "/audit")) AUDIT_ROOT.mkdir(parents=True, exist_ok=True) app = FastAPI( title="mock-suite-sat-service", version="0.1.0", description="Deterministic stub of the parent Suite Satellite Service.", default_response_class=ORJSONResponse, ) # --------------------------------------------------------------------------- # Behaviour control (test-only) # --------------------------------------------------------------------------- class _MockConfig(BaseModel): force_status: int | None = Field(default=None, description="Force this status on every ingest.") simulated_latency_ms: int = 0 _config = _MockConfig() # --------------------------------------------------------------------------- # Ingest schema (mirror of the contract sketch — keep them in sync) # --------------------------------------------------------------------------- class TileQualityMetadata(BaseModel): capture_utc: str source_provider: Literal["maxar", "planet", "sentinel-2", "skywatch", "operator-supplied"] resolution_m_per_px: float = Field(gt=0, le=10.0) cloud_coverage_pct: float = Field(ge=0, le=100) geo_accuracy_m: float = Field(ge=0) class TilePublishRequest(BaseModel): tile_id: str = Field(min_length=8, max_length=128) bbox_wgs84: tuple[float, float, float, float] zoom_level: int = Field(ge=10, le=22) descriptor_sha256: str = Field(min_length=64, max_length=64) payload_size_bytes: int = Field(gt=0) quality: TileQualityMetadata # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run_audit_path(run_id: str) -> Path: safe = "".join(c for c in run_id if c.isalnum() or c in "-_") or "default" return AUDIT_ROOT / f"{safe}.jsonl" def _append_audit(run_id: str, entry: dict[str, object]) -> None: entry = {**entry, "received_at_unix": time.time(), "entry_id": str(uuid.uuid4())} path = _run_audit_path(run_id) with path.open("ab") as fh: fh.write(orjson.dumps(entry)) fh.write(b"\n") # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get("/mock/health") def health() -> dict[str, str]: return {"status": "ok"} @app.post("/tiles", status_code=202) def publish_tile( request: TilePublishRequest, run_id: Annotated[str, Query(alias="run_id")] = "default", ) -> dict[str, object]: if _config.simulated_latency_ms > 0: time.sleep(_config.simulated_latency_ms / 1000.0) if _config.force_status is not None and _config.force_status >= 400: raise HTTPException( status_code=_config.force_status, detail=f"forced status by /mock/config (current force_status={_config.force_status})", ) _append_audit( run_id, { "tile_id": request.tile_id, "bbox_wgs84": list(request.bbox_wgs84), "zoom_level": request.zoom_level, "descriptor_sha256": request.descriptor_sha256, "payload_size_bytes": request.payload_size_bytes, "quality": request.quality.model_dump(), }, ) return {"accepted": True, "tile_id": request.tile_id, "run_id": run_id} @app.exception_handler(ValidationError) def on_validation_error(_request, exc: ValidationError) -> ORJSONResponse: # type: ignore[no-untyped-def] return ORJSONResponse(status_code=400, content={"detail": exc.errors()}) @app.get("/tiles/audit") @app.get("/mock/audit") def get_audit(run_id: Annotated[str, Query(alias="run_id")] = "default") -> ORJSONResponse: path = _run_audit_path(run_id) if not path.exists(): return ORJSONResponse(content={"run_id": run_id, "entries": []}) entries = [] with path.open("rb") as fh: for line in fh: line = line.strip() if not line: continue entries.append(orjson.loads(line)) return ORJSONResponse(content={"run_id": run_id, "entries": entries}) @app.post("/mock/config") def update_config(config: _MockConfig) -> _MockConfig: global _config _config = config return _config @app.post("/mock/reset") def reset(run_id: Annotated[str, Query(alias="run_id")] = "default") -> PlainTextResponse: path = _run_audit_path(run_id) if path.exists(): path.unlink() return PlainTextResponse("reset")