Files
NODEDC_MISSION_CORE/tests/test_map_api.py
T

369 lines
12 KiB
Python

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)