feat(map): add guarded map gateway BFF

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 16:45:11 +03:00
parent d95a13059c
commit 66f8d7a1fa
7 changed files with 1118 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"bleak==3.0.2", "bleak==3.0.2",
"fastapi>=0.116,<1", "fastapi>=0.116,<1",
"foxglove-sdk==0.25.3", "foxglove-sdk==0.25.3",
"httpx>=0.28,<1",
"lz4>=4.4,<5", "lz4>=4.4,<5",
"missioncore-plugin-sdk", "missioncore-plugin-sdk",
"paho-mqtt>=2.1,<3", "paho-mqtt>=2.1,<3",
+14
View File
@@ -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.environment_api import build_environment_router
from k1link.web.laboratory_api import build_laboratory_router from k1link.web.laboratory_api import build_laboratory_router
from k1link.web.lidar_api import build_lidar_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_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import ( from k1link.web.plugin_runtime import (
STATE_READ_ACTION_ID, STATE_READ_ACTION_ID,
@@ -151,6 +157,7 @@ session_perception_epoch_store = (
if _ffprobe is not None if _ffprobe is not None
else None else None
) )
map_gateway_proxy = MapGatewayProxy(MapGatewayConfiguration.from_environment())
def _prepare_recorded_media_for_launch( def _prepare_recorded_media_for_launch(
@@ -291,6 +298,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
reconciler = asyncio.create_task(_recording_preparation_reconciler()) reconciler = asyncio.create_task(_recording_preparation_reconciler())
yield yield
finally: finally:
await map_gateway_proxy.close()
if reconciler is not None: if reconciler is not None:
reconciler.cancel() reconciler.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -417,6 +425,12 @@ app.include_router(
root_provider=lambda: session_store.data_dir / "ui-environment" 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( app.include_router(
build_polygon_router( build_polygon_router(
root_provider=lambda: configured_polygon_runs_root() root_provider=lambda: configured_polygon_runs_root()
+470
View File
@@ -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
+179
View File
@@ -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
+315
View File
@@ -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)
+100
View File
@@ -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
Generated
+39
View File
@@ -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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.4.2" 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" }, { 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]] [[package]]
name = "httptools" name = "httptools"
version = "0.8.0" 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
@@ -287,6 +324,7 @@ dependencies = [
{ name = "bleak" }, { name = "bleak" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "foxglove-sdk" }, { name = "foxglove-sdk" },
{ name = "httpx" },
{ name = "lz4" }, { name = "lz4" },
{ name = "missioncore-plugin-sdk" }, { name = "missioncore-plugin-sdk" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
@@ -311,6 +349,7 @@ requires-dist = [
{ name = "bleak", specifier = "==3.0.2" }, { name = "bleak", specifier = "==3.0.2" },
{ name = "fastapi", specifier = ">=0.116,<1" }, { name = "fastapi", specifier = ">=0.116,<1" },
{ name = "foxglove-sdk", specifier = "==0.25.3" }, { name = "foxglove-sdk", specifier = "==0.25.3" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "lz4", specifier = ">=4.4,<5" }, { name = "lz4", specifier = ">=4.4,<5" },
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" }, { name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
{ name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "paho-mqtt", specifier = ">=2.1,<3" },