Files
brain-of-reese/tests/e2e/test_document_back_navigation.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

218 lines
8.8 KiB
Python

"""Phase 13 E2E (Playwright): the viewer's back button returns to the
page the document was opened from.
Story: ``.agents/user_stories/document-back-navigation.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_back_navigation.py -v --no-cov
The viewer can be reached directly (no browser history to go back to),
so the return target is carried in the viewer URL: chat chips append
``&back=%2F`` (resolves to "Chat"), Sources links omit the param (the
viewer's default ``/sources.html`` applies → "Sources"). The viewer only
honors same-origin relative ``back`` values; everything else falls back
to ``/sources.html``.
Phase 26 adaptation: the chip/row-link LEFT click now opens the
document in the same-page modal — no new tab is spawned. The encoded
viewer URL survives as each link's ``href`` (the no-JS / context-menu
"open in new tab" escape hatch), so the back contract is asserted on
that exact href and verified by navigating to it directly.
Test → story mapping (Playwright Mapping Rule):
1. ``test_back_from_chat_returns_to_chat`` — question → source chip href
(carries ``&back=%2F``) → viewer back link href ``/`` labeled "Chat"
→ click → the chat page.
2. ``test_back_from_sources_returns_to_sources`` — Sources table link
href (no ``back`` param) → back link href ``/sources.html`` labeled
"Sources" → click → the Sources page.
3. ``test_malicious_back_param_is_rejected`` — absolute,
protocol-relative, and ``javascript:`` ``back`` values all fall back
to ``/sources.html`` (labeled "Sources", navigable).
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
# Seeded fixture doc (source=docs) shared by every test in this file.
DOC_SOURCE = "docs"
DOC_PATH = "homelab%2Fkubernetes.md"
DOC_TITLE = "Kubernetes Homelab Cluster"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# 1. Chat source chip → viewer with back=/ → back returns to the chat
# ---------------------------------------------------------------------------
def test_back_from_chat_returns_to_chat(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat AND the viewer content are gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
# Chat chips carry back=/ (encoded %2F) so the viewer knows where
# home is. Phase 26: the left click opens the same-page modal (no
# target=_blank); this href is what the no-JS / context-menu "open
# in a new tab" path reaches, so the back contract rides on it.
expect(chip.first).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
)
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
# The exact href asserted above (the no-JS / new-tab escape hatch).
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
expect(page).to_have_url(
re.compile(
re.escape(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
)
)
# The cited document actually rendered (this is the viewer, not an error).
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link resolved to the chat page, labeled "Chat".
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/")
expect(back).to_have_text("Chat")
# Click: deterministic anchor navigation back to the chat page.
back.click()
expect(page).to_have_url(f"{app_url}/")
expect(page.locator("#composer")).to_be_visible()
# ---------------------------------------------------------------------------
# 2. Sources table link → viewer without back param → back returns to Sources
# ---------------------------------------------------------------------------
def test_back_from_sources_returns_to_sources(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources table is admin-only
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Sources links carry NO back param — the viewer's default target
# (/sources.html) applies. Phase 26: left click opens the modal;
# the href (no back param) is the no-JS / new-tab escape hatch.
expect(link).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
)
expect(link).not_to_have_attribute("target") # phase 26: modal, not a new tab
# The exact href asserted above — no back param in the URL.
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}")
assert "back=" not in page.url, f"unexpected back param: {page.url}"
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link kept the default target, labeled "Sources".
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back).to_have_text("Sources")
back.click()
expect(page).to_have_url(f"{app_url}/sources.html")
expect(page.locator("#docs-table")).to_be_visible()
# ---------------------------------------------------------------------------
# 3. Hostile back values (absolute, protocol-relative, pseudo-protocol)
# are all rejected in favor of the same-origin default
# ---------------------------------------------------------------------------
def test_malicious_back_param_is_rejected(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url, next="/") # phase 79: the viewer content is gated
errors: list[str] = []
dialogs: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
def _catch_dialog(d) -> None: # a fired dialog == executed script
dialogs.append(d.message)
d.dismiss()
page.on("dialog", _catch_dialog)
viewer_base = f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
# Anything that is not a same-origin relative URL must be rejected:
# an absolute https URL, a protocol-relative URL, and a javascript:
# pseudo-protocol.
for evil in ("https%3A%2F%2Fevil.com", "%2F%2Fevil.com", "javascript%3Aalert(1)"):
page.goto(f"{viewer_base}&back={evil}")
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back).to_have_text("Sources")
# And the fallback is really navigable: clicking lands on Sources.
page.click("#doc-back")
expect(page).to_have_url(f"{app_url}/sources.html")
assert dialogs == [], f"dialog fired — a back param escaped validation: {dialogs}"
assert errors == [], f"console crashes: {errors}"