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