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, MAP_SANDBOX_HIDE_CREDITS_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( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv(MAP_SANDBOX_HIDE_CREDITS_ENV, raising=False) 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 document["sandbox"] == {"hide_credit_overlay": False} assert response.headers["cache-control"] == "no-store" assert not requests assert "map-gateway.internal" not in _response_body(response).decode() def test_runtime_config_hides_credits_only_with_explicit_sandbox_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: service, _ = _service(lambda request: httpx.Response(200)) monkeypatch.setenv(MAP_SANDBOX_HIDE_CREDITS_ENV, "true") response = service.runtime_configuration() assert isinstance(response, JSONResponse) document = json.loads(_response_body(response)) assert document["sandbox"] == {"hide_credit_overlay": True} 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_cache_accepts_only_the_public_bing_gateway_key_marker() -> None: service, requests = _service( lambda request: httpx.Response( 200, headers={"content-type": "application/json"}, content=b"{}", ) ) accepted = asyncio.run( service.proxy_cache( _request("/api/v1/map/gateway/cache"), target_url=( "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial" "?key=nodedc-gateway" ), ) ) assert isinstance(accepted, StreamingResponse) assert requests[0].url.params["url"].endswith("key=nodedc-gateway") with pytest.raises(HTTPException) as exc_info: asyncio.run( service.proxy_cache( _request("/api/v1/map/gateway/cache"), target_url=( "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial" "?key=browser-secret" ), ) ) assert exc_info.value.status_code == 422 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)