phase: 181_svg_document_preview
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s

**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**

- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
This commit is contained in:
2026-10-14 22:54:43 -04:00
parent 6880bd0866
commit 814a400e6d
70 changed files with 4460 additions and 52 deletions
@@ -0,0 +1,443 @@
"""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 ``<img>`` 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/<uuid>/image``, ``format: "svg"`` and the extracted
``<title>``/``<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>Topology</title><desc>Two nodes, one link</desc>"
b'<text x="8" y="12">edge router</text>'
b"<path d='M10 60 L110 20'/>"
b"<script>alert('probe')</script></svg>"
)
#: 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 — ``<image_dir>/<doc-id><ext>``)
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/<uuid>/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
``<title>``/``<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