feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user