feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+70
View File
@@ -16,14 +16,18 @@ 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 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:
@@ -44,6 +48,16 @@ def _version_token(url: str) -> str:
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."""
@@ -123,6 +137,62 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
assert sources_token == login_token == history_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."""
+498
View File
@@ -0,0 +1,498 @@
"""Phase 51 E2E (Playwright): share a chat by link — anonymous view.
TODO.md L6 (owner 2026-08-29): "Need a way to share a chat with a link
so others can see it anonymously."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_share_chat.py -v --no-cov
The owner-locked loop under test (2026-08-29, roadmap confirmation):
* **Share from the chat page** — the Share pill (admin-only, ships
hidden) on an UNSAVED conversation saves AND shares in ONE action
(``POST /api/chats`` with ``share: true`` — the row appears in
``GET /api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>``); the absolute URL is copied to the clipboard,
with the inline-link fallback on a non-secure origin (the assertion
branches on ``navigator.clipboard`` availability);
* **Anonymous view** — a FRESH browser context (a separate session, no
cookies) opening ``/shared/<token>`` sees the full conversation
read-only through the same record shape: title = the auto-title,
user + brain bubbles (the same deterministic answer text the admin
session saw), the thinking block RESTORED COLLAPSED, the source
chips as PLAIN TEXT (zero ``a.source-chip`` — guests cannot open
documents, the documents API is admin-only), and ZERO interactive
controls anywhere (no composer, no Save/Share pills, no Tune/Retry,
no button chips); the nav's admin-only links stay hidden for a
guest;
* **Share from History + unshare** — the History row's Share column:
"Create link" → Copy + Unshare; Unshare is the inline two-step
(no native dialog); after Yes the cell returns to "Create link",
the SAME URL now shows the "invalid or was revoked" state in a
fresh anonymous context, and ``GET /api/chats/<id>`` no longer
carries ``share_url`` (the omission rule — the key is absent, not
null);
* **Bad token** — a well-formed but unknown token renders the invalid
state with no JS crash and the guest header (the page route serves
the HTML for any well-formed token; the client's 404 read drives
the invalid card).
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows
across suites, so every test here uses a DISTINCTIVE question text
(its auto-title is therefore unique), never asserts on absolute row
counts, and deletes the rows it creates in a ``finally`` (admin
cookie). The KB tables are truncated + re-seeded the house way
(deterministic mock embeddings); ``saved_chats`` is never touched by
the reset.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, 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"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
#: The invalid/revoked card's line (frontend/shared.html, task 03).
INVALID_TEXT = "This share link is invalid or was revoked."
BAD_TOKEN = "00000000-0000-4000-8000-000000000000"
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 + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
is deliberately NOT touched: rows persist across suites and every
test here cleans up after itself."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes")
)
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
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 (the test's API side
sees exactly what the signed-in browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(
rows: list[dict[str, Any]], title: str
) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context.
``http://127.0.0.1`` is a secure context, so ``navigator.clipboard``
exists — but headless Chromium still requires the permission grant
before ``writeText`` resolves (without it the owner-locked
inline-link fallback fires). The assertion below branches on the
API's availability, so a non-secure origin still passes through
the fallback branch deterministically.
"""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the owner-locked
outcome on the live region: "Share link copied." when
``navigator.clipboard`` is available in the context, else the
inline fallback link field carrying the ``/shared/<uuid>`` URL.
The branch is on the API's availability (task spec)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
expect(page.locator(".share-link-fallback")).to_have_count(0)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# ---------------------------------------------------------------------------
# 1. Share from the chat page: an UNSAVED conversation is saved + shared
# in one action; the API row carries the /shared/<uuid> link
# ---------------------------------------------------------------------------
def test_share_from_chat_page(
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="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (share-chat)"
_ask(page, q)
# Admin: the Share pill is revealed (ships hidden, whoami reveals
# it — the same block as Save). The conversation is UNSAVED at this
# point: no row exists yet under the auto-title.
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
cookies = _admin_cookies(page)
assert (
_find_row(_chats(app_url, cookies), _auto_title(q)) is None
), "the conversation is unsaved before the Share click"
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
created: str | None = None
try:
# The API agrees: ONE action saved AND shared — the new row
# exists with a non-null share_url of the token shape.
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the Share click must have saved the conversation"
assert row["message_count"] == 2, "the saved conversation holds both messages"
share_url = row.get("share_url")
assert share_url is not None, "the share_url must be present (non-null)"
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
created = row["id"]
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
# ---------------------------------------------------------------------------
# 2. The anonymous shared view: a FRESH context (no cookies) sees the
# full conversation read-only — thinking collapsed, plain-text chips,
# zero controls, the guest header
# ---------------------------------------------------------------------------
def test_anonymous_shared_view(
page: Page,
browser: Browser,
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="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# "think out loud" → the mock streams reasoning first, so the saved
# record (and the shared view) carries a thinking block.
q = "think out loud — how is my kubernetes cluster set up? (share-view)"
_ask(page, q)
# The answer text the admin session saw — the shared view must
# render the SAME deterministic text (same record, same renderer).
answer = page.locator(".msg.brain .bubble").first.inner_text()
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None and row.get("share_url")
share_url: str = row["share_url"]
created: str | None = row["id"]
anon_ctx: BrowserContext | None = None
try:
# A FRESH context: a separate session with no cookies — the
# guest's only credential is the token in the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
# Title = the auto-title (the shared chat's h1).
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
# The conversation rendered: the user question bubble + the
# brain answer with the SAME deterministic text the admin saw.
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
bubble = anon.locator(".msg.brain .bubble").first
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
assert bubble.inner_text() == answer, "the shared answer must match the admin's"
# The thinking block exists and is RESTORED COLLAPSED (the
# phase-17 restore convention — a closed <details> has no
# `open` attribute).
think = anon.locator("details.thinking")
expect(think).to_have_count(1)
expect(think.first).not_to_have_attribute("open")
# Source chips are PLAIN TEXT: the on-topic turn carries its
# source chips (top-2 docs — the hybrid retrieval), but every
# one as a <span>: zero <a.source-chip> anywhere (a guest
# cannot open documents; the documents API is admin-only).
assert (
anon.locator(".msg.brain .source-chip").count() >= 1
), "the grounded turn must carry its source chips"
expect(
anon.locator(".msg.brain .source-chip", has_text="kubernetes.md")
).to_have_count(1)
expect(anon.locator("a.source-chip")).to_have_count(0)
# ZERO interactive controls anywhere in the conversation: no
# composer, no Save/Share pills, no Tune/Retry, and (were the
# turn deflected) the "Maybe try" chips would be spans — never
# buttons.
expect(anon.locator("#composer")).to_have_count(0)
expect(anon.locator("#save-chat-btn, #share-chat-btn")).to_have_count(0)
expect(anon.locator(".tune-btn")).to_have_count(0)
expect(anon.locator(".retry-btn")).to_have_count(0)
expect(anon.locator("button.suggestion-chip")).to_have_count(0)
# The nav's admin-only links stay hidden for a guest (and the
# Sign in link is what the guest gets instead).
for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"):
expect(anon.locator(admin_link)).to_be_hidden()
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
finally:
if anon_ctx is not None:
anon_ctx.close()
if created is not None:
_delete_chat(app_url, cookies, created)
# ---------------------------------------------------------------------------
# 3. Share from the History row + unshare: Create link → Copy/Unshare →
# the two-step confirm revokes — the same URL goes invalid and the
# API drops share_url
# ---------------------------------------------------------------------------
def test_share_from_history_and_unshare(
page: Page,
browser: Browser,
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="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (share-history)"
_ask(page, q)
# Save first (this test drives the History column, not the
# save-then-share one-action path — test 1 covers that).
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# History: the row's Share cell ships in the unshared state —
# the single "Create link" button.
page.goto(app_url + "/history.html")
tr = page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
create = tr.locator("button.history-share-create")
expect(create).to_be_visible(timeout=15_000)
expect(create).to_have_text("Create link")
# Create link → the cell re-renders to the shared state
# (Copy + Unshare) and the outcome lands on the live region
# (clipboard or the inline field — either success line).
create.click()
expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000)
expect(tr.locator("button.history-unshare")).to_be_visible()
expect(page.locator("#history-status")).to_contain_text("Share link")
# The API agrees: the row now carries the share link…
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
share_url = r.json().get("share_url")
assert share_url is not None
assert SHARE_URL_RE.fullmatch(share_url)
# …and the FRESH anonymous context renders the conversation at
# the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
anon_ctx.close()
anon_ctx = None
# Unshare: the inline two-step (the phase-50 confirm pattern —
# no native dialog; the pair appearing is the pinned contract).
tr.locator("button.history-unshare").click()
expect(tr.locator(".history-confirm-text")).to_have_text("Unshare?")
expect(tr.locator(".history-confirm-yes")).to_be_visible()
expect(tr.locator(".history-confirm-no")).to_be_visible()
tr.locator(".history-confirm-yes").click()
# The cell returns to the unshared state + the live region.
expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000)
expect(tr.locator("button.history-share-copy")).to_have_count(0)
expect(page.locator("#history-status")).to_have_text(f'Unshared "{q}".')
# The SAME URL is revoked now: a fresh anonymous context sees
# the invalid state (no data rendered).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-invalid")).to_be_visible(timeout=15_000)
expect(anon.locator("#shared-invalid")).to_contain_text(INVALID_TEXT)
expect(anon.locator(".msg")).to_have_count(0)
anon_ctx.close()
anon_ctx = None
# The API agrees: the row is unshared — share_url is ABSENT
# (the omission rule: no null in the wire shape).
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
assert "share_url" not in r.json(), "unshared → share_url must be absent"
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 4. A well-formed but unknown token: the invalid state, no JS crash,
# the guest header renders
# ---------------------------------------------------------------------------
def test_bad_token_invalid_state(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
# No DB reset / no login: the test is anonymous by construction
# (a fresh context from the page fixture) and the page route serves
# the HTML for any well-formed token — the 404 comes from the
# public API read, which needs the DB up (db_ready).
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
# The page route serves the page (200 HTML) for the well-formed
# token — the invalid state is rendered by the client after its
# 404 API read, not a server error page.
doc = page.goto(app_url + f"/shared/{BAD_TOKEN}")
assert doc is not None
assert doc.status == 200
expect(page.locator("#shared-invalid")).to_be_visible(timeout=15_000)
expect(page.locator("#shared-invalid")).to_contain_text(INVALID_TEXT)
# The title keeps its static fallback and nothing rendered.
expect(page.locator("#shared-title")).to_have_text("Shared conversation")
expect(page.locator(".msg")).to_have_count(0)
# The guest header rendered: Sign in visible, the admin-only nav
# links hidden.
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"):
expect(page.locator(admin_link)).to_be_hidden()
# No crash: no uncaught page errors.
assert not js_errors, f"the invalid-state render must not throw: {js_errors}"
+7 -2
View File
@@ -89,6 +89,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
@@ -126,7 +127,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html"], # phase 50: + the History page
"/git-sources.html", "/history.html", # phase 50: + the History page
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
"""Each of the other four pages revalidates and carries at least one
@@ -179,6 +181,7 @@ def test_styles_and_js_served(client) -> None:
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page
assert client.get("/assets/shared.js").status_code == 200 # phase 51: shared page module
# Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -218,6 +221,8 @@ def _find_emoji(text: str) -> list[str]:
"/assets/login.js", # phase 16
"/assets/document-modal.js", # phase 26: the document modal module
"/assets/git-sources.js", # phase 35: the git sources page module
"/shared.html", # phase 51: the anonymous shared page
"/assets/shared.js", # phase 51: the shared page module
"/assets/styles.css",
],
)
@@ -234,7 +239,7 @@ def test_ui_chrome_has_no_emoji(client, path: str) -> None:
r = client.get(path)
assert r.status_code == 200
text = r.text
if path == "/assets/app.js":
if path in ("/assets/app.js", "/assets/shared.js"):
text = text.replace('"🔎 Listing documents"', "")
text = text.replace('"📄 Reading "', "")
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
+303
View File
@@ -16,10 +16,12 @@ Requires: podman compose up -d db
"""
from __future__ import annotations
import re
import time
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
@@ -27,12 +29,21 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.main import app as fastapi_app
from app.models import SavedChat
FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes"
#: The share link's shape: the page path + a canonical (lowercase) UUID.
SHARE_URL_RE = re.compile(
r"^/shared/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
#: The PUBLIC read's exact key set — no id, no timestamps, no token.
SHARED_OUT_KEYS = {"title", "messages"}
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
#: key present; the round-trip test asserts it survives byte-identical.
FULL_BRAIN: dict[str, Any] = {
@@ -110,7 +121,11 @@ def test_anonymous_every_route_returns_403(client: TestClient) -> None:
("GET", f"/api/chats/{unknown}", None),
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
("DELETE", f"/api/chats/{unknown}", None),
("POST", f"/api/chats/{unknown}/share", None),
("POST", f"/api/chats/{unknown}/unshare", None),
]
# The public read is NOT in this list — it is anonymous by design
# (a wrong token 404s there, it never 403s).
for method, path, body in cases:
r = anon.request(method, path, json=body)
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
@@ -444,3 +459,291 @@ def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
# ---------- share / unshare / public read (phase 51, task 01) ----------
def _share(admin_client: TestClient, chat_id: str) -> dict[str, Any]:
r = admin_client.post(f"/api/chats/{chat_id}/share")
assert r.status_code == 200
return r.json()
def _stored_token(db: Session, chat_id: str) -> uuid.UUID | None:
"""The row's ``share_token`` as seen by a fresh DB read."""
row = db.get(SavedChat, uuid.UUID(chat_id))
assert row is not None, "the chat row must exist"
return row.share_token
def test_share_returns_200_with_share_url_and_is_idempotent(
admin_client: TestClient, db: Session
) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
body1 = _share(admin_client, created["id"])
assert set(body1) == {"chat_id", "share_url"}
assert body1["chat_id"] == created["id"]
assert SHARE_URL_RE.fullmatch(body1["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body1['share_url']}"
)
token = uuid.UUID(body1["share_url"].removeprefix("/shared/"))
# Persisted on the row (the A10 extension unchanged: same row, one
# new column — no new table).
assert _stored_token(db, created["id"]) == token
# Idempotent: a re-share returns the SAME token, unchanged.
body2 = _share(admin_client, created["id"])
assert body2 == body1
assert _stored_token(db, created["id"]) == token
def test_share_leaves_updated_at_unchanged(admin_client: TestClient) -> None:
"""Sharing is not a content edit — the token is written with a Core
``update()`` that skips the ORM ``onupdate``, so the History page's
"latest activity first" order follows content edits only."""
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
updated_before = created["updated_at"]
time.sleep(0.1) # now() has µs resolution — make a bump observable
_share(admin_client, created["id"])
r = admin_client.get(f"/api/chats/{created['id']}")
assert r.json()["updated_at"] == updated_before, (
"share must not bump updated_at"
)
def test_unshare_revokes_the_link_and_is_idempotent(
admin_client: TestClient, db: Session
) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
share_url = _share(admin_client, created["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
assert anon.get(f"/api{share_url}").status_code == 200 # live, pre-revoke
r = admin_client.post(f"/api/chats/{created['id']}/unshare")
assert r.status_code == 200
assert r.json() == {"chat_id": created["id"], "shared": False}
# The token is NULL in the DB and the public read now 404s.
assert _stored_token(db, created["id"]) is None
revoked = anon.get(f"/api{share_url}")
assert revoked.status_code == 404
assert revoked.json() == {"detail": "unknown or revoked share link"}
# Idempotent: unsharing an unshared chat is a clean 200 (no write).
r2 = admin_client.post(f"/api/chats/{created['id']}/unshare")
assert r2.status_code == 200
assert r2.json() == {"chat_id": created["id"], "shared": False}
assert _stored_token(db, created["id"]) is None
def test_unshare_leaves_updated_at_unchanged(admin_client: TestClient) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
updated_before = created["updated_at"]
_share(admin_client, created["id"])
time.sleep(0.1)
admin_client.post(f"/api/chats/{created['id']}/unshare")
r = admin_client.get(f"/api/chats/{created['id']}")
assert r.json()["updated_at"] == updated_before, (
"unshare must not bump updated_at"
)
def test_public_read_returns_snapshot_without_private_keys(
admin_client: TestClient,
) -> None:
"""A fresh anonymous client reads the shared chat: title + messages
round-trip, and the body carries NONE of the admin-surface keys
(no id, no timestamps, no token — a content snapshot, not a handle)."""
created = admin_client.post(
"/api/chats",
json={
"title": EXPLICIT_TITLE,
"messages": [_user(FIRST_QUESTION), FULL_BRAIN],
},
).json()
share_url = _share(admin_client, created["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{share_url}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS
assert body["title"] == EXPLICIT_TITLE
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
"""Wrong (never issued) and revoked (unshared) tokens 404 with the
SAME detail — no enumeration between the two cases."""
anon = TestClient(fastapi_app)
wrong = anon.get(f"/api/shared/{uuid.uuid4()}")
assert wrong.status_code == 404
assert wrong.json() == {"detail": "unknown or revoked share link"}
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
share_url = _share(admin_client, created["id"])["share_url"]
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
revoked = anon.get(f"/api{share_url}")
assert revoked.status_code == 404
assert revoked.json() == wrong.json() # one message, both cases
def test_public_read_malformed_token_returns_422() -> None:
anon = TestClient(fastapi_app)
assert anon.get("/api/shared/not-a-uuid").status_code == 422
def test_share_and_unshare_unknown_chat_return_404(admin_client: TestClient) -> None:
unknown = uuid.uuid4()
r = admin_client.post(f"/api/chats/{unknown}/share")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
r = admin_client.post(f"/api/chats/{unknown}/unshare")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
# ---------- create-with-share (phase 51, task 02 — the save-then-share
# contract: one request saves AND shares; unshared shapes carry NO
# ``share_url`` key at all — absent, not null) ----------
def test_create_with_share_sets_token_in_the_same_commit(
admin_client: TestClient, db: Session
) -> None:
"""``POST /api/chats`` with ``share: true``: the 201 body carries
``share_url`` (the ONLY extra key — the shape is OUT_KEYS +
``share_url``), matching the token shape, and the row's
``share_token`` is persisted in the SAME commit (one INSERT — no
second request, no window where the row is saved but unshared)."""
r = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
)
assert r.status_code == 201
body = r.json()
assert set(body) == OUT_KEYS | {"share_url"}
assert SHARE_URL_RE.fullmatch(body["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body['share_url']}"
)
token = uuid.UUID(body["share_url"].removeprefix("/shared/"))
assert _stored_token(db, body["id"]) == token
def test_create_with_share_is_immediately_publicly_readable(
admin_client: TestClient,
) -> None:
"""The save-then-share contract's payoff: the row is readable
ANONYMOUSLY the moment the 201 lands (no second step)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{created['share_url']}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS
assert body["messages"] == _expect(_simple_conversation())
def test_create_without_share_has_no_share_url(admin_client: TestClient) -> None:
"""The default (``share`` absent or false) is byte-for-byte the
phase-50 shape: NO ``share_url`` key in the create body, the get
body, or the list row — absent, not ``null``."""
r = admin_client.post("/api/chats", json={"messages": _simple_conversation()})
assert r.status_code == 201
created = r.json()
assert "share_url" not in created
assert set(created) == OUT_KEYS
got = admin_client.get(f"/api/chats/{created['id']}").json()
assert "share_url" not in got and set(got) == OUT_KEYS
row = admin_client.get("/api/chats").json()["chats"][0]
assert "share_url" not in row and set(row) == ROW_KEYS
def test_create_share_false_is_explicitly_unshared(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": False}
)
assert r.status_code == 201
assert "share_url" not in r.json(), "share: false is a plain Save (phase-50 shape)"
def test_list_rows_carry_share_url_only_when_shared(
admin_client: TestClient,
) -> None:
"""The list endpoint populates ``share_url`` — so the History
column renders straight from ``GET /api/chats`` (no second fetch
per row): shared rows carry it (token shape), unshared rows omit it
(the row shape is exactly ROW_KEYS)."""
shared = admin_client.post(
"/api/chats",
json={"title": "Shared one", "messages": _simple_conversation(), "share": True},
).json()
plain = admin_client.post(
"/api/chats",
json={"title": "Plain one", "messages": _simple_conversation()},
).json()
rows = {c["id"]: c for c in admin_client.get("/api/chats").json()["chats"]}
assert SHARE_URL_RE.fullmatch(rows[shared["id"]]["share_url"])
assert set(rows[shared["id"]]) == ROW_KEYS | {"share_url"}
assert "share_url" not in rows[plain["id"]]
assert set(rows[plain["id"]]) == ROW_KEYS
def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> None:
"""``GET /{chat_id}`` carries ``share_url`` while shared (the same
path as the create body) and drops the key after ``unshare`` — the
full-payload shape returns to the phase-50 OUT_KEYS."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
got = admin_client.get(f"/api/chats/{created['id']}").json()
assert got["share_url"] == created["share_url"]
assert set(got) == OUT_KEYS | {"share_url"}
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
got2 = admin_client.get(f"/api/chats/{created['id']}").json()
assert "share_url" not in got2
assert set(got2) == OUT_KEYS
# ---------- the /shared/<token> page route (phase 51, task 01) ----------
def test_page_route_missing_shared_html_returns_same_404_json(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Stale-deploy guard: a static dir WITHOUT ``shared.html`` (the
page lands in task 03) 404s with the SAME JSON as the API — never a
500, regardless of the token."""
monkeypatch.setattr(
"app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path))
)
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "unknown or revoked share link"}
def test_page_route_serves_shared_html_when_present(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Once the file exists (task 03), the route serves it for any well-
formed token — token validity is the page's own concern (it fetches
the API and renders the "invalid or revoked" state itself)."""
(tmp_path / "shared.html").write_text("<html>shared page</html>", encoding="utf-8")
monkeypatch.setattr(
"app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path))
)
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert r.text == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"]
+230
View File
@@ -0,0 +1,230 @@
"""Integration: migration 0009 (saved_chats.share_token) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0008.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0009`` explicitly so later migrations cannot break them:
* upgrade 0008 → 0009 → ``saved_chats.share_token`` exists as
``UUID`` **NULLable** (NULL = not shared) and the UNIQUE index
``ix_saved_chats_share_token`` exists; pre-0009 rows come back
unshared (NULL);
* the NULLs-distinct behavior (the phase-38 ``git_sources.path``
precedent): two rows may both carry NULL, while two identical
non-NULL tokens are rejected by the unique index;
* downgrade to 0008 → the column and the index are gone (A13 —
reversible), the rest of the table survives;
* upgrade back to 0009 → both are back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one saved_chats column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'saved_chats' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_token_index(db: Session) -> int:
"""1 iff ``ix_saved_chats_share_token`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'saved_chats'"
" AND indexname = 'ix_saved_chats_share_token'"
" AND indexdef ILIKE 'CREATE UNIQUE%'"
)
).scalar()
assert count is not None, "pg_indexes count must be an int"
return int(count)
def _insert(db: Session, token: uuid.UUID | None) -> uuid.UUID:
"""Insert one saved_chats row with an explicit ``share_token``."""
chat_id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages, share_token)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb), :tok)"
" RETURNING id"
),
{
"t": "Mig 0009",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
"tok": token,
},
).scalar_one()
db.commit()
return chat_id
def _legacy_insert(db: Session) -> uuid.UUID:
"""Insert one row WITHOUT the ``share_token`` column — the only
possible shape at revision 0008 (the column does not exist yet)."""
chat_id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
),
{
"t": "Mig 0009",
"m": '[{"who": "user", "text": "How did I install gitlab?"}]',
},
).scalar_one()
db.commit()
return chat_id
def _delete(db: Session, chat_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat_id})
db.commit()
def test_upgrade_to_0009_adds_share_token(db: Session, alembic: Config) -> None:
"""Upgrade 0008 → 0009: the column is UUID + NULLable, the unique
index exists, and a pre-0009 row comes back unshared (NULL)."""
command.downgrade(alembic, "0008") # start from the pre-0009 state
assert _version(db) == "0008"
assert _column(db, "share_token") is None, "share_token must be absent at 0008"
assert _unique_token_index(db) == 0, "the index must be absent at 0008"
# A pre-0009 row (no share_token in the INSERT — the column does
# not exist at 0008): its data must survive the additive migration.
legacy = _legacy_insert(db)
try:
command.upgrade(alembic, "0009")
assert _version(db) == "0009", "alembic_version must be at 0009"
col = _column(db, "share_token")
assert col is not None, "saved_chats.share_token is missing"
assert col[0] == "uuid", "share_token must be UUID"
assert col[1] == "YES", "share_token must be NULLable (NULL = not shared)"
assert _unique_token_index(db) == 1, "the unique token index is missing"
token = db.execute(
text("SELECT share_token FROM saved_chats WHERE id = :i"), {"i": legacy}
).scalar_one()
assert token is None, "a pre-0009 row must upgrade as unshared (NULL)"
finally:
_delete(db, legacy)
def test_unique_index_treats_nulls_as_distinct(db: Session, alembic: Config) -> None:
"""NULLs are distinct under the unique index (the phase-38
``git_sources.path`` precedent): any number of unshared chats
coexist."""
command.upgrade(alembic, "head")
a = _insert(db, None)
b = _insert(db, None)
try:
count = db.execute(
text(
"SELECT count(*) FROM saved_chats"
" WHERE id IN (:a, :b) AND share_token IS NULL"
),
{"a": a, "b": b},
).scalar_one()
assert count == 2, "two NULL tokens must coexist (NULLs are distinct)"
finally:
_delete(db, a)
_delete(db, b)
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
"""Two identical non-NULL tokens are rejected by the unique index —
the share link is a unique handle (and a distinct token still lands).
"""
command.upgrade(alembic, "head")
token = uuid.uuid4()
a = _insert(db, token)
b: uuid.UUID | None = None
try:
try:
_insert(db, token)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate non-NULL share_token must be rejected")
# A different token is fine — only the exact duplicate is unique.
b = _insert(db, uuid.uuid4())
finally:
_delete(db, a)
if b is not None:
_delete(db, b)
def test_downgrade_to_0008_drops_share_token(db: Session, alembic: Config) -> None:
"""Downgrade to 0008: the column and the index are gone (A13 —
reversible) while the rest of the table survives."""
command.downgrade(alembic, "0008")
assert _version(db) == "0008"
assert _column(db, "share_token") is None, "share_token must be dropped"
assert _unique_token_index(db) == 0, "the unique index must be dropped"
id_col = _column(db, "id")
assert id_col is not None and id_col[0] == "uuid", (
"saved_chats.id must survive the downgrade"
)
def test_upgrade_round_trip_restores_share_token(db: Session, alembic: Config) -> None:
"""Downgrade to 0008, then upgrade back to 0009: the column and the
unique index are back."""
command.downgrade(alembic, "0008")
command.upgrade(alembic, "0009")
assert _version(db) == "0009", "round-trip upgrade must land at 0009"
col = _column(db, "share_token")
assert col is not None, "share_token must be back after the round-trip"
assert col[0] == "uuid" and col[1] == "YES", (
"share_token must be UUID + NULLable after the round-trip"
)
assert _unique_token_index(db) == 1, "the unique index must be back"
+66
View File
@@ -17,6 +17,7 @@ import asyncio
import os
import re
import subprocess
import uuid
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
@@ -205,6 +206,7 @@ def test_html_pages_include_history() -> None:
"/tuning.html",
"/git-sources.html",
"/history.html",
"/shared.html", # phase 51: the shared page's static path
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
@@ -386,3 +388,67 @@ def test_read_body_drains_streaming_response() -> None:
resp = StreamingResponse(content=gen(), media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
# ---------------------------------------------------------------------------
# Phase 51: the dynamic share page — the ``/shared/`` prefix contract
# ---------------------------------------------------------------------------
def _shared_page_app() -> FastAPI:
"""A bare app with the phase-51 route pair + the middleware:
``/shared/<token>`` (``text/html`` — the page the route serves) and
``/api/shared/<token>`` (the JSON read), plus an unknown path."""
app = FastAPI()
@app.get("/shared/{token}", response_class=HTMLResponse)
def shared_page(token: str) -> str:
return (
"<html><head>"
'<link rel="stylesheet" href="/assets/styles.css">'
"</head><body>shared</body></html>"
)
@app.get("/api/shared/{token}")
def shared_api(token: str) -> JSONResponse:
return JSONResponse({"title": "Shared", "messages": []})
@app.get("/some/unknown/path")
def unknown() -> JSONResponse:
return JSONResponse({"ok": True})
caching.configure_caching(app)
return app
def test_middleware_treats_shared_page_path_as_known_html_page() -> None:
"""Phase 51: ``/shared/<uuid>`` joins the HTML_PAGES contract —
``no-cache`` + ``?v=`` asset rewrite on the ``text/html`` body (the
FileResponse body is drained by the existing ``_read_body`` path)."""
client = TestClient(_shared_page_app())
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
token = caching.asset_version()
assert f'href="/assets/styles.css?v={token}"' in r.text
assert 'href="/assets/styles.css">' not in r.text
def test_middleware_leaves_api_shared_read_untouched() -> None:
"""``/api/shared/<uuid>`` — the JSON read — starts with ``/api/``,
not ``/shared/``: byte-identical pass-through, no injected headers."""
client = TestClient(_shared_page_app())
r = client.get(f"/api/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"title": "Shared", "messages": []}
def test_middleware_leaves_unknown_paths_untouched() -> None:
"""A path that is neither a known page, ``/shared/*``, nor
``/assets/*`` passes through byte-identical, no headers."""
client = TestClient(_shared_page_app())
r = client.get("/some/unknown/path")
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"ok": True}
+7 -2
View File
@@ -29,6 +29,7 @@ HTML_PAGES = (
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page
)
@@ -138,9 +139,13 @@ def test_every_page_loads_brand_js_before_its_module_script() -> None:
default must be set first)."""
for page in HTML_PAGES:
html = _text(FRONTEND / page)
brand_idx = html.find('<script src="assets/brand.js"></script>')
# Optional leading slash: root-level pages use "assets/brand.js",
# but the shared page is served from the NESTED /shared/<token>
# route, where a relative ref would 404 — it uses "/assets/…".
m = re.search(r'<script src="(?:/)?assets/brand\.js"></script>', html)
assert m, f"{page}: brand.js must load"
brand_idx = m.start()
module_idx = html.find('<script type="module"')
assert brand_idx != -1, f"{page}: brand.js must load"
assert module_idx != -1, f"{page}: the page module must load"
assert brand_idx < module_idx, (
f"{page}: brand.js must load BEFORE the module script"
+7 -2
View File
@@ -42,8 +42,11 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
STYLES_CSS = ASSETS / "styles.css"
#: The six pages of the app (phase 46: the shared bar contract extends to
#: the phase-35 git-sources page — the hamburger is part of that bar).
#: The app's pages (phase 46: the shared bar contract extends to the
#: phase-35 git-sources page — the hamburger is part of that bar; the
#: phase-50 History page and the phase-51 shared page carry the same
#: bar — the full seven-page set). The two pages added after phase 46
#: keep the identical header block, so they pin here too.
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -51,6 +54,8 @@ PAGES = (
FRONTEND / "git-sources.html",
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
)
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
+199 -12
View File
@@ -39,11 +39,13 @@ TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
#: The phase-34 one-bar contract + the History page + the shared
#: page: EIGHT pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
@@ -52,6 +54,7 @@ ALL_PAGES = (
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
SHARED_HTML,
)
@@ -110,16 +113,17 @@ def test_nav_history_present_on_all_seven_pages() -> None:
)
def test_nav_history_count_is_exactly_seven_pages() -> None:
def test_nav_history_count_is_exactly_eight_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no
extra page that forgot (or added twice)."""
one ``id="nav-history"`` per page, eight pages (phase 51: + the
shared page), no duplicates and no extra page that forgot (or
added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
@@ -173,27 +177,35 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns —
Title | Messages | Updated | Actions (the Actions header text is
visually-hidden — the row buttons carry their own aria-labels) —
and the empty-state row (ship-hidden, the exact copy)."""
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy.
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html
assert "<td colspan=\"5\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -426,3 +438,178 @@ def test_history_table_mobile_behavior() -> None:
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
# ---------- the Share column (phase 51, owner-locked 2026-08-29) ----------
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
)
def test_share_control_three_states_and_two_step_unshare() -> None:
"""The share cell's THREE states — unshared → [Create link];
shared → [Copy] [Unshare]; confirming → "Unshare? [Yes] [No]" —
plus the inline two-step unshare (the phase-50 Delete-confirm
pattern: focus moves to Yes, No restores the shared state, no
native dialog). The shipped state comes from the row's share_url
(the list endpoint populates it — no second fetch)."""
js = _js()
assert "window.confirm" not in js, "history.js must use the inline two-step only"
# makeShareControl: the shipped state branches on chat.share_url.
make = _fn(js, "makeShareControl")
assert "cell.className = \"history-share\"" in make
assert "chat.share_url" in make
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
)
assert 'no.className = "history-confirm-no"' in shared
assert "cell.replaceChildren(label, yes, no)" in shared
swap_i = shared.find("cell.replaceChildren(label, yes, no)")
assert shared.find("yes.focus(", swap_i) > 0, "focus moves to Yes after the swap"
assert 'no.addEventListener("click", restoreShared)' in shared
restore_i = shared.find("function restoreShared")
assert restore_i != -1
assert "cell.replaceChildren(copy, unshare)" in shared[restore_i:restore_i + 120], (
"No (and a failed request) restore the shared state"
)
def test_share_create_and_unshare_request_outcomes() -> None:
"""createShareLink: POST /api/chats/<id>/share → the response's
share_url becomes the row's data, the cell re-renders shared, and
the ABSOLUTE link is offered for copying (clipboard → fallback);
non-2xx / network keep the unshared state (retryable) + the error
line. confirmUnshare: POST /api/chats/<id>/unshare → the cell
re-renders unshared + `Unshared "<title>".`; non-2xx / network
restore the shared state + the error line. Both double-fire
guarded."""
js = _js()
create = _fn(js, "createShareLink")
assert "createBtn.disabled = true" in create
assert 'fetch(`/api/chats/${chat.id}/share`, { method: "POST" })' in create
assert "renderShareShared(chat, cell)" in create, "success re-renders the shared state"
assert "chat.share_url = share_url" in create, "the row's data gains the link"
assert "new URL(share_url, window.location.origin).toString()" in create, (
"the ABSOLUTE link is what gets copied (the origin supplies scheme/host)"
)
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in create
assert "is the app reachable?" in create, "the network-error line"
assert "try again" in create, "the non-2xx line"
# A failed request keeps the button (re-enabled) — retryable.
assert create.count("createBtn.disabled = false") == 2, (
"both failure paths re-enable the Create link button"
)
unshare = _fn(js, "confirmUnshare")
assert "yesBtn.disabled = true" in unshare
assert 'fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" })' in unshare
assert "chat.share_url = null" in unshare, "a revoked link drops the row's share_url"
assert "renderShareUnshared(chat, cell)" in unshare, "success re-renders the unshared state"
assert 'announce(`Unshared "${chat.title}".`)' in unshare
assert unshare.count("restoreShared()") == 2, (
"non-2xx and network both restore the shared state (retryable)"
)
assert "is the app reachable?" in unshare
assert "try again" in unshare
def test_share_copy_uses_own_per_page_clipboard_helper_with_fallback() -> None:
"""The per-page duplication house style: history.js keeps its OWN
~10-line copy of the clipboard + inline-link fallback helper (no
import from app.js, no new shared module). A non-secure (http)
origin rejects navigator.clipboard → a transient .share-link-fallback
<a> field lands in the row's share cell (selects its full URL on
focus — the range-based selectAllInField), one field at a time."""
js = _js()
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("app.js" not in line for line in import_lines), (
"no cross-page import — the helper is duplicated per page"
)
copy = _fn(js, "copyShareLink")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy, "the clipboard try"
assert 'cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in copy, (
"one field at a time — a new offer replaces the old"
)
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy
assert "field.focus({ preventScroll: true })" in copy, "selects the URL on focus"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
# Copy (shared state) goes through the same helper.
rowcopy = _fn(js, "copyRowShareLink")
assert "copyShareLink(" in rowcopy
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in rowcopy
def test_share_column_css() -> None:
"""styles.css: the Share cell's ghost buttons (the Tune/Retry family
— transparent, --line border, ink-soft, ≥44px) + Unshare's
error-rose hover (it revokes — the Delete language) + the inline
fallback field (input-like: mono, surface fill, --line border,
ellipsis, 3px focus-visible). The unshare two-step reuses the
.history-confirm-* pair CSS (no new confirm styles)."""
css = _css()
for cls in (".history-share-create", ".history-share-copy", ".history-unshare"):
assert re.search(re.escape(cls), css), f"styles.css must style {cls}"
btn = re.search(
r"\.history-share-create,\n\.history-share-copy,\n\.history-unshare \{([\s\S]*?)\n\}",
css,
)
assert btn, "the share buttons share one ghost-button block"
body = btn.group(1)
assert "min-height: 44px" in body, "≥44px comfortable target"
assert "border: 1px solid var(--line)" in body
assert "background: transparent" in body
assert "var(--ink-soft)" in body
assert (
".history-unshare:hover:not(:disabled) { background: var(--err-bg);"
" color: var(--err-ink); border-color: var(--err-line); }"
) in css, "Unshare hovers the error rose (it revokes the link)"
field = re.search(r"\.share-link-fallback \{([\s\S]*?)\n\}", css)
assert field, "the inline fallback field must be styled"
fbody = field.group(1)
assert "var(--mono)" in fbody, "input-like: mono (the URL is data)"
assert "background: var(--surface)" in fbody
assert "border: 1px solid var(--line)" in fbody
assert "text-overflow: ellipsis" in fbody
assert re.search(r"\.share-link-fallback:focus-visible \{[^}]*outline[^}]*3px", css), (
"the fallback field keeps a 3px :focus-visible outline"
)
+156
View File
@@ -291,3 +291,159 @@ def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the Save button adds no external script/link."""
index = _index()
assert 'src="http' not in index and 'href="http' not in index
# ---------- the Share button on the chat page (phase 51, task 02) ----------
def test_share_button_ships_hidden_beside_save() -> None:
"""#share-chat-btn: a real type=button with the accessible name
"Share chat", SHIPPED HIDDEN (app.js reveals it for admin only),
BESIDE #save-chat-btn in .chat-shell inside <main>, above
#messages — the chat-shell actions read as a pair (Save | Share).
No other page carries it (chat-page only, like Save)."""
html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Share chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# The label: the visible text is "Share" (the link SVG is aria-hidden
# decoration; the aria-label carries the accessible name).
btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block
# Beside Save: after it, still inside .chat-shell, above #messages.
shell_idx = html.find('class="container chat-shell"')
save_idx = html.find('id="save-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < save_idx < btn.start() < messages_idx, (
"the button must sit beside #save-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Share button is chat-page only"
)
def test_share_button_css_is_the_exact_save_family() -> None:
"""styles.css: .share-chat-btn carries the EXACT visual family of
.save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA;
borderless; 999px radius; ≥44px target; hover lightens the brand
fill); the ≤640px block mirrors the Save overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
css = _css()
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .share-chat-btn"
body = block.group(1)
assert "min-height: 44px" in body
assert "border-radius: 999px" in body
assert "border: 0" in body
assert "background: var(--brand)" in body, "same solid brand fill as Save"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".share-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, ("squeezes with Save")
assert ".share-chat-label { display: none; }" in mbody
assert ".share-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .share-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for Save"
)
assert ".chat-shell .share-chat-btn svg { display: none; }" in mbody
def test_share_current_chat_save_then_share_branch() -> None:
"""shareCurrentChat: the same empty-conversation no-op guard as
Save (live region, no request). The save-then-share branch: linked
(currentChatId set) → POST /api/chats/<id>/share (the idempotent
token); unlinked → POST /api/chats with { messages: conversation,
share: true } and link currentChatId to the created id — one action
saves AND shares (owner-locked). Success: the ABSOLUTE URL is
copied — the clipboard try succeeds → the live region reads
"Share link copied."; the rejection (a non-secure http origin)
renders the .share-link-fallback field + "Share link ready — copy it
from the field." 403/5xx → the actionable banner (signed-out hint);
network → the reachable? banner. The double-click guard releases in
the finally — never stale."""
js = _js()
body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to share yet."')
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: POST share when linked, create-with-share when not.
assert "if (currentChatId)" in body
linked = '`/api/chats/${currentChatId}/share`'
share_fetch = body.find(linked)
assert share_fetch != -1, "the linked branch POSTs the idempotent share"
assert 'fetch("/api/chats", {' in body, "the unlinked branch POSTs /api/chats"
assert 'JSON.stringify({ messages: conversation, share: true })' in body, (
"the create-with-share payload — the server sets the token in the same commit"
)
post_idx = body.find('fetch("/api/chats", {')
assert -1 < share_fetch < post_idx, "the linked branch precedes the unlinked fallback"
created_idx = body.find("currentChatId = String(created.id)", post_idx)
assert created_idx != -1, "one action saved AND shared: the conversation links to the row"
# The copy: the ABSOLUTE URL (share_url resolved against the page
# origin) + the two live-region outcomes (success / the owner-locked
# inline-field fallback).
abs_fn = _fn(js, "absoluteShareUrl")
assert "new URL(shareUrl, window.location.origin).toString()" in abs_fn, (
"the ABSOLUTE URL is what gets copied (the origin supplies scheme/host)"
)
assert "copyShareLinkWithFallback(absoluteShareUrl(shareUrl))" in body
assert (
'sendStatus.textContent = copied\n'
' ? "Share link copied."\n'
' : "Share link ready — copy it from the field."'
) in body
# Failures raise an actionable banner (non-ok HTTP + network).
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
assert body.count("check you're still signed in and try again") == 2, (
"both the linked and the unlinked branch carry the non-ok banner"
)
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# The clipboard + fallback helpers live in app.js (the chat page's
# copy of the per-page helper).
copy = _fn(js, "copyShareLinkWithFallback")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, (
"select-on-focus — the field-like behavior"
)
assert "composer.appendChild(field)" in copy, "near the status line (the composer)"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#share-chat-btn, binds the click to shareCurrentChat, and the boot
IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block
as Save (phase 16 absent-not-hidden — no trace for anonymous)."""
js = _js()
assert 'document.querySelector("#share-chat-btn")' in js
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation — and right next to Save's own reveal line.
boot_start = js.find("(async () => {")
reveal = js.find("shareBtn.hidden = !isAdmin")
save_reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
)
+10 -2
View File
@@ -424,7 +424,12 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
"""Phase 34 task 02: initSharedHeader points #sign-in-link at
/login.html?next=<current pathname> (default "/") — the admin lands
back on the page they signed in from. The page markup keeps its own
static ?next= as the no-JS fallback."""
static ?next= as the no-JS fallback.
Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
the NESTED /shared/<token> page rewrites to the APP ROOT ("/")
instead of the shared URL: a guest signing in from a shared page
returns to the app root, not to a public link."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
@@ -432,7 +437,10 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
# raw pathname (always a query-safe "/…" string — never "//", and ?
# # / spaces stay percent-encoded inside it; login.js safeNext
# re-validates), same shape as the static markup fallbacks
assert '"/login.html?next=" + (window.location.pathname || "/")' in body
assert 'const nextPath = window.location.pathname || "/";' in body
assert 'link.href = "/login.html?next=" + signInNext;' in body
# the shared-page exception: /shared/<token> → the app root
assert 'nextPath.startsWith("/shared/") ? "/" : nextPath' in body
# ---------- phase 34 task 01: the steering controls move to the module ----------
+449
View File
@@ -0,0 +1,449 @@
"""Unit: the phase-51 task-03 shared page contract (anonymous,
read-only, zero controls).
The browser behavior itself is E2E-gated by the story suite (task 04);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the shared-conversation contract depends on, so a
silent regression is caught without a browser:
* the page scaffold (the identical shared header — every admin-only
link ships hidden, the auth pair's static fallback is ``?next=/`` — a
guest signing in from a shared page returns to the app root — the
steering panel, the h1 fallback, the read-only note, the invalid
state, the messages section);
* the token parse (a malformed path → the invalid state, NO fetch of
any kind);
* the 404 / network / malformed read → the invalid state (no data
render, no banner);
* ZERO interactive controls: ``renderSharedMessage`` never calls the
chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips
carry no ``href``;
* the shared shell's 46rem column mapping + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
SHARED_HTML = FRONTEND / "shared.html"
SHARED_JS = ASSETS / "shared.js"
STYLES_CSS = ASSETS / "styles.css"
def _js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _html() -> str:
return SHARED_HTML.read_text(encoding="utf-8")
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in shared.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- shared.html: the page scaffold ----------
def test_shared_html_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the h1 with its static fallback, the read-only note,
the invalid state (ship-hidden), the messages section, and the
footer with the version span (the index.html shape)."""
html = _html()
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert '<nav class="app-nav" id="app-nav" aria-label="Primary">' in html
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
# The steering panel (hidden) + its announcer, first children of <main>.
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert 'id="steering-list"' in html
assert 'id="steering-empty"' in html
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert html.find('id="steering-panel"') < html.find('id="steering-announcer"')
# The title: JS-filled, with the static fallback text.
assert "<h1 id=\"shared-title\">Shared conversation</h1>" in html
# The read-only note (brand resolves through window.BOR_BRAND at
# call time in shared.js; the static copy is the default name).
assert "<p class=\"shared-note\">Shared via Brain of Reese — read-only.</p>" in html
# The invalid / revoked state: ship-hidden, the exact copy.
invalid = re.search(r'<div[^>]*id="shared-invalid"[^>]*>', html)
assert invalid and "hidden" in invalid.group(0), (
"#shared-invalid must ship hidden"
)
assert (
"<div id=\"shared-invalid\" hidden>"
"This share link is invalid or was revoked.</div>"
) in html
# The messages section: the SAME structure container as the chat page.
assert (
'<section class="messages" id="messages" aria-label="Shared conversation">'
in html
)
# Footer with the version span.
assert '<span class="footer-version" id="app-version"></span>' in html
def test_shared_html_identical_header_guest_safe() -> None:
"""The IDENTICAL shared header (the phase-34 one-bar contract):
every admin-only nav link SHIPS hidden (a guest never sees one for
a frame), no nav link is "current" (a detail view — the
document.html convention), and the auth pair's static fallback is
?next=/ — a guest signing in from a shared page returns to the
app root (owner-locked; the comment notes it)."""
html = _html()
# The one-bar inventory (brand, hamburger, nav, auth pair).
assert 'class="brand"' in html
assert re.search(r'<button[^>]*id="nav-toggle"[^>]*aria-label="Menu"[^>]*>', html)
for link in (
r'<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>',
r'<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>',
r'<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>',
r'<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>',
):
assert re.search(link, html), f"missing the ship-hidden nav link: {link}"
# No link is current — the shared page is a read-only detail view.
assert "is-active" not in html, "no nav link is current on the shared page"
# The auth pair: BOTH copies (bar + mobile dropdown) fall back to
# the app root — ?next=/ — with the owner-locked note in a comment.
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link" id="sign-in-link" hidden>'
in html
)
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile"'
' id="sign-in-link-mobile" hidden>'
in html
)
assert re.search(
r'<button[^>]*class="auth-link sign-out-btn" id="sign-out-btn"[^>]*>', html
)
assert (
"a guest signing in FROM a shared page returns to the APP" in html
), "the ?next=/ app-root contract must be documented in the markup"
def test_shared_html_scripts_and_no_controls() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the classic markdown renderer second, the shared.js module last —
ALL with ABSOLUTE /assets/ paths: the page is served from the
NESTED /shared/<token> route, where a relative "assets/…" ref
would resolve to /shared/assets/… and 404. NO direct header.js
<script> tag (single-evaluation design — shared.js imports it
relatively). Zero controls (owner-locked): no document-modal, no
composer, no Send/Save/Share/New chat buttons, no form element,
and the ONLY <button>s on the page are the shared bar's own (the
hamburger + the two sign-out copies)."""
html = _html()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["/assets/brand.js", "/assets/markdown.js", "/assets/shared.js"], (
f"shared.html must load brand.js + markdown.js (classic, absolute "
f"/assets/ paths — the nested route breaks relative refs) and the "
f"shared.js module, in that order, got {srcs}"
)
# The stylesheet too (a relative href would break the same way).
assert '<link rel="stylesheet" href="/assets/styles.css">' in html
assert 'src="header.js"' not in html, (
"no direct header.js <script> tag — the single-evaluation design"
)
assert 'type="module" src="/assets/shared.js"' in html
# No CDN (AGENTS.md rule 6): every asset is local.
assert 'src="http' not in html and 'href="http' not in html
# Zero controls: nothing the chat page's controls would look like.
for marker in (
"document-modal",
'id="composer"',
'id="send-btn"',
'id="new-chat-btn"',
'id="save-chat-btn"',
'id="share-chat-btn"',
'id="tune',
"<form",
):
assert marker not in html, f"shared.html must not carry {marker!r}"
# Exactly the shared bar's three buttons — nothing in the content.
assert html.count("<button") == 3, (
"only the hamburger + the two sign-out copies may be buttons"
)
# ---------- shared.js: the token + the no-fetch rule ----------
def test_token_parse_last_segment_uuid_only() -> None:
"""parseSharedToken: the last path segment of /shared/<token> — a
well-formed uuid only; a malformed or missing token (no final
segment, a non-uuid segment) returns null."""
js = _js()
body = _fn(js, "parseSharedToken")
assert "window.location.pathname.split(\"/\").filter(Boolean)" in body, (
"the token is the LAST path segment"
)
assert re.search(
r"TOKEN_RE = /\^\[0-9a-f\]\{8\}-\[0-9a-f\]\{4\}-\[0-9a-f\]\{4\}-"
r"\[0-9a-f\]\{4\}-\[0-9a-f\]\{12\}\$/i",
js,
), "the uuid gate is the house TOKEN_RE shape"
assert "TOKEN_RE.test(last)" in body
def test_malformed_token_shows_invalid_with_no_fetch() -> None:
"""The boot: the malformed-token branch shows #shared-invalid and
returns BEFORE any fetch of any kind — no /api/shared read, no
whoami (initSharedHeader), nothing: the page ships in its guest
state, which is already correct for a bad URL."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
gate = boot.find("if (!token)")
assert gate != -1, "the malformed-token gate must run in boot"
ret = boot.find("return;", gate)
branch = boot[gate:ret]
assert "showInvalid()" in branch, "the invalid state shows for a bad token"
assert "fetch(" not in branch, "a malformed token must trigger NO fetch"
assert "initSharedHeader" not in branch, (
"no whoami either — the header ships in its guest state"
)
# The note is set before the gate (the brand resolves at call time).
note_i = boot.find("noteEl.textContent")
assert 0 < note_i < gate, "the brand note is set before the token gate"
assert "Shared via ${brand()} — read-only." in boot
def test_boot_order_header_then_public_read() -> None:
"""A well-formed token: initSharedHeader() FIRST (the header works
for guests — whoami anonymous, the admin links stay hidden), then
the public read GET /api/shared/<token>; a null read shows the
invalid state, a 200 renders through renderSharedChat."""
js = _js()
boot = js[js.find("(async () => {"):]
header_i = boot.find("await initSharedHeader();")
read_i = boot.find("fetchSharedChat(token)")
render_i = boot.find("renderSharedChat(data)")
assert -1 < header_i < read_i < render_i, (
"boot order: header init → public read → render"
)
invalid_i = boot.find("if (!data)")
assert -1 < invalid_i < render_i, "the null read gates the render"
branch = boot[invalid_i:render_i]
assert "showInvalid()" in branch
assert "renderSharedChat" not in branch, "no render on a null read"
# The relative import of the shared header module (no absolute
# /assets/ import — the esbuild bundle contract).
assert 'import { initSharedHeader } from "./header.js";' in js
assert '"/assets/header.js"' not in js
# No cross-page import (the per-page duplication house style).
import_lines = [
line for line in js.splitlines() if line.strip().startswith("import")
]
assert all("app.js" not in line for line in import_lines)
def test_public_read_collapses_failures_to_null() -> None:
"""fetchSharedChat: a network failure, a non-2xx (the 404 for a
wrong or revoked token), or a malformed body all return null →
the invalid state (no data render, no banner — this page has no
error banner). A 200 without a messages array is unusable too."""
js = _js()
body = _fn(js, "fetchSharedChat")
assert "fetch(`/api/shared/${token}`)" in body
# Exactly three failure collapses to null (network / non-ok /
# malformed) + the shape guard on the 200 path.
assert body.count("return null") == 3, (
"network, non-2xx and malformed body each collapse to null"
)
assert "if (!res.ok) return null" in body, "404 (wrong/revoked) → null"
assert "Array.isArray(data.messages)" in body, "the 200 shape guard"
# ---------- shared.js: the read-only render ----------
def test_render_shared_message_record_shape() -> None:
"""renderSharedMessage: the SAME record shape the chat page
restores — user → the .msg.user bubble; brain → the .msg.brain
bubble with the optional thinking block (restored COLLAPSED — the
phase-17 convention), the tool lines in saved order, the
is-deflected treatment + the plain-text "Maybe try" chips, the
plain-text source chips, and the stopped note. Markdown through
the GLOBAL escape-first renderMarkdown (no local copy)."""
js = _js()
body = _fn(js, "renderSharedMessage")
assert 'addSharedMessage("user", renderMarkdown(m.text))' in body
assert 'addSharedMessage("brain", renderMarkdown(m.text))' in body
assert "renderMarkdown" in body, "the escape-first global renderer"
assert "function renderMarkdown" not in js, (
"shared.js must NOT define its own renderer — markdown.js is the one copy"
)
# The thinking block: restored COLLAPSED.
think = _fn(js, "addThinkingBlock")
assert 'block.className = "thinking"' in think
assert "block.open = false" in think, "the phase-17 restore convention: collapsed"
assert 'summary.textContent = "Thinking"' in think
assert "renderMarkdown(thinking)" in think, "the raw reasoning is markdown-rendered"
assert 'textEl.className = "thinking-text"' in think
# The tool lines (phase 37): the exact app.js template strings (the
# frontend emoji guard strips precisely these two literals here).
tools = _fn(js, "addToolLines")
assert 'container.className = "tool-calls"' in tools
assert '"🔎 Listing documents"' in tools
assert '"📄 Reading "' in tools
assert "code.textContent = argument" in tools, "the path is data, never markup"
# Deflection: the class + the plain-text "Maybe try" chips.
assert 'wrap.classList.add("is-deflected")' in body
maybe = _fn(js, "addMaybeTry")
assert 'group.className = "maybe-try"' in maybe
assert 'chip.className = "suggestion-chip"' in maybe
assert 'chip.setAttribute("role", "listitem")' in maybe
assert "chip.textContent = text" in maybe, "XSS contract: textContent only"
# Sources: plain text spans (the label is data).
sources = _fn(js, "addSources")
assert 'meta.className = "msg-meta"' in sources
assert 'chip.className = "source-chip"' in sources
assert "chip.textContent = label" in sources
assert "chip.title = label" in sources
# The stopped note (phase 48): the local copy of the chat markup.
stopped = _fn(js, "addStoppedNote")
assert 'note.className = "stopped-note"' in stopped
assert 'label.textContent = "Stopped"' in stopped
assert "rect x=\"6.5\" y=\"6.5\" width=\"11\" height=\"11\" rx=\"2\"" in stopped
def test_zero_interactive_controls_in_the_renderer() -> None:
"""Owner-locked zero controls, pinned at source level:
renderSharedMessage (and the whole file) never calls the chat
page's interactive builders, never creates a button/form/anchor,
and binds no click handler — the chips are spans, the source chips
carry no href, and the document modal is never wired."""
js = _js()
for name in ("renderChips", "appendTuneButton", "appendRetryButton",
"openDocumentModal", "openTuneForm"):
assert name not in js, (
f"shared.js must never reference {name} (the chat page's interactive path)"
)
for marker in ('createElement("button")', 'createElement("form")',
'createElement("a")', 'addEventListener("click"'):
assert marker not in js, f"no interactive markup: {marker}"
assert "chip.href" not in js and ".href =" not in js, (
"the source chips carry no href (guests cannot open documents)"
)
assert "document-modal" not in js, "no document-modal wiring on the shared page"
# The rendered message wrapper: the .msg/.bubble structure only.
add = _fn(js, "addSharedMessage")
assert "wrap.className = `msg ${who}`" in add
assert '<div class="bubble">' in add
def test_title_and_defensive_filter_on_the_200_path() -> None:
"""renderSharedChat: the h1 gets the title only when it is a
non-blank string (the static fallback stays otherwise — and on a
failed read the function is never called, so the fallback keeps);
the same defensive record filter as the chat page's restore keeps
a corrupted row from poisoning the render."""
js = _js()
body = _fn(js, "renderSharedChat")
assert "typeof data.title === \"string\"" in body
assert "titleEl.textContent = title" in body
assert 'm.who === "user" || m.who === "brain"' in body
assert 'typeof m.text === "string"' in body
assert "renderSharedMessage(m)" in body
def test_brand_note_resolves_at_call_time() -> None:
"""Phase 39: the note reads window.BOR_BRAND through the house
brand() fallback (exactly one copy of the literal in the file —
the brand() fallback), so a label set after the /api/config fetch
lands carries the configured name."""
js = _js()
assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js
assert js.count('"Brain of Reese"') == 1, (
"the literal must appear only in the brand() fallback"
)
# ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered
46rem chat column (the conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged)."""
css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the PLAN §7 centered chat column"
assert "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body
def test_shared_page_title_note_and_invalid_css() -> None:
"""#shared-title (the page-head h1 size), .shared-note (the muted
meta line under the h1), and #shared-invalid (a centered muted
block — the not-found language: surface card, --line border,
italic ink-soft)."""
css = _css()
title = re.search(r"#shared-title \{([^}]*)\}", css)
assert title and "font-size: 1.7rem" in title.group(1)
note = re.search(r"\.shared-note \{([\s\S]*?)\n\}", css)
assert note and "var(--ink-soft)" in note.group(1), (
"the note is the muted meta line"
)
invalid = re.search(r"#shared-invalid \{([\s\S]*?)\n\}", css)
assert invalid, "the invalid state must be styled"
ibody = invalid.group(1)
for decl in (
"text-align: center",
"var(--ink-soft)",
"font-style: italic",
"background: var(--surface)",
"border: 1px solid var(--line)",
):
assert decl in ibody, f"#shared-invalid missing {decl!r}"
def test_static_chips_are_text_only_in_the_shared_scope() -> None:
"""The guest's chips are plain text (owner-locked zero controls):
the pill families' pointer treatments are scoped OFF in
.shared-shell and only there — the chat page's interactive chips
keep their styles untouched."""
css = _css()
block = re.search(
r"\.shared-shell \.suggestion-chip,\n\.shared-shell \.source-chip \{([\s\S]*?)\n\}",
css,
)
assert block, "the static-chip rule must scope both chip families"
body = block.group(1)
assert "pointer-events: none" in body, "no pointer (the hover rules die with it)"
assert "cursor: default" in body, "no cursor"
# The interactive treatments live OUTSIDE the shared scope
# (the chat page's chips are untouched).
hover = re.search(r"\.suggestion-chip:hover \{([^}]*)\}", css)
assert hover, "the chat page's chip hover must stay"
assert "pointer-events: none" not in (hover.group(1) or "")
def test_shared_page_mobile_squeeze() -> None:
"""≤640px (the phase-07 contract): the shared title + note step
down (the empty-state-title family); the shell keeps its column
and the global .msg-body 92% override applies."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert "#shared-title { font-size: 1.35rem; }" in mbody
assert ".shared-note { font-size: 0.88rem; }" in mbody
+4 -2
View File
@@ -22,8 +22,9 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: All seven pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page).
#: All eight pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page +
#: phase 51's shared page).
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -32,6 +33,7 @@ PAGES = (
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
)