From 66f8d7a1fa4930abe937e9a919cba7060ce0afe2 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Jul 2026 16:45:11 +0300 Subject: [PATCH] feat(map): add guarded map gateway BFF --- pyproject.toml | 1 + src/k1link/web/app.py | 14 + src/k1link/web/map_api.py | 470 +++++++++++++++++++++++++++++++++ src/k1link/web/map_view_api.py | 179 +++++++++++++ tests/test_map_api.py | 315 ++++++++++++++++++++++ tests/test_map_view_api.py | 100 +++++++ uv.lock | 39 +++ 7 files changed, 1118 insertions(+) create mode 100644 src/k1link/web/map_api.py create mode 100644 src/k1link/web/map_view_api.py create mode 100644 tests/test_map_api.py create mode 100644 tests/test_map_view_api.py diff --git a/pyproject.toml b/pyproject.toml index 10d5e05..a1ff7ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "bleak==3.0.2", "fastapi>=0.116,<1", "foxglove-sdk==0.25.3", + "httpx>=0.28,<1", "lz4>=4.4,<5", "missioncore-plugin-sdk", "paho-mqtt>=2.1,<3", diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index a37aae1..2d927e3 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -42,6 +42,12 @@ from k1link.web.e30_review_api import build_e30_review_router from k1link.web.environment_api import build_environment_router from k1link.web.laboratory_api import build_laboratory_router from k1link.web.lidar_api import build_lidar_router +from k1link.web.map_api import ( + MapGatewayConfiguration, + MapGatewayProxy, + build_map_router, +) +from k1link.web.map_view_api import build_map_view_router from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError from k1link.web.plugin_runtime import ( STATE_READ_ACTION_ID, @@ -151,6 +157,7 @@ session_perception_epoch_store = ( if _ffprobe is not None else None ) +map_gateway_proxy = MapGatewayProxy(MapGatewayConfiguration.from_environment()) def _prepare_recorded_media_for_launch( @@ -291,6 +298,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]: reconciler = asyncio.create_task(_recording_preparation_reconciler()) yield finally: + await map_gateway_proxy.close() if reconciler is not None: reconciler.cancel() with suppress(asyncio.CancelledError): @@ -417,6 +425,12 @@ app.include_router( root_provider=lambda: session_store.data_dir / "ui-environment" ) ) +app.include_router(build_map_router(map_gateway_proxy)) +app.include_router( + build_map_view_router( + root_provider=lambda: session_store.data_dir / "map-view", + ) +) app.include_router( build_polygon_router( root_provider=lambda: configured_polygon_runs_root() diff --git a/src/k1link/web/map_api.py b/src/k1link/web/map_api.py new file mode 100644 index 0000000..15097f1 --- /dev/null +++ b/src/k1link/web/map_api.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import os +import re +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass +from ipaddress import ip_address +from typing import Annotated, Any, Final +from urllib.parse import parse_qsl, urlsplit + +import httpx +from fastapi import APIRouter, HTTPException, Path, Query, Request +from fastapi.responses import JSONResponse, Response, StreamingResponse + +MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL" +MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1" +MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1" +MAP_PAGE_VERSION: Final = "0.1.0" +CESIUM_RENDERER_VERSION: Final = "1.143.0" +MAP_GATEWAY_SUBJECT: Final = "mission-core-loopback-operator" +ALLOWED_ION_ASSET_IDS: Final = frozenset({1, 2, 96188}) +SAFE_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$") +MAX_JSON_RESPONSE_BYTES: Final = 1024 * 1024 +MAX_CACHE_TARGET_LENGTH: Final = 8192 + +REQUEST_HEADER_ALLOWLIST: Final = frozenset( + { + "accept", + "accept-encoding", + "if-modified-since", + "if-none-match", + "range", + } +) +RESPONSE_HEADER_ALLOWLIST: Final = frozenset( + { + "accept-ranges", + "cache-control", + "content-encoding", + "content-length", + "content-range", + "content-type", + "etag", + "expires", + "last-modified", + "vary", + } +) +SENSITIVE_JSON_KEYS: Final = frozenset( + { + "access_token", + "accesstoken", + "api_key", + "apikey", + "authorization", + "bing_key", + "bingkey", + "cookie", + "master_token", + "mastertoken", + "secret", + "set_cookie", + "setcookie", + "token", + } +) +CREDENTIAL_QUERY_KEYS: Final = frozenset( + { + "access_token", + "access-token", + "authorization", + "credential", + "key", + "sig", + "signature", + "token", + } +) + + +@dataclass(frozen=True, slots=True) +class MapGatewayConfiguration: + internal_url: str | None + + @classmethod + def from_environment(cls) -> MapGatewayConfiguration: + raw_url = os.environ.get(MAP_GATEWAY_URL_ENV, "").strip() + if not raw_url: + return cls(internal_url=None) + return cls(internal_url=_validate_internal_url(raw_url)) + + +class MapGatewayProxy: + """Narrow same-origin proxy to the shared Platform Map Gateway.""" + + def __init__( + self, + configuration: MapGatewayConfiguration, + *, + client: httpx.AsyncClient | None = None, + ) -> None: + self._configuration = configuration + self._owns_client = client is None + self._client = client or httpx.AsyncClient( + follow_redirects=False, + timeout=httpx.Timeout( + connect=15.0, + read=30.0, + write=15.0, + pool=15.0, + ), + limits=httpx.Limits( + max_connections=48, + max_keepalive_connections=16, + keepalive_expiry=30.0, + ), + ) + + @property + def configured(self) -> bool: + return self._configuration.internal_url is not None + + def runtime_configuration(self) -> Response: + if not self.configured: + return _safe_error_response( + 503, + "map_gateway_not_configured", + retryable=False, + ) + return JSONResponse( + content={ + "schema_version": MAP_RUNTIME_SCHEMA_VERSION, + "map_page_version": MAP_PAGE_VERSION, + "renderer": { + "id": "cesium", + "version": CESIUM_RENDERER_VERSION, + }, + "gateway": { + "configured": True, + "health_url": "/api/v1/map/gateway/health", + "asset_endpoint_template": ( + "/api/v1/map/gateway/ion/assets/{asset_id}/endpoint" + ), + "cache_proxy_prefix": "/api/v1/map/gateway/cache?url=", + }, + "assets": { + "imagery": 2, + "terrain": 1, + "buildings": 96188, + }, + }, + headers={"Cache-Control": "no-store"}, + ) + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def proxy_json( + self, + request: Request, + *, + upstream_path: str, + ) -> Response: + upstream = await self._send(request, upstream_path=upstream_path) + try: + payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES) + except MapGatewayPayloadTooLarge: + return _safe_error_response( + 502, + "map_gateway_invalid_response", + retryable=False, + ) + finally: + await upstream.aclose() + + if upstream.status_code >= 400: + return _safe_upstream_error(upstream.status_code, payload) + + try: + document = _decode_safe_json(upstream, payload) + except ValueError: + return _safe_error_response( + 502, + "map_gateway_invalid_response", + retryable=False, + ) + return JSONResponse( + content=document, + status_code=upstream.status_code, + headers={"Cache-Control": "no-store"}, + ) + + async def proxy_cache( + self, + request: Request, + *, + target_url: str, + ) -> Response: + _validate_cache_target(target_url) + upstream = await self._send( + request, + upstream_path="/api/map/cache", + query={"url": target_url}, + ) + if upstream.status_code >= 400: + try: + payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES) + except MapGatewayPayloadTooLarge: + payload = b"" + finally: + await upstream.aclose() + return _safe_upstream_error(upstream.status_code, payload) + + response_headers = _filtered_response_headers(upstream.headers) + if request.method == "HEAD" or upstream.status_code in {204, 304}: + await upstream.aclose() + return Response( + status_code=upstream.status_code, + headers=response_headers, + ) + + async def stream_body() -> AsyncIterator[bytes]: + try: + async for chunk in upstream.aiter_raw(): + if chunk: + yield chunk + finally: + await upstream.aclose() + + return StreamingResponse( + stream_body(), + status_code=upstream.status_code, + headers=response_headers, + ) + + async def _send( + self, + request: Request, + *, + upstream_path: str, + query: Mapping[str, str] | None = None, + ) -> httpx.Response: + _require_loopback_request(request) + base_url = self._configuration.internal_url + if base_url is None: + raise HTTPException( + status_code=503, + detail={ + "schema_version": MAP_SCHEMA_VERSION, + "code": "map_gateway_not_configured", + "retryable": False, + }, + ) + + headers = { + name: value + for name, value in request.headers.items() + if name.lower() in REQUEST_HEADER_ALLOWLIST + } + headers["x-nodedc-user-id"] = MAP_GATEWAY_SUBJECT + upstream_request = self._client.build_request( + request.method, + f"{base_url}{upstream_path}", + params=query, + headers=headers, + ) + try: + return await self._client.send(upstream_request, stream=True) + except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ReadTimeout) as exc: + raise HTTPException( + status_code=504, + detail={ + "schema_version": MAP_SCHEMA_VERSION, + "code": "map_gateway_headers_timeout", + "retryable": True, + }, + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status_code=502, + detail={ + "schema_version": MAP_SCHEMA_VERSION, + "code": "map_gateway_unavailable", + "retryable": True, + }, + ) from exc + + +class MapGatewayPayloadTooLarge(ValueError): + pass + + +def _validate_internal_url(raw_url: str) -> str: + parsed = urlsplit(raw_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError(f"{MAP_GATEWAY_URL_ENV} must be an HTTP(S) origin") + path = parsed.path.rstrip("/") + if path: + raise ValueError(f"{MAP_GATEWAY_URL_ENV} must not include a path") + return raw_url.rstrip("/") + + +def _validate_cache_target(target_url: str) -> None: + if len(target_url) > MAX_CACHE_TARGET_LENGTH: + raise HTTPException(status_code=422, detail="Map resource URL is too long.") + parsed = urlsplit(target_url) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise HTTPException( + status_code=422, + detail="Map resource URL must be a credential-free HTTPS URL.", + ) + if any( + key.lower() in CREDENTIAL_QUERY_KEYS + for key, _ in parse_qsl(parsed.query, keep_blank_values=True) + ): + raise HTTPException( + status_code=422, + detail="Map resource URL must not contain credential parameters.", + ) + + +def _require_loopback_request(request: Request) -> None: + client = request.client + if client is None: + raise HTTPException(status_code=403, detail="Map access requires a local session.") + try: + is_loopback = ip_address(client.host).is_loopback + except ValueError: + is_loopback = client.host == "localhost" + if not is_loopback: + raise HTTPException(status_code=403, detail="Map access requires a local session.") + + +async def _read_bounded(response: httpx.Response, limit: int) -> bytes: + payload = bytearray() + async for chunk in response.aiter_raw(): + payload.extend(chunk) + if len(payload) > limit: + raise MapGatewayPayloadTooLarge + return bytes(payload) + + +def _decode_safe_json(response: httpx.Response, payload: bytes) -> dict[str, Any]: + content_type = response.headers.get("content-type", "") + if "application/json" not in content_type.lower(): + raise ValueError("Map Gateway response is not JSON") + document = httpx.Response( + status_code=response.status_code, + content=payload, + headers={"content-type": content_type}, + ).json() + if not isinstance(document, dict) or _contains_sensitive_json(document): + raise ValueError("Map Gateway response violates the public contract") + return document + + +def _contains_sensitive_json(value: Any) -> bool: + if isinstance(value, dict): + for raw_key, child in value.items(): + key = str(raw_key).replace("-", "_").lower() + if key in SENSITIVE_JSON_KEYS or _contains_sensitive_json(child): + return True + return False + if isinstance(value, list): + return any(_contains_sensitive_json(child) for child in value) + return False + + +def _safe_upstream_error(status_code: int, payload: bytes) -> JSONResponse: + code = "map_gateway_request_failed" + try: + parsed = httpx.Response( + status_code=status_code, + content=payload, + headers={"content-type": "application/json"}, + ).json() + except ValueError: + parsed = None + if isinstance(parsed, dict): + candidate = parsed.get("code") + if not isinstance(candidate, str): + error = parsed.get("error") + if isinstance(error, dict): + candidate = error.get("code") + if isinstance(candidate, str) and SAFE_ERROR_CODE.fullmatch(candidate): + code = candidate + return _safe_error_response( + status_code, + code, + retryable=status_code >= 500 or status_code == 429, + ) + + +def _safe_error_response( + status_code: int, + code: str, + *, + retryable: bool, +) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + "schema_version": MAP_SCHEMA_VERSION, + "code": code, + "retryable": retryable, + }, + headers={"Cache-Control": "no-store"}, + ) + + +def _filtered_response_headers(headers: httpx.Headers) -> dict[str, str]: + return { + name: value + for name, value in headers.items() + if name.lower() in RESPONSE_HEADER_ALLOWLIST + } + + +def build_map_router(service: MapGatewayProxy) -> APIRouter: + router = APIRouter(prefix="/api/v1/map", tags=["map"]) + + @router.get("/runtime-config") + async def runtime_config(request: Request) -> Response: + _require_loopback_request(request) + return service.runtime_configuration() + + @router.get("/gateway/health") + async def gateway_health(request: Request) -> Response: + return await service.proxy_json(request, upstream_path="/healthz") + + @router.get("/gateway/ion/assets/{asset_id}/endpoint") + async def ion_asset_endpoint( + request: Request, + asset_id: Annotated[int, Path()], + ) -> Response: + if asset_id not in ALLOWED_ION_ASSET_IDS: + return _safe_error_response( + 404, + "cesium_asset_not_allowed", + retryable=False, + ) + return await service.proxy_json( + request, + upstream_path=f"/api/map/ion/assets/{asset_id}/endpoint", + ) + + @router.api_route("/gateway/cache", methods=["GET", "HEAD"]) + async def map_cache( + request: Request, + url: Annotated[str, Query(min_length=1, max_length=MAX_CACHE_TARGET_LENGTH)], + ) -> Response: + if len(request.query_params.getlist("url")) != 1: + raise HTTPException(status_code=422, detail="One map resource URL is required.") + return await service.proxy_cache(request, target_url=url) + + return router diff --git a/src/k1link/web/map_view_api.py b/src/k1link/web/map_view_api.py new file mode 100644 index 0000000..c553bef --- /dev/null +++ b/src/k1link/web/map_view_api.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import json +import os +from collections.abc import Callable +from pathlib import Path +from threading import Lock +from typing import Literal + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field, model_validator + +MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v1"] = "missioncore.map-view/v1" +MapInspectorSection = Literal[ + "base-terrain", + "atmosphere-light", + "buildings", + "targets", + "grid-lod", + "camera", + "tile-cache", + "selection", +] + + +class StrictMapModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class MapCamera(StrictMapModel): + longitude: float = Field(ge=-180.0, le=180.0) + latitude: float = Field(ge=-90.0, le=90.0) + height: float = Field(ge=1.0, le=100_000_000.0) + heading: float = Field(ge=-360.0, le=360.0) + pitch: float = Field(ge=-90.0, le=90.0) + roll: float = Field(ge=-360.0, le=360.0) + + +class MapVisualSettings(StrictMapModel): + atmosphere_enabled: bool = True + lighting_enabled: bool = True + monochrome_enabled: bool = False + terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0) + buildings_maximum_screen_space_error: float = Field( + default=16.0, + ge=1.0, + le=64.0, + ) + camera_animation_enabled: bool = True + + +class MapLayerVisibility(StrictMapModel): + imagery: bool = True + terrain: bool = True + buildings: bool = True + grid: bool = False + targets: bool = True + + +class MapCacheIntent(StrictMapModel): + enabled: bool = True + no_overwrite: bool = True + + +class MapViewContent(StrictMapModel): + camera: MapCamera | None = None + visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings) + map_height: int = Field(default=720, ge=420, le=2160) + inspector_open_sections: list[MapInspectorSection] = Field( + default_factory=list, + max_length=1, + ) + cache_intent: MapCacheIntent = Field(default_factory=MapCacheIntent) + selected_subject_id: str | None = Field( + default=None, + max_length=160, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$", + ) + layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility) + + @model_validator(mode="after") + def validate_selection_section(self) -> MapViewContent: + if self.selected_subject_id is None and "selection" in self.inspector_open_sections: + raise ValueError("selection section requires a selected stable subject") + return self + + +class MapViewPut(StrictMapModel): + revision: int = Field(ge=0) + view: MapViewContent + + +class MapViewDocument(MapViewPut): + schema_version: Literal["missioncore.map-view/v1"] = MAP_VIEW_SCHEMA_VERSION + + +def default_map_view() -> MapViewDocument: + return MapViewDocument( + revision=0, + view=MapViewContent(), + ) + + +class MapViewStore: + def __init__(self, root: Path) -> None: + self.root = root.expanduser().resolve() + self.document_path = self.root / "view.json" + self._lock = Lock() + + def read(self) -> MapViewDocument: + with self._lock: + return self._read_unlocked() + + def save(self, request: MapViewPut) -> MapViewDocument: + with self._lock: + current = self._read_unlocked() + if request.revision != current.revision: + raise RuntimeError("map view revision changed") + document = MapViewDocument( + revision=current.revision + 1, + view=request.view, + ) + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = self.document_path.with_suffix(".json.tmp") + temporary.write_text( + json.dumps( + document.model_dump(mode="json"), + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + os.replace(temporary, self.document_path) + return document + + def _read_unlocked(self) -> MapViewDocument: + if not self.document_path.is_file(): + return default_map_view() + try: + return MapViewDocument.model_validate_json( + self.document_path.read_text(encoding="utf-8") + ) + except (OSError, ValueError) as exc: + raise RuntimeError("map view is corrupt") from exc + + +def build_map_view_router(root_provider: Callable[[], Path]) -> APIRouter: + router = APIRouter(prefix="/api/v1/map/view", tags=["map"]) + + def store() -> MapViewStore: + return MapViewStore(root_provider()) + + @router.get("") + def get_map_view() -> MapViewDocument: + try: + return store().read() + except RuntimeError as exc: + raise HTTPException( + status_code=500, + detail="Сохранённое состояние карты повреждено.", + ) from exc + + @router.put("") + def put_map_view(request: MapViewPut) -> MapViewDocument: + try: + return store().save(request) + except RuntimeError as exc: + if "revision changed" in str(exc): + raise HTTPException( + status_code=412, + detail="Состояние карты было изменено в другом окне.", + ) from exc + raise HTTPException( + status_code=500, + detail="Сохранённое состояние карты повреждено.", + ) from exc + + return router diff --git a/tests/test_map_api.py b/tests/test_map_api.py new file mode 100644 index 0000000..4f1d649 --- /dev/null +++ b/tests/test_map_api.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Callable +from typing import Any + +import httpx +import pytest +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.routing import APIRoute + +from k1link.web.map_api import ( + MAP_GATEWAY_URL_ENV, + MapGatewayConfiguration, + MapGatewayProxy, + build_map_router, +) + + +class _AsyncContent(httpx.AsyncByteStream): + def __init__(self, content: bytes) -> None: + self._content = content + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._content + + +def _request( + path: str, + *, + method: str = "GET", + query: str = "", + headers: list[tuple[bytes, bytes]] | None = None, + client: tuple[str, int] = ("127.0.0.1", 8000), +) -> Request: + async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + return Request( + { + "type": "http", + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": query.encode("ascii"), + "headers": headers or [], + "client": client, + "server": ("127.0.0.1", 8000), + }, + receive, + ) + + +def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]: + for route in router.routes: + if ( + isinstance(route, APIRoute) + and route.path == path + and route.methods is not None + and method in route.methods + ): + return route.endpoint + raise AssertionError(f"{method} {path} route is missing") + + +def _response_body(response: JSONResponse) -> bytes: + return bytes(response.body) + + +def _service( + handler: Callable[[httpx.Request], httpx.Response], +) -> tuple[MapGatewayProxy, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def recording_handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + response = handler(request) + if response.is_stream_consumed: + return httpx.Response( + response.status_code, + headers=response.headers, + stream=_AsyncContent(response.content), + ) + return response + + client = httpx.AsyncClient(transport=httpx.MockTransport(recording_handler)) + return ( + MapGatewayProxy( + MapGatewayConfiguration("http://map-gateway.internal:18103"), + client=client, + ), + requests, + ) + + +def test_configuration_is_fail_closed_and_rejects_non_origin_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(MAP_GATEWAY_URL_ENV, raising=False) + assert MapGatewayConfiguration.from_environment().internal_url is None + + monkeypatch.setenv(MAP_GATEWAY_URL_ENV, "http://user:pass@gateway/path?token=no") + with pytest.raises(ValueError, match="HTTP\\(S\\) origin"): + MapGatewayConfiguration.from_environment() + + +def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin() -> None: + service, requests = _service( + lambda request: httpx.Response( + 200, + headers={"content-type": "application/json"}, + json={"ok": True, "rendererVersion": "1.143.0"}, + ) + ) + router = build_map_router(service) + endpoint = _endpoint(router, "/api/v1/map/runtime-config", "GET") + + response = asyncio.run(endpoint(request=_request("/api/v1/map/runtime-config"))) + + assert isinstance(response, JSONResponse) + assert response.status_code == 200 + document = json.loads(_response_body(response)) + assert document["schema_version"] == "missioncore.map-runtime/v1" + assert document["map_page_version"] == "0.1.0" + assert document["renderer"] == {"id": "cesium", "version": "1.143.0"} + assert document["gateway"]["cache_proxy_prefix"].startswith("/api/v1/map/") + assert document["assets"] == { + "imagery": 2, + "terrain": 1, + "buildings": 96188, + } + assert response.headers["cache-control"] == "no-store" + assert not requests + assert "map-gateway.internal" not in _response_body(response).decode() + + +def test_runtime_config_is_fail_closed_when_gateway_is_not_configured() -> None: + service = MapGatewayProxy(MapGatewayConfiguration(None)) + response = service.runtime_configuration() + + assert isinstance(response, JSONResponse) + assert response.status_code == 503 + assert json.loads(_response_body(response))["code"] == "map_gateway_not_configured" + + +def test_public_json_contract_rejects_secret_material() -> None: + service, _ = _service( + lambda request: httpx.Response( + 200, + headers={"content-type": "application/json"}, + json={"ok": True, "accessToken": "must-not-cross-bff"}, + ) + ) + + response = asyncio.run( + service.proxy_json( + _request("/api/v1/map/runtime-config"), + upstream_path="/api/map/runtime-config", + ) + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 502 + assert json.loads(_response_body(response))["code"] == "map_gateway_invalid_response" + assert b"must-not-cross-bff" not in _response_body(response) + + +def test_cache_stream_forwards_only_allowlisted_headers_and_preserves_range() -> None: + service, requests = _service( + lambda request: httpx.Response( + 206, + headers={ + "content-type": "application/octet-stream", + "content-range": "bytes 0-3/10", + "etag": '"tile-v1"', + "set-cookie": "forbidden=1", + "x-internal-secret": "forbidden", + }, + content=b"tile", + ) + ) + response = asyncio.run( + service.proxy_cache( + _request( + "/api/v1/map/gateway/cache", + headers=[ + (b"range", b"bytes=0-3"), + (b"authorization", b"Bearer browser-secret"), + (b"x-nodedc-user-id", b"browser-forgery"), + ], + ), + target_url="https://assets.cesium.com/tile.bin?nodedc_cache_profile=live", + ) + ) + + assert isinstance(response, StreamingResponse) + + async def read_body() -> bytes: + chunks: list[bytes] = [] + async for chunk in response.body_iterator: + chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk)) + return b"".join(chunks) + + assert asyncio.run(read_body()) == b"tile" + assert response.headers["content-range"] == "bytes 0-3/10" + assert response.headers["etag"] == '"tile-v1"' + assert "set-cookie" not in response.headers + assert "x-internal-secret" not in response.headers + assert requests[0].headers["range"] == "bytes=0-3" + assert "authorization" not in requests[0].headers + assert requests[0].headers["x-nodedc-user-id"] == "mission-core-loopback-operator" + assert requests[0].url.params["url"].startswith("https://assets.cesium.com/") + + +def test_cache_rejects_credentials_non_https_and_non_loopback_clients() -> None: + service, requests = _service( + lambda request: httpx.Response(200, content=b"unused") + ) + unsafe_targets = ( + "http://assets.cesium.com/tile.bin", + "https://user:pass@assets.cesium.com/tile.bin", + "https://assets.cesium.com/tile.bin#secret", + "https://assets.cesium.com/tile.bin?access_token=forbidden", + ) + for target in unsafe_targets: + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + service.proxy_cache( + _request("/api/v1/map/gateway/cache"), + target_url=target, + ) + ) + assert exc_info.value.status_code == 422 + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + service.proxy_cache( + _request( + "/api/v1/map/gateway/cache", + client=("192.0.2.10", 1234), + ), + target_url="https://assets.cesium.com/tile.bin", + ) + ) + assert exc_info.value.status_code == 403 + assert not requests + + +def test_asset_route_is_a_fixed_allowlist() -> None: + service, requests = _service( + lambda request: httpx.Response( + 200, + headers={"content-type": "application/json"}, + json={"ok": True, "assetId": 1, "credentialMode": "gateway"}, + ) + ) + router = build_map_router(service) + endpoint = _endpoint( + router, + "/api/v1/map/gateway/ion/assets/{asset_id}/endpoint", + "GET", + ) + + rejected = asyncio.run( + endpoint( + request=_request("/api/v1/map/gateway/ion/assets/42/endpoint"), + asset_id=42, + ) + ) + accepted = asyncio.run( + endpoint( + request=_request("/api/v1/map/gateway/ion/assets/1/endpoint"), + asset_id=1, + ) + ) + + assert isinstance(rejected, JSONResponse) + assert rejected.status_code == 404 + assert json.loads(_response_body(rejected))["code"] == "cesium_asset_not_allowed" + assert isinstance(accepted, JSONResponse) + assert len(requests) == 1 + assert requests[0].url.path.endswith("/api/map/ion/assets/1/endpoint") + + +def test_upstream_errors_preserve_only_safe_code() -> None: + service, _ = _service( + lambda request: httpx.Response( + 504, + headers={"content-type": "application/json"}, + json={ + "code": "map_upstream_timeout", + "detail": "private-host.internal?token=forbidden", + }, + ) + ) + + response = asyncio.run( + service.proxy_cache( + _request("/api/v1/map/gateway/cache"), + target_url="https://assets.cesium.com/tile.bin", + ) + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 504 + assert json.loads(_response_body(response)) == { + "schema_version": "missioncore.map-gateway/v1", + "code": "map_upstream_timeout", + "retryable": True, + } + assert b"private-host" not in _response_body(response) diff --git a/tests/test_map_view_api.py b/tests/test_map_view_api.py new file mode 100644 index 0000000..e10388d --- /dev/null +++ b/tests/test_map_view_api.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from k1link.web.map_view_api import ( + MapCamera, + MapViewContent, + MapViewPut, + MapViewStore, + default_map_view, +) + + +def test_default_map_view_is_honestly_unlocated_and_cache_safe() -> None: + document = default_map_view() + + assert document.schema_version == "missioncore.map-view/v1" + assert document.revision == 0 + assert document.view.camera is None + assert document.view.selected_subject_id is None + assert document.view.layer_visibility.imagery is True + assert document.view.layer_visibility.targets is True + assert document.view.cache_intent.enabled is True + assert document.view.cache_intent.no_overwrite is True + + +def test_map_view_round_trip_uses_optimistic_revision(tmp_path: Path) -> None: + store = MapViewStore(tmp_path / "mission-data" / "map-view") + initial = store.read() + view = initial.view.model_copy( + update={ + "camera": MapCamera( + longitude=37.6176, + latitude=55.7558, + height=2500.0, + heading=12.0, + pitch=-48.0, + roll=0.0, + ), + "inspector_open_sections": ["camera"], + } + ) + + saved = store.save(MapViewPut(revision=initial.revision, view=view)) + restored = MapViewStore(store.root).read() + + assert saved.revision == 1 + assert restored == saved + assert restored.view.camera is not None + assert restored.view.camera.longitude == pytest.approx(37.6176) + + with pytest.raises(RuntimeError, match="revision changed"): + store.save(MapViewPut(revision=0, view=view)) + + +def test_map_view_contract_cannot_store_secret_or_provider_fields() -> None: + payload: dict[str, object] = { + "camera": None, + "visual_settings": {}, + "map_height": 720, + "inspector_open_sections": [], + "cache_intent": {"enabled": True, "no_overwrite": True}, + "selected_subject_id": None, + "layer_visibility": {}, + "token": "forbidden", + "gateway_url": "http://private.internal", + } + + with pytest.raises(ValidationError): + MapViewContent.model_validate(payload) + + +def test_selection_inspector_requires_real_stable_subject() -> None: + with pytest.raises(ValidationError, match="selected stable subject"): + MapViewContent(inspector_open_sections=["selection"]) + + selected = MapViewContent( + selected_subject_id="map.moving_object/device-006", + inspector_open_sections=["selection"], + ) + assert selected.selected_subject_id == "map.moving_object/device-006" + + +def test_persisted_document_contains_no_runtime_or_credential_material( + tmp_path: Path, +) -> None: + store = MapViewStore(tmp_path / "map-view") + initial = store.read() + store.save(MapViewPut(revision=0, view=initial.view)) + + payload = json.loads(store.document_path.read_text(encoding="utf-8")) + serialized = json.dumps(payload).lower() + assert "token" not in serialized + assert "gateway" not in serialized + assert "provider" not in serialized + assert "cesium" not in serialized diff --git a/uv.lock b/uv.lock index d24d758..1a254c8 100644 --- a/uv.lock +++ b/uv.lock @@ -66,6 +66,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -146,6 +155,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -161,6 +183,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -287,6 +324,7 @@ dependencies = [ { name = "bleak" }, { name = "fastapi" }, { name = "foxglove-sdk" }, + { name = "httpx" }, { name = "lz4" }, { name = "missioncore-plugin-sdk" }, { name = "paho-mqtt" }, @@ -311,6 +349,7 @@ requires-dist = [ { name = "bleak", specifier = "==3.0.2" }, { name = "fastapi", specifier = ">=0.116,<1" }, { name = "foxglove-sdk", specifier = "==0.25.3" }, + { name = "httpx", specifier = ">=0.28,<1" }, { name = "lz4", specifier = ">=4.4,<5" }, { name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" }, { name = "paho-mqtt", specifier = ">=2.1,<3" },