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
+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)