Files
brain-of-reese/tests/e2e/test_cache_busting.py
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

220 lines
8.7 KiB
Python

"""Phase 33 E2E (Playwright): cache busting — what the browser actually
receives and requests.
Story: ``.agents/user_stories/cache-busting.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_cache_busting.py -v --no-cov
The assertions are against the wire: the HTML document responses carry
``Cache-Control: no-cache``; every asset request URL the browser actually
makes carries ``?v=<token>`` (one token per process — the git short SHA of
this checkout, i.e. the deploy); the asset responses are immutable for a
year; and the API — the SSE chat stream in particular — is untouched. The
mock LLM keeps the SSE check deterministic (no live aipi).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
import httpx
from playwright.sync_api import Page
from e2e.auth_helpers import ADMIN_PASSWORD, login
REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
def _expected_token() -> str:
"""The token the app process appends to its asset URLs.
Computed exactly the way the app does (``asset_version`` over the same
static dir): the git short SHA of this checkout in a git repo (a commit
is a deploy), so the browser's asset requests must carry it.
"""
from app.core.caching import asset_version
return asset_version(str(REPO / "frontend"))
def _version_token(url: str) -> str:
"""Extract the ``?v=`` token from a versioned asset URL (asserts one)."""
assert "?v=" in url, f"asset request is not versioned: {url}"
return url.rsplit("?v=", 1)[1]
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends.
Phase 79: POST /api/chat is require_user-gated — the httpx client
signs in as the admin first (the middleware under test never touches
the auth contract; this is purely the request's credentials)."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204
frames: list[dict[str, Any]] = []
with client.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
) as r:
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
return frames
def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None:
"""`/`: the document revalidates (no-cache); the CSS/JS request URLs
the browser actually makes carry the process token; the asset
responses are immutable for a year; the served HTML carries no
unversioned asset references."""
token = _expected_token()
assert token, "the version token must be non-empty"
with (
page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info,
page.expect_response(lambda r: "/assets/app.js" in r.url) as js_info,
):
doc = page.goto(app_url)
# The document: always revalidated, never served from cache unchecked.
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
# CSS: versioned request URL + immutable-for-a-year response.
css = css_info.value
assert _version_token(css.url) == token
css_cc = css.headers["cache-control"]
assert "immutable" in css_cc
assert "max-age=31536000" in css_cc
# JS: the SAME token (one per process — the URL identifies the
# content, which is what makes the 1-year cache safe).
assert _version_token(js_info.value.url) == _version_token(css.url)
# The served HTML carries the versioned reference and no unversioned
# one (the "sticky" reference is gone from the page the browser sees).
html = page.content()
assert f'href="/assets/styles.css?v={token}"' in html
assert '/assets/styles.css"' not in html
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
"""/sources.html, /login.html, /history.html (phase 50) and
/doc-edit.html (phase 59): each document revalidates, and all four
pages' stylesheet requests carry the same process token."""
token = _expected_token()
assert token
def navigate(path: str) -> str:
with page.expect_response(
lambda r: "/assets/styles.css" in r.url
) as css_info:
doc = page.goto(f"{app_url}{path}")
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
return _version_token(css_info.value.url)
sources_token = navigate("/sources.html")
login_token = navigate("/login.html")
history_token = navigate("/history.html") # phase 50: the new page
docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen
assert sources_token == login_token == history_token == docedit_token == token
def test_shared_page_is_no_cache_and_versioned(
page: Page, app_url: str, db_ready: None
) -> None:
"""`/shared/<token>` (phase 51, the dynamic share page): the same
contract as the static HTML pages — the document revalidates
(no-cache) and the served HTML's asset refs are `?v=`-tagged (the
middleware's prefix extension, task 01). The chat is created +
shared via the admin API for the test."""
token = _expected_token()
assert token, "the version token must be non-empty"
login(page, app_url, next="/")
cookies = _admin_cookies(page)
r = httpx.post(
f"{app_url}/api/chats",
json={
"messages": [
{"who": "user", "text": "cache-busting shared-page probe"},
{"who": "brain", "text": "Shared for the cache contract."},
],
"share": True,
},
timeout=10,
cookies=cookies,
)
assert r.status_code == 201
body = r.json()
assert SHARE_URL_RE.fullmatch(body["share_url"]), body["share_url"]
try:
with page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info:
doc = page.goto(app_url + body["share_url"])
# The document: always revalidated, like every HTML page.
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
# The CSS request the browser actually makes carries the
# process token…
assert _version_token(css_info.value.url) == token
# …and the served HTML references its assets versioned (the
# page uses ABSOLUTE /assets refs — required for the nested
# /shared/<token> path).
html = page.content()
assert f'href="/assets/styles.css?v={token}"' in html
assert f'src="/assets/brand.js?v={token}"' in html
assert f'src="/assets/markdown.js?v={token}"' in html
assert f'src="/assets/shared.js?v={token}"' in html
# No unversioned reference survives the rewrite.
assert 'href="/assets/styles.css"' not in html
assert 'src="/assets/shared.js"' not in html
finally:
httpx.delete(f"{app_url}/api/chats/{body['id']}", timeout=10, cookies=cookies)
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
"""`/api/*` passes through untouched: no injected Cache-Control on the
health endpoint, and the SSE chat stream still streams to done."""
r = page.request.get(f"{app_url}/api/health")
assert r.status == 200
# Baseline (pre-middleware) behavior: FastAPI's JSON responses ship no
# Cache-Control header — the middleware must not inject one.
assert "cache-control" not in r.headers
# The SSE contract (PLAN §4) survives the middleware: deltas, then a
# final done — the stream is neither read nor rewritten by it.
frames = _stream_chat_frames(app_url, CHAT_QUESTION)
assert frames, "the SSE stream must deliver events"
assert any(f["type"] == "delta" for f in frames), "answer must be streamed"
assert frames[-1]["type"] == "done", "the stream must complete with done"