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

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+303
View File
@@ -16,10 +16,12 @@ Requires: podman compose up -d db
"""
from __future__ import annotations
import re
import time
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
@@ -27,12 +29,21 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.main import app as fastapi_app
from app.models import SavedChat
FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes"
#: The share link's shape: the page path + a canonical (lowercase) UUID.
SHARE_URL_RE = re.compile(
r"^/shared/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
#: The PUBLIC read's exact key set — no id, no timestamps, no token.
SHARED_OUT_KEYS = {"title", "messages"}
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
#: key present; the round-trip test asserts it survives byte-identical.
FULL_BRAIN: dict[str, Any] = {
@@ -110,7 +121,11 @@ def test_anonymous_every_route_returns_403(client: TestClient) -> None:
("GET", f"/api/chats/{unknown}", None),
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
("DELETE", f"/api/chats/{unknown}", None),
("POST", f"/api/chats/{unknown}/share", None),
("POST", f"/api/chats/{unknown}/unshare", None),
]
# The public read is NOT in this list — it is anonymous by design
# (a wrong token 404s there, it never 403s).
for method, path, body in cases:
r = anon.request(method, path, json=body)
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
@@ -444,3 +459,291 @@ def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
# ---------- share / unshare / public read (phase 51, task 01) ----------
def _share(admin_client: TestClient, chat_id: str) -> dict[str, Any]:
r = admin_client.post(f"/api/chats/{chat_id}/share")
assert r.status_code == 200
return r.json()
def _stored_token(db: Session, chat_id: str) -> uuid.UUID | None:
"""The row's ``share_token`` as seen by a fresh DB read."""
row = db.get(SavedChat, uuid.UUID(chat_id))
assert row is not None, "the chat row must exist"
return row.share_token
def test_share_returns_200_with_share_url_and_is_idempotent(
admin_client: TestClient, db: Session
) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
body1 = _share(admin_client, created["id"])
assert set(body1) == {"chat_id", "share_url"}
assert body1["chat_id"] == created["id"]
assert SHARE_URL_RE.fullmatch(body1["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body1['share_url']}"
)
token = uuid.UUID(body1["share_url"].removeprefix("/shared/"))
# Persisted on the row (the A10 extension unchanged: same row, one
# new column — no new table).
assert _stored_token(db, created["id"]) == token
# Idempotent: a re-share returns the SAME token, unchanged.
body2 = _share(admin_client, created["id"])
assert body2 == body1
assert _stored_token(db, created["id"]) == token
def test_share_leaves_updated_at_unchanged(admin_client: TestClient) -> None:
"""Sharing is not a content edit — the token is written with a Core
``update()`` that skips the ORM ``onupdate``, so the History page's
"latest activity first" order follows content edits only."""
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
updated_before = created["updated_at"]
time.sleep(0.1) # now() has µs resolution — make a bump observable
_share(admin_client, created["id"])
r = admin_client.get(f"/api/chats/{created['id']}")
assert r.json()["updated_at"] == updated_before, (
"share must not bump updated_at"
)
def test_unshare_revokes_the_link_and_is_idempotent(
admin_client: TestClient, db: Session
) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
share_url = _share(admin_client, created["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
assert anon.get(f"/api{share_url}").status_code == 200 # live, pre-revoke
r = admin_client.post(f"/api/chats/{created['id']}/unshare")
assert r.status_code == 200
assert r.json() == {"chat_id": created["id"], "shared": False}
# The token is NULL in the DB and the public read now 404s.
assert _stored_token(db, created["id"]) is None
revoked = anon.get(f"/api{share_url}")
assert revoked.status_code == 404
assert revoked.json() == {"detail": "unknown or revoked share link"}
# Idempotent: unsharing an unshared chat is a clean 200 (no write).
r2 = admin_client.post(f"/api/chats/{created['id']}/unshare")
assert r2.status_code == 200
assert r2.json() == {"chat_id": created["id"], "shared": False}
assert _stored_token(db, created["id"]) is None
def test_unshare_leaves_updated_at_unchanged(admin_client: TestClient) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
updated_before = created["updated_at"]
_share(admin_client, created["id"])
time.sleep(0.1)
admin_client.post(f"/api/chats/{created['id']}/unshare")
r = admin_client.get(f"/api/chats/{created['id']}")
assert r.json()["updated_at"] == updated_before, (
"unshare must not bump updated_at"
)
def test_public_read_returns_snapshot_without_private_keys(
admin_client: TestClient,
) -> None:
"""A fresh anonymous client reads the shared chat: title + messages
round-trip, and the body carries NONE of the admin-surface keys
(no id, no timestamps, no token — a content snapshot, not a handle)."""
created = admin_client.post(
"/api/chats",
json={
"title": EXPLICIT_TITLE,
"messages": [_user(FIRST_QUESTION), FULL_BRAIN],
},
).json()
share_url = _share(admin_client, created["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{share_url}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS
assert body["title"] == EXPLICIT_TITLE
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
admin_client: TestClient,
) -> None:
"""Wrong (never issued) and revoked (unshared) tokens 404 with the
SAME detail — no enumeration between the two cases."""
anon = TestClient(fastapi_app)
wrong = anon.get(f"/api/shared/{uuid.uuid4()}")
assert wrong.status_code == 404
assert wrong.json() == {"detail": "unknown or revoked share link"}
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
share_url = _share(admin_client, created["id"])["share_url"]
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
revoked = anon.get(f"/api{share_url}")
assert revoked.status_code == 404
assert revoked.json() == wrong.json() # one message, both cases
def test_public_read_malformed_token_returns_422() -> None:
anon = TestClient(fastapi_app)
assert anon.get("/api/shared/not-a-uuid").status_code == 422
def test_share_and_unshare_unknown_chat_return_404(admin_client: TestClient) -> None:
unknown = uuid.uuid4()
r = admin_client.post(f"/api/chats/{unknown}/share")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
r = admin_client.post(f"/api/chats/{unknown}/unshare")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
# ---------- create-with-share (phase 51, task 02 — the save-then-share
# contract: one request saves AND shares; unshared shapes carry NO
# ``share_url`` key at all — absent, not null) ----------
def test_create_with_share_sets_token_in_the_same_commit(
admin_client: TestClient, db: Session
) -> None:
"""``POST /api/chats`` with ``share: true``: the 201 body carries
``share_url`` (the ONLY extra key — the shape is OUT_KEYS +
``share_url``), matching the token shape, and the row's
``share_token`` is persisted in the SAME commit (one INSERT — no
second request, no window where the row is saved but unshared)."""
r = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
)
assert r.status_code == 201
body = r.json()
assert set(body) == OUT_KEYS | {"share_url"}
assert SHARE_URL_RE.fullmatch(body["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body['share_url']}"
)
token = uuid.UUID(body["share_url"].removeprefix("/shared/"))
assert _stored_token(db, body["id"]) == token
def test_create_with_share_is_immediately_publicly_readable(
admin_client: TestClient,
) -> None:
"""The save-then-share contract's payoff: the row is readable
ANONYMOUSLY the moment the 201 lands (no second step)."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{created['share_url']}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS
assert body["messages"] == _expect(_simple_conversation())
def test_create_without_share_has_no_share_url(admin_client: TestClient) -> None:
"""The default (``share`` absent or false) is byte-for-byte the
phase-50 shape: NO ``share_url`` key in the create body, the get
body, or the list row — absent, not ``null``."""
r = admin_client.post("/api/chats", json={"messages": _simple_conversation()})
assert r.status_code == 201
created = r.json()
assert "share_url" not in created
assert set(created) == OUT_KEYS
got = admin_client.get(f"/api/chats/{created['id']}").json()
assert "share_url" not in got and set(got) == OUT_KEYS
row = admin_client.get("/api/chats").json()["chats"][0]
assert "share_url" not in row and set(row) == ROW_KEYS
def test_create_share_false_is_explicitly_unshared(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": False}
)
assert r.status_code == 201
assert "share_url" not in r.json(), "share: false is a plain Save (phase-50 shape)"
def test_list_rows_carry_share_url_only_when_shared(
admin_client: TestClient,
) -> None:
"""The list endpoint populates ``share_url`` — so the History
column renders straight from ``GET /api/chats`` (no second fetch
per row): shared rows carry it (token shape), unshared rows omit it
(the row shape is exactly ROW_KEYS)."""
shared = admin_client.post(
"/api/chats",
json={"title": "Shared one", "messages": _simple_conversation(), "share": True},
).json()
plain = admin_client.post(
"/api/chats",
json={"title": "Plain one", "messages": _simple_conversation()},
).json()
rows = {c["id"]: c for c in admin_client.get("/api/chats").json()["chats"]}
assert SHARE_URL_RE.fullmatch(rows[shared["id"]]["share_url"])
assert set(rows[shared["id"]]) == ROW_KEYS | {"share_url"}
assert "share_url" not in rows[plain["id"]]
assert set(rows[plain["id"]]) == ROW_KEYS
def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> None:
"""``GET /{chat_id}`` carries ``share_url`` while shared (the same
path as the create body) and drops the key after ``unshare`` — the
full-payload shape returns to the phase-50 OUT_KEYS."""
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
).json()
got = admin_client.get(f"/api/chats/{created['id']}").json()
assert got["share_url"] == created["share_url"]
assert set(got) == OUT_KEYS | {"share_url"}
assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
got2 = admin_client.get(f"/api/chats/{created['id']}").json()
assert "share_url" not in got2
assert set(got2) == OUT_KEYS
# ---------- the /shared/<token> page route (phase 51, task 01) ----------
def test_page_route_missing_shared_html_returns_same_404_json(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Stale-deploy guard: a static dir WITHOUT ``shared.html`` (the
page lands in task 03) 404s with the SAME JSON as the API — never a
500, regardless of the token."""
monkeypatch.setattr(
"app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path))
)
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "unknown or revoked share link"}
def test_page_route_serves_shared_html_when_present(
client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Once the file exists (task 03), the route serves it for any well-
formed token — token validity is the page's own concern (it fetches
the API and renders the "invalid or revoked" state itself)."""
(tmp_path / "shared.html").write_text("<html>shared page</html>", encoding="utf-8")
monkeypatch.setattr(
"app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path))
)
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert r.text == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"]