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