CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get granted admin privileges. then registration is closed."* The old shape had a window in it. The first account was always allowed and became admin; every account after that was gated by `allow_registration` — which defaulted to ON. So the door stayed open between "my account exists" and "I remembered to turn it off in Settings", and on a public host that gap is the entire exposure: it starts the moment DNS resolves and lasts until someone remembers. Now the door shuts as a CONSEQUENCE of the admin account existing, in the same transaction that creates it. Not "defaults closed" — that would still need the first person to get in somehow. There is no window to remember, because there is no window. Re-opening it is a deliberate act in Settings → Access: turn it on, have the person register, turn it off. Crude, and it is the only mechanism there is — **there is no invite system**, not even a stub. That is real work (a token table, admin create/revoke, a redemption flow, expiry) and is filed as later work rather than smuggled into a release. An integration test covers it, because it is the interaction between two writes in one transaction: first register → 201 and `is_admin: true`; the setting is then false; a second register → 403; re-open deliberately and a third → 201, not admin. **This does not retroactively close an instance that already has users.** The close fires on first-account creation, so a server whose admin predates this keeps whatever the setting was — which was on. `docs/public-hosting.md` now says so explicitly, and step 1 of the checklist is "check" rather than "do" for exactly that reason.
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""The real-Postgres lane (family rule 6).
|
|
|
|
Everything else in this suite is deliberately DB-free, which means the schema the
|
|
migrations build has never been checked against the models that read it. That gap is
|
|
what this file closes, and it is not theoretical: M13 dropped three columns and
|
|
rebuilt a generated column, and until now `alembic upgrade head` ran for the first
|
|
time when the operator's container started.
|
|
|
|
Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a
|
|
workstation without Postgres runs the rest of the suite unchanged.
|
|
|
|
The schema comes from real migrations, never `metadata.create_all` (rule 82) — the
|
|
point is to test what actually ships, and `create_all` would build a schema no
|
|
deployment has ever seen.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import select, text
|
|
|
|
from thoughtsync import ratelimit
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.db import dispose_engine, session_scope
|
|
from thoughtsync.models.note import Note
|
|
from thoughtsync.models.note_item import NoteItem
|
|
from thoughtsync.models.user import User
|
|
from thoughtsync.settings import get_setting, set_settings
|
|
from thoughtsync.notes.helpers import derive_display_title
|
|
from thoughtsync.models.note_link_preview import NoteLinkPreview
|
|
from thoughtsync.sync import _apply_note_items
|
|
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
# Every table the tests touch, child-first so FKs never block the truncate.
|
|
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
|
_TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users"
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def db():
|
|
"""A session against the migrated database, wiped before each test.
|
|
|
|
Wiped BEFORE rather than after so a failed test leaves its rows behind to look at.
|
|
"""
|
|
async with session_scope() as session:
|
|
await session.execute(text(f"TRUNCATE {_TABLES} RESTART IDENTITY CASCADE"))
|
|
await session.commit()
|
|
yield session
|
|
await dispose_engine()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def app_client(db):
|
|
"""A test client against the real app, over the migrated database.
|
|
|
|
The credential throttle is process-global and its counters outlive a single
|
|
test, so they are cleared here — otherwise a suite that registers a few times
|
|
starts handing out 429s for reasons that have nothing to do with the test.
|
|
"""
|
|
ratelimit.reset_all()
|
|
yield create_app().test_client()
|
|
ratelimit.reset_all()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def owner(db):
|
|
"""A user to hang notes off — `notes.owner_id` is a real foreign key."""
|
|
user = User(email=f"{uuid.uuid4().hex}@example.test", display_name="Integration")
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
async def test_the_migrated_schema_matches_the_models(db, owner):
|
|
"""The check that has never run: insert through the ORM, read it back.
|
|
|
|
A column the models expect and the migrations never created — or the reverse —
|
|
fails right here, instead of when a container starts.
|
|
"""
|
|
note = Note(owner_id=owner.id, body="a thought", display_title="a thought")
|
|
db.add(note)
|
|
await db.commit()
|
|
await db.refresh(note)
|
|
|
|
found = await db.scalar(select(Note).where(Note.id == note.id))
|
|
assert found is not None
|
|
assert found.body == "a thought"
|
|
assert found.display_title == "a thought"
|
|
|
|
|
|
async def test_the_dropped_columns_are_actually_gone(db):
|
|
"""M13 dropped three. If a migration silently no-opped, this is where it shows."""
|
|
cols = set(
|
|
(
|
|
await db.execute(
|
|
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'notes'")
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert "title" not in cols, "notes.title should have gone in 0026"
|
|
assert "kind" not in cols, "notes.kind should have gone in 0025"
|
|
assert "display_title" in cols and "body" in cols
|
|
|
|
rev_cols = set(
|
|
(
|
|
await db.execute(
|
|
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'note_revisions'")
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert "title" not in rev_cols, "note_revisions.title should have gone in 0026"
|
|
|
|
tables = set(
|
|
(await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert "note_links" not in tables, "note_links should have gone in 0024"
|
|
|
|
|
|
async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
|
|
"""0026 had to drop and recreate a STORED GENERATED column.
|
|
|
|
Postgres refuses to drop a column another generated column depends on, so getting
|
|
this wrong doesn't produce a subtly wrong ranking — it produces a migration that
|
|
won't run at all. Worth proving the replacement actually indexes something.
|
|
"""
|
|
note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets")
|
|
db.add(note)
|
|
await db.commit()
|
|
|
|
hit = await db.scalar(
|
|
text(
|
|
"SELECT count(*) FROM notes "
|
|
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
|
|
).bindparams(q="ferry")
|
|
)
|
|
assert hit == 1
|
|
|
|
# The NAME is weight A and the body weight B, which is what makes a name match
|
|
# rank above a body-only one. Both must be in the vector at all.
|
|
body_only = await db.scalar(
|
|
text(
|
|
"SELECT count(*) FROM notes "
|
|
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
|
|
).bindparams(q="friday")
|
|
)
|
|
assert body_only == 1
|
|
|
|
|
|
async def test_a_note_keeps_both_its_body_and_its_items(db, owner):
|
|
"""The shape M13 step 2 made normal: a note HAS a checklist, it isn't one."""
|
|
note = Note(owner_id=owner.id, body="weekend shop", display_title="weekend shop")
|
|
db.add(note)
|
|
await db.flush()
|
|
db.add_all(
|
|
[
|
|
NoteItem(note_id=note.id, text="milk", position=0),
|
|
NoteItem(note_id=note.id, text="eggs", position=1),
|
|
]
|
|
)
|
|
await db.commit()
|
|
|
|
items = (
|
|
await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position))
|
|
).all()
|
|
assert [i.text for i in items] == ["milk", "eggs"]
|
|
assert (await db.scalar(select(Note.body).where(Note.id == note.id))) == "weekend shop"
|
|
|
|
|
|
async def test_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner):
|
|
"""The data-loss path step 2 removed, pinned against a real database.
|
|
|
|
`_apply_note_items` used to delete every item when the note wasn't `kind = "list"`.
|
|
Nothing can produce that state any more, but this is the regression that would
|
|
have silently eaten a checklist, and it deserves a test that would catch its
|
|
return.
|
|
"""
|
|
note = Note(owner_id=owner.id, body="packing", display_title="packing")
|
|
db.add(note)
|
|
await db.flush()
|
|
db.add(NoteItem(note_id=note.id, text="socks", position=0))
|
|
await db.commit()
|
|
|
|
# A change that says nothing about items must LEAVE them alone — absent means
|
|
# "not telling us", not "empty".
|
|
await _apply_note_items(db, note, {"body": "packing"})
|
|
await db.commit()
|
|
assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks"
|
|
|
|
# An explicit list replaces them.
|
|
await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]})
|
|
await db.commit()
|
|
rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
|
assert [(r.text, r.checked) for r in rows] == [("charger", True)]
|
|
|
|
|
|
async def test_a_note_with_only_items_still_has_a_name(db, owner):
|
|
"""The hole that made removing the title unsafe until step 2 closed it."""
|
|
note = Note(owner_id=owner.id, body="", display_title="")
|
|
db.add(note)
|
|
await db.flush()
|
|
db.add(NoteItem(note_id=note.id, text="milk", position=0))
|
|
await db.commit()
|
|
|
|
first = await db.scalar(
|
|
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
|
|
)
|
|
note.display_title = derive_display_title(note.body, first)
|
|
await db.commit()
|
|
|
|
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
|
|
|
|
|
|
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
|
|
"""The background pass, run inline so the assertions are deterministic.
|
|
|
|
The network is stubbed — this is about what reaches the DATABASE, not about
|
|
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
|
|
here is the part only a real database can show: the unique constraint holding, the
|
|
upsert going to the right row, and a second pass not re-fetching.
|
|
"""
|
|
note = Note(
|
|
owner_id=owner.id,
|
|
body="read https://example.com/a and https://example.com/b",
|
|
display_title="read https://example.com/a and https://example.com/b",
|
|
)
|
|
db.add(note)
|
|
await db.commit()
|
|
|
|
calls: list[str] = []
|
|
|
|
async def fake_unfurl(url):
|
|
calls.append(url)
|
|
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
|
|
|
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
|
|
|
await _unfurl_new_urls(note.id, note.body)
|
|
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
|
|
|
|
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
|
assert {r.url for r in rows} == {"https://example.com/a", "https://example.com/b"}
|
|
assert all(r.title.startswith("T ") for r in rows)
|
|
|
|
# A second pass over an unchanged body fetches nothing — the whole reason
|
|
# `schedule` is safe to call on every save.
|
|
calls.clear()
|
|
await _unfurl_new_urls(note.id, note.body)
|
|
assert calls == []
|
|
|
|
|
|
async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, monkeypatch):
|
|
"""A slow fetch must not resurrect a link the person deleted mid-flight."""
|
|
note = Note(owner_id=owner.id, body="https://example.com/gone", display_title="x")
|
|
db.add(note)
|
|
await db.commit()
|
|
|
|
async def fake_unfurl(url):
|
|
# Simulate the body changing while the request was in the air.
|
|
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
|
|
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
|
note.body = "changed my mind"
|
|
await db.commit()
|
|
|
|
await _unfurl_new_urls(note.id, "https://example.com/gone")
|
|
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
|
assert rows == [], "a preview was stored for a URL the note no longer contains"
|
|
|
|
|
|
async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
|
|
"""The detector and the storage path read the same body the same way."""
|
|
body = "one https://example.com/x. two (https://example.com/y) three"
|
|
assert detect_urls(body) == ["https://example.com/x", "https://example.com/y"]
|
|
|
|
note = Note(owner_id=owner.id, body=body, display_title="one")
|
|
db.add(note)
|
|
await db.commit()
|
|
|
|
async def fake_unfurl(url):
|
|
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
|
|
|
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
|
await _unfurl_new_urls(note.id, body)
|
|
|
|
stored = {
|
|
r for r in (await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note.id))).all()
|
|
}
|
|
assert stored == set(detect_urls(body))
|
|
|
|
|
|
async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
|
|
"""The gap this removes: registration was open between "my account exists" and
|
|
"I remembered to turn it off", and on a public host that gap starts at DNS.
|
|
|
|
Runs against a real database because it is the interaction between two writes —
|
|
the user row and the settings row — inside one transaction.
|
|
"""
|
|
# The instance is empty (the fixture truncated it), so this is the first account:
|
|
# allowed unconditionally, and it becomes the admin.
|
|
first = await app_client.post(
|
|
"/api/auth/register",
|
|
json={"email": "owner@example.test", "password": "a-long-enough-password"},
|
|
)
|
|
assert first.status_code == 201
|
|
assert (await first.get_json())["is_admin"] is True
|
|
|
|
# …and the door shut behind it.
|
|
async with session_scope() as fresh:
|
|
assert await get_setting(fresh, "allow_registration") is False
|
|
|
|
second = await app_client.post(
|
|
"/api/auth/register",
|
|
json={"email": "stranger@example.test", "password": "a-long-enough-password"},
|
|
)
|
|
assert second.status_code == 403
|
|
|
|
# Re-opening it deliberately still works — that is how a second person gets in
|
|
# until invites exist.
|
|
async with session_scope() as fresh:
|
|
await set_settings(fresh, {"allow_registration": True})
|
|
await fresh.commit()
|
|
|
|
third = await app_client.post(
|
|
"/api/auth/register",
|
|
json={"email": "invited@example.test", "password": "a-long-enough-password"},
|
|
)
|
|
assert third.status_code == 201
|
|
assert (await third.get_json())["is_admin"] is False
|