All verification complete. Final state summary: **Report — phase 180 final verification pass** - Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`). - Completion criteria: - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E) - ``→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅ - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅ - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe) - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅ - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅ - Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate. - Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181. - Next pending phase: `181_svg_document_preview`.
131 lines
6.0 KiB
Python
131 lines
6.0 KiB
Python
"""Shared Playwright auth helpers (phase 16 + phase 79).
|
|
|
|
``login`` drives the REAL form login on /login.html (fill → submit →
|
|
redirect) so every story that needs the admin does exactly what a human
|
|
would — no cookie surgery. ``password=None`` uses the shared E2E admin
|
|
password (success path); pass a wrong value to drive the error state
|
|
(no redirect, ``#login-error`` role=alert visible, still anonymous).
|
|
|
|
``login_with_token`` (phase 79) drives the REAL in-app token gate on the
|
|
shell the same way (fill → submit): pass an admin-issued token for the
|
|
success contract (the gate hides, the app is interactive); call it with
|
|
the default all-zeros sentinel — or ``expect_error=True`` for a token
|
|
that USED to be valid (revocation) — for the wrong-token contract
|
|
(``#auth-gate-error`` role=alert visible, the gate stays, still
|
|
anonymous). Task 07's story suite is the first consumer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from playwright.sync_api import Page, expect
|
|
|
|
from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
|
|
|
|
DEFAULT_NEXT = "/sources.html"
|
|
|
|
#: The wrong-token sentinel (phase 79): the ``bor_`` prefix plus 32 zero
|
|
#: hex chars. The admin issues ``bor_`` + ``secrets.token_hex(16)``, so
|
|
#: an all-zeros token is never a live one — the DEFAULT call drives the
|
|
#: wrong-token contract without needing a real token at all.
|
|
WRONG_TOKEN = "bor_" + "0" * 32
|
|
|
|
|
|
def login_with_token(
|
|
page: Page,
|
|
app_url: str,
|
|
token: str = WRONG_TOKEN,
|
|
next: str = "/",
|
|
expect_error: bool | None = None,
|
|
) -> None:
|
|
"""Drive the real in-app token gate (phase 79) on the shell.
|
|
|
|
``page.goto`` the shell — the test contexts are fresh, so no
|
|
``bor.token`` is cached in localStorage and ``#auth-gate`` is the
|
|
visible surface — then fill ``#auth-gate-input`` with ``token`` and
|
|
submit the gate's form:
|
|
|
|
* success (an explicitly-passed valid, unrevoked token — with
|
|
``expect_error`` left ``None`` the branch is derived from the
|
|
all-zeros ``WRONG_TOKEN`` sentinel) → ``#auth-gate`` hides and
|
|
the app is interactive (the composer is reachable) at ``next``;
|
|
* wrong / revoked (``expect_error=True``, or the sentinel default)
|
|
→ ``#auth-gate-error`` (role=alert) is visible, the gate stays
|
|
visible — the visible gate IS the anonymity proof, the UI mirror
|
|
of ``login``'s wrong-password branch — and the visitor is still
|
|
anonymous.
|
|
"""
|
|
page.goto(app_url + next)
|
|
gate = page.locator("#auth-gate")
|
|
expect(gate).to_be_visible(timeout=30_000)
|
|
page.fill("#auth-gate-input", token)
|
|
page.click("#auth-gate-form button")
|
|
error_expected = token == WRONG_TOKEN if expect_error is None else expect_error
|
|
if error_expected:
|
|
error = page.locator("#auth-gate-error")
|
|
expect(error).to_have_attribute("role", "alert")
|
|
expect(error).to_be_visible(timeout=15_000)
|
|
expect(gate).to_be_visible() # the gate is the anonymity proof
|
|
return
|
|
expect(gate).to_be_hidden(timeout=30_000)
|
|
expect(page.locator("#message-input")).to_be_visible() # app is interactive
|
|
expect(page).to_have_url(app_url + next)
|
|
|
|
|
|
def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None:
|
|
"""Perform the real form login and wait for its outcome.
|
|
|
|
* correct password (or ``password=None`` → the shared admin password)
|
|
→ redirects to ``next`` (default ``/sources.html``);
|
|
* wrong password → ``#login-error`` (role=alert) is visible, the URL
|
|
never changes, and the visitor is still anonymous.
|
|
|
|
Idempotent (phase 79): in a context that is ALREADY signed in the
|
|
login page boots straight to ``next`` (login.js's whoami redirect —
|
|
no form) and the call returns as a success; a test may sign in once
|
|
per context and call it again from a later section without breaking.
|
|
"""
|
|
attempt = ADMIN_PASSWORD if password is None else password
|
|
url = f"{app_url}/login.html"
|
|
if next is not None:
|
|
url += f"?next={next}"
|
|
page.goto(url)
|
|
if attempt == ADMIN_PASSWORD:
|
|
# Phase 79 (task 04): the helper must be IDEMPOTENT — a context
|
|
# that is ALREADY signed in never sees the form (login.js's
|
|
# boot whoami redirect goes straight to `next`), and a test that
|
|
# signs in twice (two sections, one context) must not break on
|
|
# the second call.
|
|
try:
|
|
expect(page.locator("#login-password")).to_be_visible(timeout=2_000)
|
|
except AssertionError:
|
|
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
|
|
_wait_for_app_boot(page)
|
|
return
|
|
else:
|
|
expect(page.locator("#login-password")).to_be_visible()
|
|
page.fill("#login-password", attempt)
|
|
page.click("#login-form button[type=submit]")
|
|
if attempt != ADMIN_PASSWORD:
|
|
expect(page.locator("#login-error")).to_be_visible(timeout=15_000)
|
|
expect(page).to_have_url(url) # no redirect on failure
|
|
return
|
|
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
|
|
_wait_for_app_boot(page)
|
|
|
|
|
|
def _wait_for_app_boot(page: Page) -> None:
|
|
"""Wait until the landed page's deferred module graph has EXECUTED.
|
|
|
|
``to_have_url`` passes the moment the redirect navigation COMMITS, while
|
|
``document.readyState`` is still ``interactive`` — the page's
|
|
``<script type="module">`` (app.js plus everything it imports) can still
|
|
be in flight, so a handler the page wires up in JS does not exist yet.
|
|
A caller that submits straight away (``#composer.requestSubmit()`` in the
|
|
thinking suites) then fires the form's NATIVE GET submit: the browser
|
|
navigates to ``/?message=…``, the conversation is never sent, and the
|
|
story's first assertion fails against an empty chat. ``load`` fires after
|
|
every deferred module has run, so this makes an interactive page a real
|
|
precondition of ``login`` for every suite — and it is a no-op once the
|
|
page has settled, so no suite pays a wait twice.
|
|
"""
|
|
page.wait_for_load_state("load")
|