diff --git a/src/k1link/simulation/gaussian_pipeline_gateway.py b/src/k1link/simulation/gaussian_pipeline_gateway.py new file mode 100644 index 0000000..d0737e0 --- /dev/null +++ b/src/k1link/simulation/gaussian_pipeline_gateway.py @@ -0,0 +1,414 @@ +"""Portable connector for the external DC Gaussian Pipeline service.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final +from urllib.parse import quote, unquote, urljoin, urlparse + +import httpx + +TUS_VERSION: Final = "1.0.0" +BUILD_REQUEST_SCHEMA: Final = "gaussian-pipeline.build-request/v1" +JOB_SCHEMA: Final = "gaussian-pipeline.job/v1" +RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1" +CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1" +SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$") +DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024 +MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024 +MAX_RETRIES: Final = 3 + + +class GaussianPipelineGatewayError(RuntimeError): + """The configured Gaussian provider violated or rejected its connector contract.""" + + +class GaussianPipelineConfigurationError(GaussianPipelineGatewayError): + """The provider connector configuration is incomplete or unsafe.""" + + +class GaussianPipelineUnavailableError(GaussianPipelineGatewayError): + """The provider cannot currently be reached.""" + + +class GaussianPipelineIntegrityError(GaussianPipelineGatewayError): + """Transferred bytes do not match their immutable descriptor.""" + + +@dataclass(frozen=True, slots=True) +class GaussianSourceUpload: + upload_id: str + filename: str + format: str + sha256: str + byte_length: int + + def to_dict(self) -> dict[str, object]: + return { + "upload_id": self.upload_id, + "filename": self.filename, + "format": self.format, + "sha256": self.sha256, + "byte_length": self.byte_length, + } + + +class GaussianPipelineGateway: + """TUS and JSON client with bounded responses and independent digest checks.""" + + def __init__( + self, + endpoint: str, + token_file: Path, + *, + timeout_seconds: float = 30.0, + chunk_bytes: int = DEFAULT_CHUNK_BYTES, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.endpoint = _endpoint(endpoint) + self.token_file = token_file.expanduser().absolute() + self.token = _read_token(self.token_file) + if timeout_seconds <= 0: + raise GaussianPipelineConfigurationError("provider timeout must be positive") + if chunk_bytes <= 0 or chunk_bytes > 64 * 1024 * 1024: + raise GaussianPipelineConfigurationError("provider upload chunk size is invalid") + self.chunk_bytes = chunk_bytes + self._client = httpx.Client( + base_url=self.endpoint, + headers={"Authorization": f"Bearer {self.token}"}, + timeout=httpx.Timeout(timeout_seconds), + transport=transport, + follow_redirects=False, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> GaussianPipelineGateway: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def capabilities(self) -> dict[str, Any]: + document = self._json("GET", "/v1/capabilities") + if ( + document.get("schema_version") != CAPABILITIES_SCHEMA + or document.get("service") != "ndc-gaussian-pipeline" + ): + raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1") + return document + + def upload_source( + self, + source_path: Path, + *, + filename: str, + source_format: str, + sha256: str, + ) -> GaussianSourceUpload: + source_candidate = source_path.expanduser().absolute() + if source_candidate.is_symlink() or not source_candidate.is_file(): + raise GaussianPipelineIntegrityError("Gaussian source must be one regular file") + source = source_candidate.resolve() + if source_format not in {"lcc", "lcc2"}: + raise GaussianPipelineIntegrityError("Gaussian source format must be lcc or lcc2") + if Path(filename).name != filename or not filename.lower().endswith(f".{source_format}"): + raise GaussianPipelineIntegrityError("Gaussian source filename is unsafe") + if SHA256_PATTERN.fullmatch(sha256) is None: + raise GaussianPipelineIntegrityError("Gaussian source digest is invalid") + byte_length = source.stat().st_size + if byte_length <= 0: + raise GaussianPipelineIntegrityError("Gaussian source is empty") + if _sha256(source) != sha256: + raise GaussianPipelineIntegrityError("Gaussian source digest does not match") + + metadata = _tus_metadata( + {"filename": filename, "format": source_format, "sha256": sha256} + ) + try: + response = self._client.post( + "/v1/uploads", + headers={ + "Tus-Resumable": TUS_VERSION, + "Upload-Length": str(byte_length), + "Upload-Metadata": metadata, + }, + content=b"", + ) + response.raise_for_status() + except httpx.HTTPError as exc: + raise _unavailable("Gaussian upload could not be created", exc) from exc + location = response.headers.get("Location") + if location is None: + raise GaussianPipelineGatewayError("Gaussian upload response omitted Location") + upload_url = urljoin(str(response.request.url), location) + if not _is_provider_upload_url(self.endpoint, upload_url): + raise GaussianPipelineGatewayError("Gaussian upload Location escaped the provider") + upload_id = unquote(urlparse(upload_url).path.rsplit("/", 1)[-1]) + if SAFE_UPLOAD_ID.fullmatch(upload_id) is None: + raise GaussianPipelineGatewayError("Gaussian upload id is invalid") + self._send_file(source, upload_url, byte_length) + return GaussianSourceUpload( + upload_id=upload_id, + filename=filename, + format=source_format, + sha256=sha256, + byte_length=byte_length, + ) + + def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]: + if document.get("schema_version") != BUILD_REQUEST_SCHEMA: + raise GaussianPipelineGatewayError("Gaussian build request schema is invalid") + result = self._json("POST", "/v1/jobs", document=document) + if result.get("schema_version") != JOB_SCHEMA: + raise GaussianPipelineGatewayError("Gaussian job response does not match v1") + return result + + def get_job(self, job_id: str) -> dict[str, Any]: + _safe_id(job_id, "job id") + result = self._json("GET", f"/v1/jobs/{quote(job_id, safe='')}") + if result.get("schema_version") != JOB_SCHEMA or result.get("job_id") != job_id: + raise GaussianPipelineGatewayError("Gaussian job identity does not match") + return result + + def get_result(self, job_id: str) -> dict[str, Any]: + _safe_id(job_id, "job id") + result = self._json("GET", f"/v1/jobs/{quote(job_id, safe='')}/result") + if result.get("schema_version") != RESULT_SCHEMA or result.get("job_id") != job_id: + raise GaussianPipelineGatewayError("Gaussian result identity does not match") + return result + + def download_artifact( + self, + job_id: str, + descriptor: Mapping[str, object], + destination: Path, + ) -> Path: + _safe_id(job_id, "job id") + logical_path = descriptor.get("logical_path") + expected_sha256 = descriptor.get("sha256") + expected_bytes = descriptor.get("byte_length") + if ( + not isinstance(logical_path, str) + or not logical_path + or logical_path.startswith("/") + or "\\" in logical_path + or any(part in {"", ".", ".."} for part in logical_path.split("/")) + ): + raise GaussianPipelineGatewayError("Gaussian artifact logical path is invalid") + if ( + not isinstance(expected_sha256, str) + or SHA256_PATTERN.fullmatch(expected_sha256) is None + ): + raise GaussianPipelineGatewayError("Gaussian artifact digest is invalid") + if ( + not isinstance(expected_bytes, int) + or isinstance(expected_bytes, bool) + or expected_bytes < 0 + ): + raise GaussianPipelineGatewayError("Gaussian artifact byte length is invalid") + target = destination.expanduser().resolve() + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.partial-{os.getpid()}") + digest = hashlib.sha256() + byte_length = 0 + temporary_created = False + try: + with self._client.stream( + "GET", + f"/v1/jobs/{quote(job_id, safe='')}/artifacts/{quote(logical_path, safe='/')}", + ) as response: + response.raise_for_status() + with temporary.open("xb") as output: + temporary_created = True + for chunk in response.iter_bytes(chunk_size=1024 * 1024): + output.write(chunk) + digest.update(chunk) + byte_length += len(chunk) + if byte_length != expected_bytes or digest.hexdigest() != expected_sha256: + raise GaussianPipelineIntegrityError( + "Gaussian artifact bytes do not match the result manifest" + ) + temporary.replace(target) + return target + except httpx.HTTPError as exc: + raise _unavailable("Gaussian artifact download failed", exc) from exc + finally: + if temporary_created: + temporary.unlink(missing_ok=True) + + def _send_file(self, source: Path, upload_url: str, byte_length: int) -> None: + offset = self._upload_offset(upload_url, byte_length) + retries = 0 + with source.open("rb") as input_file: + while offset < byte_length: + input_file.seek(offset) + chunk = input_file.read(min(self.chunk_bytes, byte_length - offset)) + if not chunk: + raise GaussianPipelineIntegrityError( + "Gaussian source ended before upload length" + ) + try: + response = self._client.patch( + upload_url, + headers={ + "Tus-Resumable": TUS_VERSION, + "Upload-Offset": str(offset), + "Content-Type": "application/offset+octet-stream", + }, + content=chunk, + ) + response.raise_for_status() + next_offset = _offset(response.headers.get("Upload-Offset"), byte_length) + if next_offset != offset + len(chunk): + raise GaussianPipelineGatewayError( + "Gaussian upload returned a non-contiguous offset" + ) + offset = next_offset + retries = 0 + except httpx.TransportError as exc: + retries += 1 + if retries > MAX_RETRIES: + raise _unavailable( + "Gaussian upload failed after resume attempts", exc + ) from exc + offset = self._upload_offset(upload_url, byte_length) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 409 and retries < MAX_RETRIES: + retries += 1 + offset = self._upload_offset(upload_url, byte_length) + continue + raise _unavailable("Gaussian upload was rejected", exc) from exc + + def _upload_offset(self, upload_url: str, byte_length: int) -> int: + try: + response = self._client.head( + upload_url, + headers={"Tus-Resumable": TUS_VERSION}, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + raise _unavailable("Gaussian upload offset is unavailable", exc) from exc + return _offset(response.headers.get("Upload-Offset"), byte_length) + + def _json( + self, + method: str, + path: str, + *, + document: Mapping[str, object] | None = None, + ) -> dict[str, Any]: + try: + response = self._client.request(method, path, json=document) + response.raise_for_status() + except httpx.HTTPError as exc: + raise _unavailable("Gaussian provider request failed", exc) from exc + if len(response.content) > MAX_JSON_RESPONSE_BYTES: + raise GaussianPipelineGatewayError("Gaussian provider JSON response is too large") + try: + value = response.json() + except json.JSONDecodeError as exc: + raise GaussianPipelineGatewayError("Gaussian provider response is not JSON") from exc + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise GaussianPipelineGatewayError("Gaussian provider response must be an object") + return value + + +def configured_gaussian_pipeline_gateway() -> GaussianPipelineGateway | None: + endpoint = os.environ.get("MISSIONCORE_GAUSSIAN_ENDPOINT") + token_file = os.environ.get("MISSIONCORE_GAUSSIAN_TOKEN_FILE") + if endpoint is None and token_file is None: + return None + if not endpoint or not token_file: + raise GaussianPipelineConfigurationError( + "MISSIONCORE_GAUSSIAN_ENDPOINT and MISSIONCORE_GAUSSIAN_TOKEN_FILE must be set together" + ) + return GaussianPipelineGateway(endpoint, Path(token_file)) + + +def _endpoint(value: str) -> str: + parsed = urlparse(value) + 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.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise GaussianPipelineConfigurationError("Gaussian provider endpoint is invalid") + return value.rstrip("/") + + +def _is_provider_upload_url(endpoint: str, upload_url: str) -> bool: + provider = urlparse(endpoint) + upload = urlparse(upload_url) + return ( + upload.scheme == provider.scheme + and upload.hostname == provider.hostname + and upload.port == provider.port + and upload.username is None + and upload.password is None + and upload.path.startswith("/v1/uploads/") + and not upload.params + and not upload.query + and not upload.fragment + ) + + +def _read_token(token_file: Path) -> str: + try: + metadata = token_file.lstat() + if token_file.is_symlink() or not token_file.is_file(): + raise GaussianPipelineConfigurationError("Gaussian token must be one regular file") + token = token_file.read_text(encoding="utf-8").strip() + except OSError as exc: + raise GaussianPipelineConfigurationError("Gaussian token file is unavailable") from exc + if metadata.st_size > 4096 or not 32 <= len(token) <= 4096: + raise GaussianPipelineConfigurationError("Gaussian token length is invalid") + return token + + +def _tus_metadata(values: Mapping[str, str]) -> str: + return ",".join( + f"{key} {base64.b64encode(value.encode('utf-8')).decode('ascii')}" + for key, value in values.items() + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _offset(value: str | None, byte_length: int) -> int: + if value is None or not value.isdigit(): + raise GaussianPipelineGatewayError("Gaussian upload offset is invalid") + offset = int(value) + if not 0 <= offset <= byte_length: + raise GaussianPipelineGatewayError("Gaussian upload offset is outside the source") + return offset + + +def _safe_id(value: str, label: str) -> None: + if SAFE_UPLOAD_ID.fullmatch(value) is None: + raise GaussianPipelineGatewayError(f"Gaussian {label} is invalid") + + +def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError: + if isinstance(error, httpx.TransportError): + return GaussianPipelineUnavailableError(message) + return GaussianPipelineGatewayError(message) diff --git a/src/k1link/web/simulation_world_provider_api.py b/src/k1link/web/simulation_world_provider_api.py new file mode 100644 index 0000000..b584791 --- /dev/null +++ b/src/k1link/web/simulation_world_provider_api.py @@ -0,0 +1,66 @@ +"""Read-only Mission Core projection of the external Gaussian build provider.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +from fastapi import APIRouter +from pydantic import BaseModel, ConfigDict + +from k1link.simulation.gaussian_pipeline_gateway import ( + GaussianPipelineGateway, + GaussianPipelineGatewayError, + configured_gaussian_pipeline_gateway, +) + +ProviderFactory = Callable[[], GaussianPipelineGateway | None] + + +class SimulationWorldProviderStatus(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["missioncore.simulation-world-provider-status/v1"] = ( + "missioncore.simulation-world-provider-status/v1" + ) + provider_id: Literal["gaussian-pipeline"] = "gaussian-pipeline" + configured: bool + state: Literal["ready", "unavailable"] + capabilities: dict[str, Any] | None + + +def build_simulation_world_provider_router( + provider_factory: ProviderFactory = configured_gaussian_pipeline_gateway, +) -> APIRouter: + router = APIRouter() + + @router.get( + "/api/v1/simulation-worlds/provider", + response_model=SimulationWorldProviderStatus, + ) + def provider_status() -> SimulationWorldProviderStatus: + provider: GaussianPipelineGateway | None = None + try: + provider = provider_factory() + if provider is None: + return SimulationWorldProviderStatus( + configured=False, + state="unavailable", + capabilities=None, + ) + return SimulationWorldProviderStatus( + configured=True, + state="ready", + capabilities=provider.capabilities(), + ) + except GaussianPipelineGatewayError: + return SimulationWorldProviderStatus( + configured=True, + state="unavailable", + capabilities=None, + ) + finally: + if provider is not None: + provider.close() + + return router diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py new file mode 100644 index 0000000..b5a84d6 --- /dev/null +++ b/tests/test_gaussian_pipeline_gateway.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path + +import httpx +import pytest + +from k1link.simulation.gaussian_pipeline_gateway import ( + BUILD_REQUEST_SCHEMA, + GaussianPipelineGateway, + GaussianPipelineGatewayError, + GaussianPipelineIntegrityError, +) + + +def _token_file(tmp_path: Path) -> Path: + token = tmp_path / "gaussian.token" + token.write_text("t" * 64, encoding="utf-8") + return token + + +def test_gateway_uploads_with_tus_and_reads_provider_contract(tmp_path: Path) -> None: + uploaded = bytearray() + metadata: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["authorization"] == f"Bearer {'t' * 64}" + if request.method == "GET" and request.url.path == "/v1/capabilities": + return httpx.Response( + 200, + json={ + "schema_version": "gaussian-pipeline.capabilities/v1", + "service": "ndc-gaussian-pipeline", + "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, + }, + ) + if request.method == "POST" and request.url.path == "/v1/uploads": + for item in request.headers["upload-metadata"].split(","): + key, encoded = item.split(" ", 1) + metadata[key] = base64.b64decode(encoded).decode("utf-8") + return httpx.Response(201, headers={"Location": "/v1/uploads/upload-001"}) + if request.method == "HEAD" and request.url.path == "/v1/uploads/upload-001": + return httpx.Response(200, headers={"Upload-Offset": str(len(uploaded))}) + if request.method == "PATCH" and request.url.path == "/v1/uploads/upload-001": + assert int(request.headers["upload-offset"]) == len(uploaded) + uploaded.extend(request.content) + return httpx.Response(204, headers={"Upload-Offset": str(len(uploaded))}) + return httpx.Response(404) + + source = tmp_path / "yard.lcc2" + source.write_bytes(b"portable-gaussian-source") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + with GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + chunk_bytes=5, + transport=httpx.MockTransport(handler), + ) as gateway: + assert gateway.capabilities()["service"] == "ndc-gaussian-pipeline" + descriptor = gateway.upload_source( + source, + filename="yard.lcc2", + source_format="lcc2", + sha256=digest, + ) + + assert bytes(uploaded) == source.read_bytes() + assert metadata == {"filename": "yard.lcc2", "format": "lcc2", "sha256": digest} + assert descriptor.to_dict() == { + "upload_id": "upload-001", + "filename": "yard.lcc2", + "format": "lcc2", + "sha256": digest, + "byte_length": len(uploaded), + } + + +def test_gateway_rejects_local_source_digest_mismatch(tmp_path: Path) -> None: + source = tmp_path / "yard.lcc" + source.write_bytes(b"source") + with ( + GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + transport=httpx.MockTransport(lambda _request: httpx.Response(500)), + ) as gateway, + pytest.raises(GaussianPipelineIntegrityError, match="digest does not match"), + ): + gateway.upload_source( + source, + filename="yard.lcc", + source_format="lcc", + sha256="a" * 64, + ) + + +def test_gateway_rejects_upload_location_outside_provider(tmp_path: Path) -> None: + source = tmp_path / "yard.lcc" + source.write_bytes(b"source") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + headers={"Location": "https://attacker.invalid/v1/uploads/stolen"}, + ) + + with ( + GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + transport=httpx.MockTransport(handler), + ) as gateway, + pytest.raises(GaussianPipelineGatewayError, match="escaped the provider"), + ): + gateway.upload_source( + source, + filename="yard.lcc", + source_format="lcc", + sha256=digest, + ) + + +def test_gateway_rejects_symlink_source_and_token(tmp_path: Path) -> None: + source = tmp_path / "source.lcc" + source.write_bytes(b"source") + source_link = tmp_path / "source-link.lcc" + source_link.symlink_to(source) + token = _token_file(tmp_path) + token_link = tmp_path / "token-link" + token_link.symlink_to(token) + + with pytest.raises(GaussianPipelineGatewayError, match="token must be one regular file"): + GaussianPipelineGateway("http://gaussian.test", token_link) + + digest = hashlib.sha256(source.read_bytes()).hexdigest() + with ( + GaussianPipelineGateway( + "http://gaussian.test", + token, + transport=httpx.MockTransport(lambda _request: httpx.Response(500)), + ) as gateway, + pytest.raises(GaussianPipelineIntegrityError, match="one regular file"), + ): + gateway.upload_source( + source_link, + filename="source.lcc", + source_format="lcc", + sha256=digest, + ) + + +def test_gateway_submits_tracks_and_imports_digest_bound_artifact(tmp_path: Path) -> None: + artifact = b"preview-sog" + artifact_sha = hashlib.sha256(artifact).hexdigest() + job_id = "gsp-20260825200000-deadbeef" + request_document: dict[str, object] = { + "schema_version": BUILD_REQUEST_SCHEMA, + "idempotency_key": "missioncore-build-01", + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/v1/jobs": + assert json.loads(request.content) == request_document + return httpx.Response( + 202, + json={"schema_version": "gaussian-pipeline.job/v1", "job_id": job_id}, + ) + if request.method == "GET" and request.url.path == f"/v1/jobs/{job_id}": + return httpx.Response( + 200, + json={"schema_version": "gaussian-pipeline.job/v1", "job_id": job_id}, + ) + if request.method == "GET" and request.url.path == f"/v1/jobs/{job_id}/result": + return httpx.Response( + 200, + json={"schema_version": "gaussian-pipeline.build-result/v1", "job_id": job_id}, + ) + if request.method == "GET" and request.url.path.endswith("/artifacts/preview.sog"): + return httpx.Response(200, content=artifact) + return httpx.Response(404) + + with GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + transport=httpx.MockTransport(handler), + ) as gateway: + assert gateway.submit_build(request_document)["job_id"] == job_id + assert gateway.get_job(job_id)["job_id"] == job_id + assert gateway.get_result(job_id)["job_id"] == job_id + destination = gateway.download_artifact( + job_id, + { + "logical_path": "preview.sog", + "sha256": artifact_sha, + "byte_length": len(artifact), + }, + tmp_path / "import" / "preview.sog", + ) + assert destination.read_bytes() == artifact diff --git a/tests/test_simulation_world_provider_api.py b/tests/test_simulation_world_provider_api.py new file mode 100644 index 0000000..caf235e --- /dev/null +++ b/tests/test_simulation_world_provider_api.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGateway +from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router + + +def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway: + token = tmp_path / "token" + token.write_text("x" * 64, encoding="utf-8") + + def handler(_request: httpx.Request) -> httpx.Response: + if status != 200: + return httpx.Response(status) + return httpx.Response( + 200, + json={ + "schema_version": "gaussian-pipeline.capabilities/v1", + "service": "ndc-gaussian-pipeline", + "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, + }, + ) + + return GaussianPipelineGateway( + "http://gaussian.test", + token, + transport=httpx.MockTransport(handler), + ) + + +def test_provider_status_is_explicitly_unconfigured() -> None: + app = FastAPI() + app.include_router(build_simulation_world_provider_router(lambda: None)) + response = TestClient(app).get("/api/v1/simulation-worlds/provider") + assert response.status_code == 200 + assert response.json() == { + "schema_version": "missioncore.simulation-world-provider-status/v1", + "provider_id": "gaussian-pipeline", + "configured": False, + "state": "unavailable", + "capabilities": None, + } + + +def test_provider_status_projects_ready_capabilities(tmp_path: Path) -> None: + app = FastAPI() + app.include_router(build_simulation_world_provider_router(lambda: _gateway(tmp_path))) + response = TestClient(app).get("/api/v1/simulation-worlds/provider") + assert response.status_code == 200 + assert response.json()["state"] == "ready" + assert response.json()["capabilities"]["provider"]["version"] == "3.3.3" + + +def test_provider_status_fails_closed_without_leaking_transport_error(tmp_path: Path) -> None: + app = FastAPI() + app.include_router(build_simulation_world_provider_router(lambda: _gateway(tmp_path, 503))) + response = TestClient(app).get("/api/v1/simulation-worlds/provider") + assert response.status_code == 200 + assert response.json()["configured"] is True + assert response.json()["state"] == "unavailable" + assert response.json()["capabilities"] is None