"""HTTP-layer tests (app/main.py) via FastAPI TestClient. Covers health/UI, upload validation errors, the honest missing-secret path, the happy path with a mocked LLM, artifact serving, rate limiting and concurrency. """ from __future__ import annotations import asyncio import dataclasses import json import time from pathlib import Path import pytest from fastapi.testclient import TestClient from app import main as main_module from app.config import Settings from app.gallery import GalleryStore from app.pipeline import JobRegistry from app.ratelimit import RateLimiter from tests.conftest import MockLLM FIXTURES = Path(__file__).resolve().parent / "fixtures" ALL_LLM_VARS = ( "LLM_API_KEY", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_STYLE", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", ) @pytest.fixture() def env(tmp_path, monkeypatch, canned_spec_text): """Isolate the app: scrubbed env, tmp registry, mock LLM factory.""" for var in ALL_LLM_VARS: monkeypatch.delenv(var, raising=False) monkeypatch.setenv("LLM_API_KEY", "test-key-not-real") monkeypatch.setenv("LLM_MODEL", "test-model") monkeypatch.setenv("LLM_BASE_URL", "https://llm.test/api") monkeypatch.setenv("RUNS_DIR", str(tmp_path / "runs")) monkeypatch.setenv("GALLERY_DIR", str(tmp_path / "gallery")) configured = Settings() monkeypatch.delenv("LLM_API_KEY") monkeypatch.delenv("LLM_MODEL") unconfigured = Settings() registry = JobRegistry(tmp_path / "runs") monkeypatch.setattr(main_module, "registry", registry) monkeypatch.setattr( main_module, "gallery_store", GalleryStore(tmp_path / "gallery") ) monkeypatch.setattr(main_module, "limiter", RateLimiter(100)) monkeypatch.setattr(main_module, "settings", configured) main_module.app.state.llm_factory = lambda s: MockLLM([canned_spec_text]) with TestClient(main_module.app) as client: yield client, registry, configured, unconfigured main_module.app.state.llm_factory = lambda s: main_module.LLMClient(s) def wait_terminal(client: TestClient, job_id: str, timeout: float = 90.0) -> dict: deadline = time.time() + timeout while time.time() < deadline: payload = client.get(f"/api/jobs/{job_id}").json() if payload["status"] in {"done", "error"}: return payload time.sleep(0.4) raise AssertionError(f"job {job_id} did not finish in {timeout}s") def post_png(client: TestClient, data: bytes | None = None, **kwargs): payload = data if data is not None else (FIXTURES / "ref.png").read_bytes() resp = client.post("/api/jobs", files={"file": ("ref.png", payload, "image/png")}, **kwargs) if resp.status_code not in (202, 429): print("\nPOST /api/jobs ->", resp.status_code, resp.text[:400]) return resp class TestBasics: def test_health(self, env): client, *_ = env payload = client.get("/health").json() assert payload["status"] == "ok" assert "llm_configured" in payload def test_index_served(self, env): client, *_ = env resp = client.get("/") assert resp.status_code == 200 assert "img2threejs" in resp.text assert "viewer-frame" in resp.text def test_static_assets(self, env): client, *_ = env for asset in ("app.js", "styles.css", "viewer.html", "viewer-core.js", "logo.svg"): assert client.get(f"/static/{asset}").status_code == 200, asset def test_config_never_leaks_secrets(self, env): client, *_ = env resp = client.get("/api/config") assert resp.status_code == 200 assert resp.json()["llm_configured"] is True assert "test-key-not-real" not in resp.text def test_security_headers(self, env): client, *_ = env resp = client.get("/") assert "content-security-policy" in resp.headers assert resp.headers["x-content-type-options"] == "nosniff" class TestHonestFailures: def test_missing_secret_503_no_fabrication(self, env, monkeypatch): client, _, _, unconfigured = env monkeypatch.setattr(main_module, "settings", unconfigured) resp = post_png(client) assert resp.status_code == 503 payload = resp.json() assert payload["error"] == "llm_not_configured" assert "LLM_API_KEY" in payload["detail"] assert "LLM_MODEL" in payload["detail"] # Never fabricate: no artifact references whatsoever. assert "sculptRuntime" not in resp.text assert "bundle" not in resp.text def test_no_file_422(self, env): client, *_ = env assert client.post("/api/jobs").status_code == 422 def test_non_image_content_type_415(self, env): client, *_ = env resp = client.post("/api/jobs", files={"file": ("x.html", b"", "text/html")}) assert resp.status_code == 415 def test_oversize_413(self, env, monkeypatch): client, *_ = env big = b"\x89PNG\r\n\x1a\n" + b"0" * (10 * 1024 * 1024 + 2) resp = client.post("/api/jobs", files={"file": ("big.png", big, "image/png")}) assert resp.status_code == 413 def test_asgi_content_length_cap_rejects_before_multipart(self, env, monkeypatch): client, _, configured, _ = env bounded = dataclasses.replace(configured, max_upload_bytes=64 * 1024) monkeypatch.setattr(main_module, "settings", bounded) body = b"x" * ( bounded.max_upload_bytes + main_module.MULTIPART_OVERHEAD_BYTES + 1 ) resp = client.post( "/api/jobs", content=body, headers={"content-type": "multipart/form-data; boundary=x"}, ) assert resp.status_code == 413 assert resp.json()["error"] == "request_too_large" # Body-limit responses still pass through the outer security policy. assert "content-security-policy" in resp.headers def test_asgi_receive_cap_without_content_length(self, env, monkeypatch): _, _, configured, _ = env bounded = dataclasses.replace(configured, max_upload_bytes=64 * 1024) monkeypatch.setattr(main_module, "settings", bounded) limit = bounded.max_upload_bytes + main_module.MULTIPART_OVERHEAD_BYTES chunks = [ {"type": "http.request", "body": b"a" * (limit // 2), "more_body": True}, {"type": "http.request", "body": b"b" * (limit // 2 + 1), "more_body": False}, ] sent: list[dict] = [] async def drain_app(scope, receive, send): while True: message = await receive() if not message.get("more_body"): return async def receive(): return chunks.pop(0) async def send(message): sent.append(message) scope = { "type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1", "method": "POST", "scheme": "http", "path": "/api/jobs", "raw_path": b"/api/jobs", "query_string": b"", "headers": [], "client": ("test-peer", 1), "server": ("test", 80), } middleware = main_module.UploadBodyLimitMiddleware(drain_app) asyncio.run(middleware(scope, receive, send)) start = next(message for message in sent if message["type"] == "http.response.start") assert start["status"] == 413 def test_corrupt_image_fails_honestly(self, env): client, *_ = env resp = client.post( "/api/jobs", files={"file": ("bad.png", b"\x89PNG\r\n\x1a\ntruncated", "image/png")}) assert resp.status_code == 202 terminal = wait_terminal(client, resp.json()["job_id"]) assert terminal["status"] == "error" assert terminal["error"]["stage"] == "intake" def test_unknown_job_404(self, env): client, *_ = env assert client.get("/api/jobs/deadbeefdeadbeef").status_code == 404 assert client.get("/api/jobs/deadbeefdeadbeef/events").status_code == 404 assert client.get("/api/jobs/deadbeefdeadbeef/artifacts/spec.json").status_code == 404 assert client.get("/api/jobs/../../etc/passwd").status_code in {404, 422} def test_artifact_name_whitelist(self, env): client, registry, *_ = env job = registry.create() (job.dir / "evil.sh").write_text("echo nope") assert client.get(f"/api/jobs/{job.id}/artifacts/evil.sh").status_code == 404 def test_failed_job_cannot_serve_late_model_artifacts(self, env): client, registry, *_ = env job = registry.create() (job.dir / "spec.json").write_text( '{"targetName":"Diagnostic"}', encoding="utf-8" ) (job.dir / "factory.ts").write_text( "export const late = true;", encoding="utf-8" ) (job.dir / "model.bundle.js").write_text( "export const late = true;", encoding="utf-8" ) job.status = "error" assert client.get( f"/api/jobs/{job.id}/artifacts/spec.json" ).status_code == 200 assert client.get( f"/api/jobs/{job.id}/artifacts/factory.ts" ).status_code == 404 assert client.get( f"/api/jobs/{job.id}/artifacts/model.bundle.js" ).status_code == 404 class TestHappyPath: def test_full_job_and_artifacts(self, env): client, *_ = env resp = post_png(client, data=(FIXTURES / "ref.png").read_bytes()) assert resp.status_code == 202 job_id = resp.json()["job_id"] terminal = wait_terminal(client, job_id) assert terminal["status"] == "done", terminal.get("error") status_response = client.get(f"/api/jobs/{job_id}") assert status_response.headers["cache-control"] == "no-store" result = terminal["result"] assert result["targetName"] == "Mug" assert result["shareRequested"] is True assert result["shared"] is True assert result["galleryItem"]["id"] == job_id gallery_detail = client.get(result["galleryItem"]["detailUrl"]) assert gallery_detail.status_code == 200 assert gallery_detail.json()["targetName"] == "Mug" factory = client.get(f"/api/jobs/{job_id}/artifacts/factory.ts") assert factory.status_code == 200 assert factory.headers["cache-control"] == "no-store" assert "createMugModel" in factory.text assert "sculptRuntime" in factory.text spec = client.get(f"/api/jobs/{job_id}/artifacts/spec.json") assert spec.status_code == 200 assert spec.json()["targetName"] == "Mug" bundle = client.get(f"/api/jobs/{job_id}/artifacts/model.bundle.js") assert bundle.status_code == 200 assert len(bundle.content) > 100_000 standalone = client.get(f"/api/jobs/{job_id}/artifacts/standalone.html") assert standalone.status_code == 200 assert "mountViewer" in standalone.text probe = client.get(f"/api/jobs/{job_id}/artifacts/probe.json") assert probe.json()["width"] == 64 reference = client.get(f"/api/jobs/{job_id}/artifacts/reference.png") assert reference.status_code == 200 assert reference.content[:8] == b"\x89PNG\r\n\x1a\n" def test_explicit_share_false_never_publishes(self, env): client, *_ = env response = client.post( "/api/jobs", files={ "file": ( "ref.png", (FIXTURES / "ref.png").read_bytes(), "image/png", ) }, data={"share": "false"}, ) assert response.status_code == 202 assert response.json()["share_requested"] is False terminal = wait_terminal(client, response.json()["job_id"]) assert terminal["status"] == "done", terminal.get("error") assert terminal["result"]["shareRequested"] is False assert terminal["result"]["shared"] is False assert terminal["result"]["galleryItem"] is None assert client.get("/api/gallery").json()["items"] == [] assert any( event["stage"] == "publishing" and event["status"] == "done" and event["data"]["shared"] is False for event in main_module.registry.get(response.json()["job_id"]).events ) def test_sse_replay_terminates(self, env): client, *_ = env job_id = post_png(client).json()["job_id"] terminal = wait_terminal(client, job_id) assert terminal["status"] == "done" resp = client.get(f"/api/jobs/{job_id}/events") assert resp.status_code == 200 assert "text/event-stream" in resp.headers["content-type"] assert resp.headers["cache-control"] == "no-store" lines = [l[5:] for l in resp.text.splitlines() if l.startswith("data:")] events = [json.loads(l) for l in lines] assert events[0]["stage"] == "queued" assert events[1]["stage"] == "queued" and events[1]["status"] == "done" assert "id: 1" in resp.text assert events[-1]["stage"] == "done" and events[-1]["status"] == "done" assert events[-1]["data"]["result"]["targetName"] == "Mug" resumed = client.get( f"/api/jobs/{job_id}/events", headers={"Last-Event-ID": str(events[-2]["seq"])}, ) resumed_events = [ json.loads(line[5:]) for line in resumed.text.splitlines() if line.startswith("data:") ] assert [event["seq"] for event in resumed_events] == [events[-1]["seq"]] def test_concurrent_http_jobs(self, env): client, *_ = env ids = [post_png(client).json()["job_id"] for _ in range(3)] terminals = [wait_terminal(client, jid) for jid in ids] assert {t["status"] for t in terminals} == {"done"} assert len(set(ids)) == 3 def test_queue_wait_reports_elapsed_then_honestly_times_out( self, env, monkeypatch ): client, registry, configured, _ = env monkeypatch.setattr(main_module, "job_semaphore", asyncio.Semaphore(0)) monkeypatch.setattr(main_module, "QUEUE_PROGRESS_INTERVAL_S", 0.02) monkeypatch.setattr( main_module, "settings", dataclasses.replace(configured, job_timeout_s=0.08), ) response = post_png(client) assert response.status_code == 202 terminal = wait_terminal(client, response.json()["job_id"], timeout=5) assert terminal["status"] == "error" assert terminal["error"]["code"] == "job_timeout" assert terminal["error"]["stage"] == "queued" events = registry.get(response.json()["job_id"]).events assert any( event["stage"] == "queued" and event["status"] == "progress" and event["data"]["elapsedSeconds"] >= 1 for event in events ) class TestRateLimit: def test_limit_enforced(self, env, monkeypatch): client, *_ = env monkeypatch.setattr(main_module, "limiter", RateLimiter(2)) assert post_png(client).status_code == 202 assert post_png(client).status_code == 202 resp = post_png(client) assert resp.status_code == 429 assert "retry-after" in resp.headers