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) Failing after 9s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the envs, we need the security values to be in the UI."* Overrules the call I made yesterday, and rule 25 is on your side — I argued deployment-topology, but the operator has to be able to SEE what protects them, and reading a container's environment is not seeing. Six new settings in a **Security** group: trusted proxy hops (default 1), the per-account and per-address sign-in limits with their shared window, and the sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate limits are no longer hardcoded constants. **The hard part was keeping the throttle cheap.** It consults these BEFORE opening a database connection — deliberately, because a refused attempt is meant to cost nothing, and the hop count is needed to know who is even asking. A query per attempt would undo both. So there is a small cache seeded from the registry defaults (the app works with no database at all, which is what the DB-free unit lane relies on), loaded at boot, and refreshed on every settings save — the same live-update contract `session_ttl_days` already had. `SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so a saved number applies to the next attempt instead of the next deploy. **Bounds are rejected, not clamped.** A hop count of 99 would trust anything a caller sent; a sign-in limit of 0 would lock every account out permanently. Both now fail validation with a message naming the range, and the number input carries min/max so the browser objects first. Silently storing a different number than the one typed is how somebody ends up believing a protection is set to something it is not. `MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself rather than the app from a caller, and there is no operator judgment to apply. Two integration tests, because the whole point is the round trip: a dangerous value refused, a legitimate one reaching the cache the throttle reads and persisting; and every Security row reaching the admin payload with bounds and a description that explains itself.
417 lines
16 KiB
Python
417 lines
16 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, live, refresh_live, reset_live, 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
|
|
|
|
|
|
async def test_security_settings_are_live_and_bounded(app_client, db):
|
|
"""The security values are settings now, not constants — so saving one has to take
|
|
effect without a restart, and a dangerous value has to be refused.
|
|
|
|
Real database because the whole point is the round trip: write through the admin
|
|
API, re-read into the cache the throttle consults, observe the new number.
|
|
"""
|
|
# An admin to authenticate as. First account, so it is allowed and becomes admin.
|
|
reset_live()
|
|
created = await app_client.post(
|
|
"/api/auth/register",
|
|
json={"email": "admin@example.test", "password": "a-long-enough-password"},
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
# Defaults are what the registry says.
|
|
async with session_scope() as fresh:
|
|
await refresh_live(fresh)
|
|
assert live("trusted_proxy_hops") == 1
|
|
assert live("signin_limit_per_account") == 10
|
|
|
|
# A value that would disable the protection is REFUSED, not clamped — storing a
|
|
# different number than the one typed is how somebody ends up believing a limit
|
|
# is set to something it is not.
|
|
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
|
|
assert bad.status_code == 400
|
|
assert "at least" in (await bad.get_json())["error"]
|
|
|
|
# …and so is a hop count that would trust anything a caller sent.
|
|
bad_hops = await app_client.patch("/api/settings", json={"trusted_proxy_hops": 99})
|
|
assert bad_hops.status_code == 400
|
|
|
|
# A legitimate change applies to the cache the throttle reads, immediately.
|
|
ok = await app_client.patch(
|
|
"/api/settings", json={"signin_limit_per_account": 3, "trusted_proxy_hops": 2}
|
|
)
|
|
assert ok.status_code == 200
|
|
assert live("signin_limit_per_account") == 3
|
|
assert live("trusted_proxy_hops") == 2
|
|
|
|
# And it is persisted, not just cached.
|
|
async with session_scope() as fresh:
|
|
assert await get_setting(fresh, "trusted_proxy_hops") == 2
|
|
|
|
reset_live()
|
|
|
|
|
|
async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
|
"""Every security value has to be visible and editable, which is the whole reason
|
|
they moved out of the environment."""
|
|
created = await app_client.post(
|
|
"/api/auth/register",
|
|
json={"email": "admin2@example.test", "password": "a-long-enough-password"},
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
resp = await app_client.get("/api/settings")
|
|
assert resp.status_code == 200
|
|
rows = (await resp.get_json())["settings"]
|
|
security = {r["key"]: r for r in rows if r["group"] == "Security"}
|
|
|
|
assert set(security) == {
|
|
"trusted_proxy_hops",
|
|
"signin_limit_per_account",
|
|
"signin_limit_per_address",
|
|
"signin_window_minutes",
|
|
"register_limit_per_address",
|
|
"register_window_minutes",
|
|
}
|
|
# The UI renders a number input from these, and it cannot offer a safe range it
|
|
# was never told about.
|
|
for row in security.values():
|
|
assert row["type"] == "int"
|
|
assert row["minimum"] is not None and row["maximum"] is not None
|
|
assert row["description"], f"{row['key']} has no description to explain itself"
|