185 lines
7.9 KiB
Python
185 lines
7.9 KiB
Python
"""US09-01: the documentation the application serves, checked without a browser.
|
|
|
|
What is checkable offline is everything that makes the manuals *navigable* rather
|
|
than merely present: every page reachable, every internal link and anchor landing
|
|
somewhere real, the vendored renderer being the file that was pinned, and the
|
|
application actually serving `docs/` in both the working copy and the image.
|
|
|
|
The rendering itself — marked, mermaid, anchors, link rewriting — is proven in a
|
|
real browser by ``tests/e2e/test_docs_ui.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from starlette.testclient import TestClient
|
|
|
|
from photo_pipeline.api.app import create_app
|
|
from photo_pipeline.api.security import DEFAULT_HEADERS
|
|
from photo_pipeline.config import Config
|
|
|
|
# Every check here belongs to the documentation gate (US09-05).
|
|
pytestmark = pytest.mark.phase_i
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
DOCS = REPO / "docs"
|
|
VENDOR = REPO / "frontend" / "js" / "vendor"
|
|
INDEX = DOCS / "index.md"
|
|
|
|
MARKDOWN_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
|
|
FENCE = re.compile(r"^```.*?^```", re.MULTILINE | re.DOTALL)
|
|
|
|
|
|
def pages() -> list[Path]:
|
|
return sorted(DOCS.rglob("*.md"))
|
|
|
|
|
|
def body(path: Path) -> str:
|
|
"""A document without its fenced code, so an example link in a shell snippet is
|
|
not mistaken for a link the reader can follow."""
|
|
return FENCE.sub("", path.read_text())
|
|
|
|
|
|
def slug(text: str) -> str:
|
|
"""The heading-anchor rule, mirroring ``frontend/js/docs.js``.
|
|
|
|
Deliberately duplicated: this check has to run without a browser. The browser
|
|
test is the authority on the real behaviour, and it asserts the same anchors.
|
|
"""
|
|
cleaned = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE).strip()
|
|
return re.sub(r"[\s-]+", "-", cleaned).strip("-") or "section"
|
|
|
|
|
|
def anchors(path: Path) -> set[str]:
|
|
found: dict[str, int] = {}
|
|
for heading in HEADING.findall(body(path)):
|
|
# Markdown emphasis and inline code are not part of the rendered text.
|
|
plain = re.sub(r"[*`_]", "", heading)
|
|
base = slug(plain)
|
|
found[base] = found.get(base, 0) + 1
|
|
return {name if index == 1 else f"{name}-{index}" for name, count in found.items() for index in range(1, count + 1)}
|
|
|
|
|
|
def internal_links(path: Path) -> list[str]:
|
|
return [
|
|
href
|
|
for href in MARKDOWN_LINK.findall(body(path))
|
|
if not re.match(r"^[a-z][a-z\d+.-]*:", href, re.IGNORECASE) and not href.startswith("//")
|
|
]
|
|
|
|
|
|
# ── the documentation tree ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_index_exists_and_names_every_page():
|
|
"""A page nobody links to is a page nobody reads."""
|
|
assert INDEX.is_file()
|
|
listed = {
|
|
(INDEX.parent / href.split("#")[0]).resolve()
|
|
for href in internal_links(INDEX)
|
|
if href.split("#")[0].endswith(".md")
|
|
}
|
|
unreachable = [page.name for page in pages() if page != INDEX and page.resolve() not in listed]
|
|
assert unreachable == [], f"not linked from the index: {unreachable}"
|
|
|
|
|
|
def test_every_internal_link_and_anchor_resolves():
|
|
broken: list[str] = []
|
|
for page in pages():
|
|
for href in internal_links(page):
|
|
target, _, anchor = href.partition("#")
|
|
destination = page if not target else (page.parent / target).resolve()
|
|
if not destination.is_file():
|
|
broken.append(f"{page.name} → {href} (no such file)")
|
|
continue
|
|
if anchor and anchor not in anchors(destination):
|
|
broken.append(f"{page.name} → {href} (no such heading)")
|
|
assert broken == [], broken
|
|
|
|
|
|
def test_every_page_starts_with_one_title():
|
|
for page in pages():
|
|
titles = [line for line in page.read_text().splitlines() if line.startswith("# ")]
|
|
assert len(titles) == 1, f"{page.name} has {len(titles)} top-level titles"
|
|
|
|
|
|
# ── the vendored renderer ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_vendored_libraries_are_the_files_that_were_pinned():
|
|
"""A vendored dependency without a recorded checksum is a dependency nobody is
|
|
reviewing. Replacing one has to be a visible change to this manifest."""
|
|
manifest = json.loads((VENDOR / "VERSIONS.json").read_text())
|
|
recorded = {entry["file"]: entry for entry in manifest["libraries"]}
|
|
|
|
on_disk = {path.name for path in VENDOR.iterdir() if path.suffix in (".js", ".mjs")}
|
|
assert on_disk == set(recorded), f"unrecorded vendored files: {on_disk ^ set(recorded)}"
|
|
|
|
for name, entry in recorded.items():
|
|
digest = hashlib.sha256((VENDOR / name).read_bytes()).hexdigest()
|
|
assert digest == entry["sha256"], f"{name} does not match the pinned checksum"
|
|
assert entry["version"] in entry["url"], f"{name}: the pinned URL and version disagree"
|
|
assert entry["license"], f"{name}: no license recorded"
|
|
|
|
|
|
def test_the_documentation_view_is_wired_into_the_shell():
|
|
shell = (REPO / "frontend" / "index.html").read_text()
|
|
assert 'data-nav="docs"' in shell, "the manuals are unreachable from the navigation"
|
|
assert '"/docs"' in (REPO / "frontend" / "js" / "app.js").read_text()
|
|
|
|
|
|
# ── the policy the view runs under ───────────────────────────────────────────
|
|
|
|
|
|
def test_the_script_boundary_is_unchanged_and_only_style_was_relaxed():
|
|
"""The measured cost of rendering diagrams in the browser, held to that cost.
|
|
|
|
mermaid needs to style the SVG it builds, which `style-src 'self'` refuses. It
|
|
does not need to execute generated code, so `script-src` must never acquire the
|
|
escape hatch that would let injected markup run.
|
|
"""
|
|
policy = DEFAULT_HEADERS["content-security-policy"]
|
|
directives = {
|
|
part.split(" ")[0]: part.split(" ")[1:]
|
|
for part in (piece.strip() for piece in policy.split(";"))
|
|
if part
|
|
}
|
|
assert directives["script-src"] == ["'self'"]
|
|
assert "'unsafe-inline'" in directives["style-src"]
|
|
for directive in ("default-src", "img-src", "connect-src", "font-src"):
|
|
assert "'self'" in directives[directive]
|
|
assert "'unsafe-inline'" not in directives[directive]
|
|
assert directives["frame-ancestors"] == ["'none'"]
|
|
|
|
|
|
# ── serving it ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_application_serves_the_markdown_without_a_session(tmp_path):
|
|
"""Whoever cannot get past the access secret is exactly who needs these pages."""
|
|
config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")})
|
|
(tmp_path / "data").mkdir()
|
|
with TestClient(create_app(config), raise_server_exceptions=False) as client:
|
|
response = client.get("/docs/index.md", headers={"cookie": ""})
|
|
assert response.status_code == 200
|
|
assert "Photo Pipeline documentation" in response.text
|
|
assert client.get("/docs/overview.md").status_code == 200
|
|
assert client.get("/docs/nothing-here.md").status_code == 404
|
|
# The mount is a directory, not a path parameter: nothing above it is reachable.
|
|
assert client.get("/docs/../pyproject.toml").status_code in (307, 404)
|
|
assert client.get("/docs/%2e%2e/pyproject.toml").status_code == 404
|
|
|
|
|
|
def test_the_image_ships_the_documentation_it_serves():
|
|
"""An image without `docs/` serves an empty manual — and the build context is
|
|
deny-by-default, so a new directory is excluded until it is named."""
|
|
assert "\n!docs\n" in (REPO / ".dockerignore").read_text()
|
|
assert "COPY docs ./docs" in (REPO / "Dockerfile").read_text()
|