feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table

This commit is contained in:
2026-08-22 02:08:49 -04:00
parent 7e8d14702e
commit 6ec6181c7b
15 changed files with 1218 additions and 58 deletions
+268
View File
@@ -0,0 +1,268 @@
"""Unit: document viewer (phase 10).
Python side:
* ``doc_format`` — format from the path suffix (incl. ``.markdown`` and the
no-suffix fallback);
* the content endpoint's 200/404 mapping — tested WITHOUT a database by
stubbing the session via FastAPI's dependency override (unknown pairs and
traversal-style paths map to 404 ``{detail: "document not found"}``;
known pairs map to the full ``DocContent`` shape).
Frontend side:
* the viewer URL builder — its real query-encoding behavior (paths with
spaces/slashes) executed under node when available, plus source pins that
run everywhere;
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
loaded by BOTH pages via a relative ``<script src>`` before the module
scripts.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.api.docs import doc_format
from app.db import get_db
from app.main import create_app
from app.models import Document
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
HAVE_NODE = shutil.which("node") is not None
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# doc_format — format-from-suffix (phase 10, PLAN §4)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("path", "expected"),
[
("kubernetes.md", "md"),
("notes/sub/deep.markdown", "markdown"),
("NOTES/ARCHIVE.MD", "md"),
("homelab/container_gitlab/gitlab-compose.yaml", "yaml"),
("homelab/networking/static-dns.json", "json"),
("homelab/scripts/uptime_probe.py", "py"),
("homelab/ssh/ssh_aliases.txt", "txt"),
("noext", "text"), # no suffix → fallback
("a/b", "text"), # no suffix → fallback
],
)
def test_doc_format_from_suffix(path: str, expected: str) -> None:
assert doc_format(path) == expected
# ---------------------------------------------------------------------------
# Content endpoint mapping — stubbed session, no database required
# ---------------------------------------------------------------------------
class _FakeResult:
def __init__(self, row: object) -> None:
self._row = row
def first(self) -> object:
return self._row
class _FakeSession:
def __init__(self, row: object) -> None:
self._row = row
def execute(self, _stmt: object) -> _FakeResult:
return _FakeResult(self._row)
def _client_with_row(row: object) -> TestClient:
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
(``None`` → no matching document row)."""
app = create_app()
app.dependency_overrides[get_db] = lambda: _FakeSession(row)
return TestClient(app)
def test_content_unknown_pair_maps_to_404() -> None:
with _client_with_row(None) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "nope.md"},
)
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
def test_content_traversal_style_path_maps_to_404() -> None:
"""``../``-style values are just non-existent rows → 404, never a
file read (DB-only endpoint, no filesystem access)."""
with _client_with_row(None) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "../../etc/passwd"},
)
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
assert "root:" not in r.text # nothing leaked
def test_content_known_pair_maps_to_doc_content() -> None:
doc = Document(
source="Homelab",
path="notes/deep mark.md",
full_path="/tmp/deep mark.md",
title="Deep Mark",
content="# Deep Mark\n\nbody",
content_hash="f" * 64,
)
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
with _client_with_row((doc, 3)) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "notes/deep mark.md"},
)
assert r.status_code == 200
body = r.json()
assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
assert body["source"] == "Homelab"
assert body["path"] == "notes/deep mark.md"
assert body["title"] == "Deep Mark"
assert body["format"] == "md"
assert body["content"] == "# Deep Mark\n\nbody"
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
assert body["chunks"] == 3
def test_content_requires_both_params() -> None:
with _client_with_row(None) as client:
assert client.get("/api/documents/content").status_code == 422
assert client.get("/api/documents/content", params={"source": "Homelab"}).status_code == 422
# ---------------------------------------------------------------------------
# Viewer URL builder — encoded query (spaces/slashes in real paths)
# ---------------------------------------------------------------------------
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
"""Both entry points (chat chips, Sources rows) build the same
encoded viewer URL and open it in a new tab with rel=noopener."""
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert "function documentUrl(source, path)" in js, name
assert '"/document.html?source=" + encodeURIComponent(' in js, name
assert '"&path=" + encodeURIComponent(' in js, name
app_js = _read(APP_JS)
assert "chip.href = documentUrl(s.source, s.path)" in app_js
assert 'chip.target = "_blank"' in app_js
assert 'chip.rel = "noopener"' in app_js
sources_js = _read(SOURCES_JS)
assert 'link.className = "doc-link"' in sources_js
assert "link.href = documentUrl(d.source, d.path)" in sources_js
assert 'link.target = "_blank"' in sources_js
assert 'link.rel = "noopener"' in sources_js
# The full path stays the hover name on the ellipsized cell AND the link.
assert "pathTd.title = d.path" in sources_js
assert "link.title = d.path" in sources_js
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _extract_function(js: str, name: str) -> str:
match = re.search(rf"(?:export )?function {name}\(source, path\) \{{.*?\n\}}", js, re.S)
assert match, f"{name}(source, path) not found"
return match.group(0).replace("export ", "", 1)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_viewer_url_builder_encodes_spaces_and_slashes() -> None:
"""Behavioral check of the real builder (app.js) under node: slashes
and spaces in source/path values must come out percent-encoded."""
fn = _extract_function(_read(APP_JS), "documentUrl")
out = _run_node(
f"{fn}\n"
"console.log(documentUrl('Homelab', 'kubernetes.md'));\n"
"console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n"
"console.log(documentUrl('H omelab', 'a/b.md'));"
)
assert out.splitlines() == [
"/document.html?source=Homelab&path=kubernetes.md",
"/document.html?source=Homelab&path=notes%2Fmy%20file.yaml",
"/document.html?source=H%20omelab&path=a%2Fb.md",
]
# ---------------------------------------------------------------------------
# Shared renderer extraction (phase 10 step 2)
# ---------------------------------------------------------------------------
def test_renderer_extracted_to_shared_markdown_js() -> None:
"""The renderer moved to assets/markdown.js (not duplicated in app.js)
and BOTH pages load it via a relative <script src> before their module
scripts — so the globals exist when app.js/document.js run."""
md = _read(MARKDOWN_JS)
assert "function renderMarkdown(md)" in md
assert "function escapeHtml(s)" in md
app_js = _read(APP_JS)
assert "function renderMarkdown" not in app_js, "renderer must live in markdown.js"
assert "function escapeHtml" not in app_js
for page in ("index.html", "document.html"):
html = _read(FRONTEND / page)
assert re.search(r'<script src="assets/markdown\.js"></script>', html), (
f"{page} must load markdown.js via a relative <script src>"
)
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
f"{page}: markdown.js must load before the module script"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
"""Behavioral check (node) that the extracted renderer still escapes
first: hostile content never becomes live HTML; basic transforms work."""
js = _read(MARKDOWN_JS)
out = _run_node(
js
+ "\nconsole.log(renderMarkdown('# Title\\n\\n<script>alert(1)</script>"
+ "\\n\\n**bold** and `code`'));"
)
html = out.strip()
assert "<script>" not in html # never live HTML
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
assert "<strong>bold</strong>" in html
assert "<code>code</code>" in html
assert "<h3>Title</h3>" in html
def test_viewer_js_rendering_contracts() -> None:
"""document.js: raw formats go in via textContent (never parsed as
HTML), markdown via the shared renderer, 404 → designed not-found
state, back link prefers browser history when there is one."""
js = _read(DOCUMENT_JS)
assert "pre.textContent = doc.content" in js # raw formats: text node
assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer
assert "showNotFound" in js
assert "history.length > 1" in js
assert "encodeURIComponent" in js # content fetch uses the same encoding