"""Integration tests: phase 181 (task 03) — the SVG half of the image BYTES route ``GET /api/documents/{doc_id}/image``. The contract this module pins (LOCKED A3): * an ``is_image`` row whose stored copy is an ``.svg`` serves ``Content-Type: image/svg+xml`` (the ``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth with the describe call's data-URL mime) plus ONE extra response header, ``Content-Security-Policy: default-src 'none'; sandbox``. An ```` can never run an SVG's script, but the URL is DIRECTLY navigable, and the bytes come from an untrusted source tree — so a direct visit must not hand the browser a script-capable SAME-ORIGIN document. The route sets the policy itself and ``SecurityHeadersMiddleware`` PRESERVES a CSP an inner layer already set, so this is what the browser sees; the middleware's global ``X-Content-Type-Options: nosniff`` and ``X-Frame-Options: DENY`` still land on the response; * the SVG bytes are served VERBATIM (no rewriting, no ``Content-Disposition: attachment`` — the graphic must render inline and the policy is the control, not a sanitizer); * every RASTER response is byte-identical to its pre-phase shape — the same header KEY set, the app-wide CSP from the middleware (no ``sandbox``, no route-specific policy), the same ``Cache-Control`` and the same exact bytes; * the phase-122 posture is unchanged: PUBLIC (phase 124 LOCKED A4 — the unguessable ``uuid4`` id IS the credential, the shared chat shows the figure to a guest) with the four 404 ``document not found`` shapes (unknown id, unparseable id — 404 and never 422, a text doc, a row whose copy is gone from disk). Task 05 of this phase adds the INGESTION-TO-SERVE round trip: the task-03 content contract on ``GET /api/documents/content`` for an SVG row (``is_image: true``, an ``image_url`` of the form ``/api/documents//image``, ``format: "svg"`` and the extracted ````/``<desc>``/``<text>`` words as ``content``) and the WIRE-CONTRACT PIN that ties the phases together: that literal ``image_url`` is accepted by phase 180's ``allowImageUrl`` renderer allowlist — the ``![alt](<image_url>)`` synergy (an answer or a markdown document referencing the bytes route paints the graphic). Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient. """ from __future__ import annotations import hashlib import re import uuid from collections.abc import Iterator from datetime import UTC, datetime from pathlib import Path import pytest from fastapi.testclient import TestClient from sqlalchemy import text import app.api.docs as docs_api from app.core.security_headers import CSP from app.main import app as fastapi_app from app.models import Chunk, Document from app.rag.summarizer import IMAGE_MIMES from app.rag.svg_text import extract_svg_text #: The phase-180 shared renderer — the file whose ``allowImageUrl`` #: allowlist decides whether a ``![alt](src)`` reference paints an #: ``img.md-img`` or stays inert text (the wire-contract pin below). MARKDOWN_JS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "markdown.js" #: The 1×1 PNG the raster control row stores (the ``test_docs_api.py`` #: fixture's bytes — a real PNG header so a Content-Type mix-up is #: visible in the value, not just in the length). PNG_1X1 = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" "0000000d4944415478da63fcffff3f0300050201000d5ca5e90000000049454e44ae426082" ) #: One SVG carrying BOTH its own words and a ``<script>`` sentinel. The #: script is what makes the pin meaningful: the route does NOT sanitize #: (it streams the stored bytes untouched — the viewer needs the real #: graphic), so the ONLY thing standing between a stored SVG and a #: script-capable document is the policy this module pins. SVG_WITH_SCRIPT = ( b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">' b"<title>TopologyTwo nodes, one link" b'edge router' b"" b"" ) #: The response-header KEY set both the raster and the vector serve share #: (FileResponse's own stat headers + the route's Cache-Control + the #: three the SecurityHeadersMiddleware adds). Pinned so a future change #: cannot silently drop or add a header on either branch — the vector #: branch differs from the raster branch in the VALUE of one key, never #: in the key set. SERVE_HEADER_KEYS = { "accept-ranges", "cache-control", "content-length", "content-security-policy", "content-type", "etag", "last-modified", "x-content-type-options", "x-frame-options", } def _cleanup_kb(db) -> None: """Truncate the KB tables (commit first — SQLAlchemy does NOT autoflush pending ORM objects before a raw ``text()`` statement, and a pending chunk INSERT flushed after the TRUNCATE would FK-violate).""" db.commit() db.execute(text("TRUNCATE chunks, documents")) db.commit() @pytest.fixture() def kb(db) -> Iterator: """The real Postgres with the KB tables emptied before and after the test (the module's own rows are the only ones present).""" _cleanup_kb(db) yield db _cleanup_kb(db) def _seed_image_row( db, tmp_path: Path, *, source: str = "SvgSrc", path: str = "diagram.svg", data: bytes = SVG_WITH_SCRIPT, content: str = "Topology — Two nodes, one link — edge router", image_path: str | None = "auto", ) -> Document: """One ``is_image`` document row plus its persistent copy (the importer's ``image_dir`` layout — ``/``) under *tmp_path*. ``image_path="auto"`` writes the copy at the importer's layout; ``None`` leaves the row without a copy path; any other string pins the column verbatim (the lost-copy corner writes the copy and unlinks it in the test itself).""" doc = Document( id=uuid.uuid4(), source=source, path=path, full_path=f"/tmp/{path}", title=Path(path).stem, content=content, content_hash=hashlib.sha256(data).hexdigest(), indexed_at=datetime.now(UTC), created_at=datetime(2026, 1, 1, tzinfo=UTC), summary=content, is_image=True, ) if image_path == "auto": copy = tmp_path / f"{doc.id}{Path(path).suffix}" copy.write_bytes(data) doc.image_path = str(copy) elif image_path is not None: doc.image_path = image_path db.add(doc) db.flush() db.add_all( [ Chunk( document_id=doc.id, position=0, content=content, embedding=[0.01] * 768 ), Chunk( document_id=doc.id, position=-1, content=content, embedding=[0.01] * 768, is_summary=True, ), ] ) db.commit() return doc def _seed_text_row(db, *, source: str = "TextSrc", path: str = "note.md") -> uuid.UUID: """One plain TEXT document (never ``is_image``) — a 404 shape.""" doc = Document( id=uuid.uuid4(), source=source, path=path, full_path=f"/tmp/{path}", title=Path(path).stem, content="# Note\n\nBody.", content_hash=hashlib.sha256(path.encode("utf-8")).hexdigest(), indexed_at=datetime.now(UTC), created_at=datetime(2026, 1, 1, tzinfo=UTC), ) db.add(doc) db.commit() return doc.id # --- the vector serve contract ------------------------------------------ def test_svg_row_serves_the_vector_mime_with_the_sandboxed_csp( kb, tmp_path: Path, admin_client: TestClient ) -> None: """An SVG row → 200 ``image/svg+xml`` + the sandboxed CSP + the unchanged Cache-Control, and the body is byte-equal to the stored copy (the script sentinel included — the route is a transport, not a sanitizer; ``sandbox`` blocks scripting and makes the document an opaque origin, ``default-src 'none'`` blocks every subresource).""" doc = _seed_image_row(kb, tmp_path) assert doc.image_path is not None r = admin_client.get(f"/api/documents/{doc.id}/image") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" # LOCKED A3, verbatim. assert r.headers["content-security-policy"] == "default-src 'none'; sandbox" assert r.headers["cache-control"] == "private, max-age=3600" assert r.headers["x-content-type-options"] == "nosniff" assert r.headers["x-frame-options"] == "DENY" assert r.content == SVG_WITH_SCRIPT # the EXACT stored bytes, nothing stripped assert Path(doc.image_path).read_bytes() == r.content def test_svg_route_header_key_set_is_the_serve_surface( kb, tmp_path: Path, admin_client: TestClient ) -> None: """The vector response's header KEY set equals the shared serve surface — one extra VALUE (the CSP), no extra header, and no ``content-disposition`` (the graphic must render inline).""" doc = _seed_image_row(kb, tmp_path) r = admin_client.get(f"/api/documents/{doc.id}/image") assert set(r.headers) == SERVE_HEADER_KEYS assert "content-disposition" not in r.headers def test_svg_mime_guard_is_derived_from_the_map_never_from_the_client( kb, tmp_path: Path, admin_client: TestClient ) -> None: """The guard keys on the resolved ``media_type`` — the extension map (lower-cased, from the ROW's stored path), never a client value: an upper-case ``.SVG`` copy is still vector, and an extension the map does not name still takes the generic fallback with NO sandbox.""" assert docs_api.SVG_MEDIA_TYPE == IMAGE_MIMES[".svg"] == "image/svg+xml" assert docs_api.SVG_SANDBOX_CSP == "default-src 'none'; sandbox" upper = _seed_image_row(kb, tmp_path, path="diagram.SVG", source="UpperSrc") assert upper.image_path is not None upper_copy = Path(upper.image_path) upper_copy.unlink() upper_copy = upper_copy.with_name(f"{upper.id}.SVG") upper_copy.write_bytes(SVG_WITH_SCRIPT) upper.image_path = str(upper_copy) kb.commit() r = admin_client.get(f"/api/documents/{upper.id}/image") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["content-security-policy"] == docs_api.SVG_SANDBOX_CSP unknown = _seed_image_row( kb, tmp_path, path="mystery.bin", source="UnknownSrc", data=PNG_1X1 ) r = admin_client.get(f"/api/documents/{unknown.id}/image") assert r.status_code == 200 assert r.headers["content-type"] == "application/octet-stream" # No sandbox — the policy is scoped to the vector type only. assert r.headers["content-security-policy"] == CSP # --- the raster branch is untouched ------------------------------------- def test_raster_row_response_headers_are_unchanged_by_the_svg_rule( kb, tmp_path: Path, admin_client: TestClient ) -> None: """A PNG row is served EXACTLY as before phase 181: ``image/png``, the same Cache-Control, the same header KEY set, and a CSP equal to the app-wide A20 string from ``SecurityHeadersMiddleware`` — no route-specific policy, no ``sandbox`` anywhere in it. The byte equality of the body is the raster half of "nothing changed".""" doc = _seed_image_row( kb, tmp_path, source="PngSrc", path="pic.png", data=PNG_1X1, content="A red square on a white background.", ) r = admin_client.get(f"/api/documents/{doc.id}/image") assert r.status_code == 200 assert r.headers["content-type"] == "image/png" assert r.headers["cache-control"] == "private, max-age=3600" assert r.headers["content-security-policy"] == CSP assert "sandbox" not in r.headers["content-security-policy"] assert r.headers["x-content-type-options"] == "nosniff" assert set(r.headers) == SERVE_HEADER_KEYS assert r.content == PNG_1X1 # --- the public posture and the 404 shapes (unchanged) ------------------ def test_svg_bytes_are_public_with_the_sandbox( kb, tmp_path: Path, admin_client: TestClient ) -> None: """Phase 124 LOCKED A4 stays intact for the vector type: a FRESH client (no session — the shared chat's guest) gets the same bytes and the same sandboxed CSP as a signed-in one. The policy is not an access control (the unguessable ``uuid4`` id is), so making the response safe does not make it private.""" doc = _seed_image_row(kb, tmp_path) anonymous = TestClient(fastapi_app) r = anonymous.get(f"/api/documents/{doc.id}/image") assert r.status_code == 200 assert r.headers["content-type"] == "image/svg+xml" assert r.headers["content-security-policy"] == "default-src 'none'; sandbox" assert r.content == SVG_WITH_SCRIPT r_admin = admin_client.get(f"/api/documents/{doc.id}/image") assert r_admin.status_code == 200 assert r_admin.content == r.content def test_svg_route_404_shapes(kb, tmp_path: Path, admin_client: TestClient) -> None: """Every non-servable case is still 404 ``document not found`` — including the SVG rows: an unknown uuid, a NON-uuid string (404, not 422 — a guessed id is an unknown document), a TEXT document (no bytes behind it), an image row whose ``image_path`` is NULL, and an SVG row whose copy was deleted from disk.""" text_doc_id = _seed_text_row(kb) no_copy = _seed_image_row(kb, tmp_path, path="nocopy.svg", image_path=None) lost = _seed_image_row(kb, tmp_path, path="lost.svg", source="LostSrc") assert lost.image_path is not None Path(lost.image_path).unlink() for doc_id in ( str(uuid.uuid4()), # unknown id "not-a-uuid", # unparseable id → 404, never 422 str(text_doc_id), # text document str(no_copy.id), # image row, image_path NULL str(lost.id), # image row, copy gone from disk ): r = admin_client.get(f"/api/documents/{doc_id}/image") assert r.status_code == 404, doc_id assert r.json() == {"detail": "document not found"}, doc_id # --- the content endpoint on an SVG row (task 05, the ingestion shape) --- def test_content_endpoint_serves_the_svg_row_as_a_vector_image( kb, tmp_path: Path, admin_client: TestClient ) -> None: """``GET /api/documents/content`` for an SVG row carries the four keys the viewer's vector branch needs: ``is_image: true``, an ``image_url`` of the exact ``/api/documents//image`` form (the bytes route above), ``format: "svg"`` — the ONLY place in the app that knows the type (task 04's ``doc-image-img--vector`` keys off it) — and ``content`` equal to the SVG's OWN extracted ````/``<desc>``/``<text>`` words (LOCKED A1: the row's text is what the importer stored, never raw XML re-derived here). The ``<script>`` sentinel in the stored bytes never reaches this payload — the DB text IS the extraction.""" extracted = extract_svg_text(SVG_WITH_SCRIPT, stem="diagram") assert extracted is not None and extracted.structural is False doc = _seed_image_row( kb, tmp_path, content=extracted.text, source="VecSrc", path="vector.svg" ) r = admin_client.get( "/api/documents/content", params={"source": "VecSrc", "path": "vector.svg"} ) assert r.status_code == 200 body = r.json() assert body["is_image"] is True assert body["image_url"] == f"/api/documents/{doc.id}/image" assert body["format"] == "svg" assert body["content"] == extracted.text assert body["content"] == "Topology\nTwo nodes, one link\nedge router" assert body["summary"] == extracted.text # The serve round trip: the image_url the payload advertises answers. img = admin_client.get(body["image_url"]) assert img.status_code == 200 assert img.headers["content-type"] == "image/svg+xml" assert img.content == SVG_WITH_SCRIPT # --- the wire-contract pin: allowImageUrl accepts the route (task 05) --- def _allow_image_url() -> re.Pattern[str]: """The phase-180 ``allowImageUrl`` regex, extracted verbatim from ``frontend/assets/markdown.js`` (the JS literal is valid Python syntax — one source, no hand-mirrored copy). A failed extraction fails loudly: the pin only means something if it is THE shipped allowlist.""" js = MARKDOWN_JS.read_text(encoding="utf-8") match = re.search( r"function allowImageUrl\(url\) \{\s*return (/.*?/[a-z]*)\.test\(url\)", js, re.S, ) assert match is not None, "allowImageUrl no longer has the shape this pin reads" literal = match.group(1) pattern, _, flags = literal[1:].rpartition("/") assert set(flags) <= {"i"}, f"unexpected regex flags {flags!r}" return re.compile(pattern, re.IGNORECASE if "i" in flags else 0) def test_image_url_shape_passes_the_phase_180_image_allowlist( kb, tmp_path: Path, admin_client: TestClient ) -> None: """The phases-tie-together pin: the literal ``image_url`` an SVG row advertises passes the renderer's ``allowImageUrl`` — so an answer (or a markdown document) that carries ``![alt](<image_url>)`` paints ``img.md-img`` from THIS route instead of printing the reference (phase 180 + phase 181 end to end; the E2E's ``test_markdown_image_ref_renders`` is the browser half). The sanity probe proves the extracted regex actually discriminates: the same allowlist rejects a ``javascript:`` src (LOCKED A5).""" doc = _seed_image_row(kb, tmp_path, source="PinSrc", path="wire.svg") r = admin_client.get( "/api/documents/content", params={"source": "PinSrc", "path": "wire.svg"} ) image_url = r.json()["image_url"] assert image_url == f"/api/documents/{doc.id}/image" # The literal path the renderer receives (no query, no fragment). assert re.fullmatch(r"/api/documents/[0-9a-f-]{36}/image", image_url) allow = _allow_image_url() assert allow.search(image_url), f"allowImageUrl rejected its own route: {image_url}" # The discriminating half: the allowlist is real, not "accept all". assert allow.search("javascript:alert(1)") is None assert allow.search("data:text/html,<script>") is None