phase: 181_svg_document_preview
**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**; ⑤`` 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:
@@ -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 ```` 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 ```` 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 ```` 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
|
||||
Reference in New Issue
Block a user