Files
inkwell/tests/test_integration.py
T
bvandeusenandClaude Opus 5.5 63955bbe97 sync: recipients keep their own pin, archive and place on shared notes
A note_user_state row per (note, recipient) holds what used to be the owner's
columns as far as anyone else could tell. The board filters and orders through
the viewer's own state; PATCH and reorder write it for a note shared at any
level; the feed's revision for a shared note is the later of the note's and the
caller's row, so a recipient's pin reaches their devices and no one else's.

Push takes the three with their own `state_at` stamp (protocol 7,
`shared_state`), so pinning a copy whose text is behind never makes that text
win over the owner's edit. A body is only stamped as an edit when it changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00

1625 lines
73 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 asyncio
import hashlib
import re
import smtplib
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import delete, func, select, text, update
from inkwell import mailer, ratelimit
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset
from inkwell.models.settings import Setting
from inkwell.models.share import Share
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
from inkwell.models.user import User
from inkwell.notes.tags import _lift_and_reconcile_tags
from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
from inkwell.notes.checklist import parse_items, set_item_checked
from inkwell.notes.helpers import derive_display_title
from inkwell.models.note_link_preview import NoteLinkPreview
from inkwell.models.note_revision import NoteRevision
from inkwell.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from inkwell.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_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, invites, password_resets, 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_its_prose_on_both_sides_of_its_list(db, owner):
"""The shape M304 made expressible at all.
The old model could not hold this: a row had a position in a table and none in the
text, so a checklist could only ever render AFTER the body. Prose, list, prose is
the case that proves the storage changed, not just the styling.
"""
body = "weekend shop\n\n- [ ] milk\n- [x] eggs\n\nback before six"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert [(i.text, i.checked) for i in parse_items(stored)] == [("milk", False), ("eggs", True)]
assert stored.splitlines()[0] == "weekend shop"
assert stored.splitlines()[-1] == "back before six"
async def test_ticking_an_item_is_a_body_edit(db, owner):
"""What replaced `_apply_note_items`: there is no separate thing left to apply.
The regression that function guarded against — a sync silently eating a checklist
off a note that also had a body — cannot recur, because there is nothing to delete.
A pushed body either has the lines or it does not.
"""
body = "packing\n\n- [ ] socks"
note = Note(owner_id=owner.id, body=body, display_title="packing")
db.add(note)
await db.commit()
note.body = set_item_checked(note.body, 0, True)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert stored == "packing\n\n- [x] socks"
assert parse_items(stored)[0].checked
# The prose is untouched — a tick rewrites one line, not the note.
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is False
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is True
assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
the first line with its marker stripped, because calling the note "- [ ] milk"
would show someone the storage instead of the note.
"""
body = "- [ ] milk\n- [ ] eggs"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
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("inkwell.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("inkwell.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("inkwell.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, for an admin who would rather open the
# door than send an invite.
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",
"reset_emails_per_account",
}
# 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"
async def _revision_count(db, note_id) -> int:
rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all()
return len(rows)
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
# A session's worth of autosaves.
for text_ in ("one two", "one two three", "one two three four"):
if await should_snapshot(db, note.id, note.body, text_):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = text_
await db.commit()
assert await _revision_count(db, note.id) == 1
# And it is the body as it was BEFORE the sitting, not some midpoint — which is
# what makes one-per-session the useful granularity rather than an arbitrary one.
kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all()
assert kept == ["one"]
async def test_rewriting_the_same_text_is_not_a_version(db, owner):
note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged")
db.add(note)
await db.commit()
assert await should_snapshot(db, note.id, note.body, "unchanged") is False
assert await _revision_count(db, note.id) == 0
async def test_a_later_sitting_earns_its_own_revision(db, owner):
"""The window has to REOPEN, or a note edited daily would keep only its first
version forever — which would be a worse history than the one we replaced."""
note = Note(owner_id=owner.id, body="today", display_title="today")
db.add(note)
await db.flush()
# A revision from longer ago than one session: the clock is not mocked, the row
# is simply written with an older timestamp, which is what the query reads.
stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1)
db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "tomorrow") is True
async def test_a_revision_inside_the_window_blocks_another(db, owner):
note = Note(owner_id=owner.id, body="draft", display_title="draft")
db.add(note)
await db.flush()
db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc)))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
async def test_renaming_a_tag_onto_an_existing_one_merges_into_the_older(app_client, db):
"""Renaming a tag onto a name another tag holds merges them, and the OLDER row
is the survivor — whichever side the caller happened to be renaming.
Runs against a real database because the whole question is about `created_at`
ordering and the note_labels rows moving, neither of which a unit test sees.
Age decides, rather than "the one that already held the name", so that renaming
A→B and renaming B→A land on the same row. If the incumbent won, the survivor
would depend on which way round someone typed it, and two clients racing the
same tidy-up would disagree about which id still exists.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
# Two separate requests, so two transactions and two distinct `func.now()`s.
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
one = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
two = await (await app_client.post("/api/notes", json={"body": "stamps"})).get_json()
await app_client.put(f"/api/notes/{one['id']}/labels", json={"label_ids": [older["id"]]})
await app_client.put(f"/api/notes/{two['id']}/labels", json={"label_ids": [newer["id"]]})
# Rename the YOUNGER onto the older's name, with different casing — matching is
# case-insensitive, and the survivor must end up spelled the way we asked.
resp = await app_client.patch(f"/api/labels/{newer['id']}", json={"name": "Grocery"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "the older row is the one that keeps existing"
assert survivor["name"] == "Grocery", "the survivor takes the spelling that was asked for"
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1, "the two became one"
assert listing[0]["id"] == older["id"]
assert listing[0]["count"] == 2, "it carries every note from both sides"
async def test_the_rename_merge_survivor_does_not_depend_on_the_direction(app_client, db):
"""The mirror of the test above: rename the OLDER onto the younger's name. The
older still survives — it just changes its name — so the two directions agree."""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags2@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
resp = await app_client.patch(f"/api/labels/{older['id']}", json={"name": "errands"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "age wins in this direction too"
assert survivor["name"] == "errands"
assert newer["id"] != older["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [older["id"]]
async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one(app_client, db):
"""A case-sensitive match here used to mint "Groceries" beside "groceries". No
synced client can hold both — their `labels` index is unique on `lower(name)` —
so the pair was a pull that would fail later, on a phone, with no UI in the path.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags3@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
first = await app_client.post("/api/labels", json={"name": "groceries"})
assert first.status_code == 201
second = await app_client.post("/api/labels", json={"name": "Groceries"})
assert second.status_code == 200, "an existing tag is returned, not a second one made"
assert (await second.get_json())["id"] == (await first.get_json())["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1
# --- One save path for a note's text (#5164) ---------------------------------
#
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,
# queue link previews. It was written out once per route, and the copies drifted —
# restoring a revision skipped the previews. These pin the sequence at each door.
async def _signed_in(app_client, who: str):
reg = await app_client.post(
"/api/auth/register",
json={"email": f"{who}@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
def _record_previews(monkeypatch, module: str) -> list[tuple[str, str]]:
"""Capture what a write path queues for unfurling, instead of fetching."""
queued: list[tuple[str, str]] = []
monkeypatch.setattr(f"{module}.schedule_unfurls", lambda nid, body: queued.append((str(nid), body)))
return queued
async def test_restoring_a_revision_queues_previews_for_its_links(app_client, db, monkeypatch):
"""The bug that motivated the shared path: restore was the one copy of the edit
sequence without the unfurl step, so a link brought back by a restore stayed a
bare URL on every surface."""
await _signed_in(app_client, "restore")
queued = _record_previews(monkeypatch, "inkwell.notes")
note = await (await app_client.post("/api/notes", json={"body": "read https://example.com/a"})).get_json()
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "no link any more"})
revisions = (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"]
assert [r["body"] for r in revisions] == ["read https://example.com/a"]
queued.clear()
resp = await app_client.post(f"/api/notes/{note['id']}/revisions/{revisions[0]['id']}/restore")
assert resp.status_code == 200
assert (await resp.get_json())["body"] == "read https://example.com/a"
assert queued == [(note["id"], "read https://example.com/a")], "the restored link gets its preview"
async def test_restoring_keeps_the_text_it_replaces(app_client, db):
"""Restore snapshots unconditionally — inside an editing session too — so a
restore can itself be undone."""
await _signed_in(app_client, "undo")
note = await (await app_client.post("/api/notes", json={"body": "first"})).get_json()
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "second"})
rev = (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"][0]
await app_client.post(f"/api/notes/{note['id']}/revisions/{rev['id']}/restore")
bodies = [r["body"] for r in (await (await app_client.get(f"/api/notes/{note['id']}/revisions")).get_json())["revisions"]]
assert sorted(bodies) == ["first", "second"]
async def test_each_route_that_writes_text_queues_previews_only_when_it_changed(app_client, db, monkeypatch):
await _signed_in(app_client, "routes")
queued = _record_previews(monkeypatch, "inkwell.notes")
note = await (await app_client.post("/api/notes", json={"body": "https://example.com/new"})).get_json()
assert queued == [(note["id"], "https://example.com/new")], "create"
queued.clear()
await app_client.patch(f"/api/notes/{note['id']}", json={"pinned": True})
assert queued == [], "a pin is not a text change"
await app_client.patch(f"/api/notes/{note['id']}", json={"body": "https://example.com/new\n- [ ] milk"})
assert queued == [(note["id"], "https://example.com/new\n- [ ] milk")], "PATCH"
queued.clear()
await app_client.patch(f"/api/notes/{note['id']}/items/0", json={"checked": True})
assert queued == [(note["id"], "https://example.com/new\n- [x] milk")], "ticking an item"
async def test_a_pushed_note_is_named_tagged_and_queued_like_a_typed_one(app_client, db, monkeypatch):
await _signed_in(app_client, "push")
queued = _record_previews(monkeypatch, "inkwell.sync")
nid = str(uuid.uuid4())
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": "Trip https://example.com/t\n#travel",
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"][0]["status"] == "created"
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert note["body"] == "Trip https://example.com/t", "the standalone tag was lifted"
assert [lb["name"] for lb in note["labels"]] == ["travel"]
assert note["display_title"] == "Trip https://example.com/t"
assert queued == [(nid, "Trip https://example.com/t")], "queued with the text as stored"
async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(app_client, db):
"""Last-write-wins compares edit times, so the stored one has to be the client's.
Lifting a tag rewrites the body in a second flush, and the column's onupdate
used to stamp the server's clock over the time the client sent."""
await _signed_in(app_client, "edited")
nid = str(uuid.uuid4())
await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": "milk\n#groceries",
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert datetime.fromisoformat(note["updated_at"]) == datetime(2026, 1, 1, tzinfo=timezone.utc)
# --- attachments as a sync entity (#5168) ---------------------------------------
async def _note_revision(app_client, nid: str) -> int:
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
return next(n["sync_revision"] for n in feed["notes"] if n["id"] == nid)
async def _pushed_note(app_client, body: str = "with a file") -> str:
nid = str(uuid.uuid4())
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": body,
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"][0]["status"] == "created"
return nid
async def _put(app_client, aid: str, nid: str, raw: bytes, sha: str | None = None, name: str = "scan.pdf"):
return await app_client.put(
f"/api/sync/attachments/{aid}",
data=raw,
headers={"Content-Type": "application/pdf"},
query_string={"note_id": nid, "filename": name, "sha256": sha or hashlib.sha256(raw).hexdigest()},
)
async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, db):
await _signed_in(app_client, "upload")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
assert (await _put(app_client, aid, nid, b"%PDF-1", sha="0" * 64)).status_code == 400, "hash mismatch refused"
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
before = await _note_revision(app_client, nid)
first = await _put(app_client, aid, nid, b"%PDF-1")
assert first.status_code == 201
assert await _note_revision(app_client, nid) > before, "other devices hear about it"
again = await _put(app_client, aid, nid, b"%PDF-1")
assert again.status_code == 200 and (await again.get_json())["status"] == "exists", "a retried upload is a no-op"
atts = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"]
assert [(a["id"], a["filename"], a["mime"], a["sha256"]) for a in atts] == [
(aid, "scan.pdf", "application/pdf", hashlib.sha256(b"%PDF-1").hexdigest())
]
other = await _pushed_note(app_client, "another note")
assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note"
async def test_an_upload_to_a_note_the_caller_does_not_own_is_not_found(app_client, db):
# Signed in first: registration is open only to the first account.
await _signed_in(app_client, "intruder")
stranger = User(email="stranger@example.test", display_name="Stranger")
db.add(stranger)
await db.flush()
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
db.add(theirs)
await db.commit()
resp = await _put(app_client, str(uuid.uuid4()), str(theirs.id), b"x")
assert resp.status_code == 404
async def test_a_pushed_attachment_delete_removes_the_row_the_file_and_bumps_the_note(app_client, db):
await _signed_in(app_client, "remove")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
await _put(app_client, aid, nid, b"bytes")
stored = (await db.scalar(select(NoteAttachment).where(NoteAttachment.id == uuid.UUID(aid)))).path
assert (Config.media_root() / stored).is_file()
before = await _note_revision(app_client, nid)
resp = await app_client.post(
"/api/sync/push",
json={"changes": [{"entity": "attachment", "id": aid, "op": "delete", "edited_at": "2000-01-01T00:00:00Z"}]},
)
assert (await resp.get_json())["results"] == [{"id": aid, "entity": "attachment", "status": "applied"}]
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
assert not (Config.media_root() / stored).exists()
# The edit time above is older than the note's: a removal is not a version
# competing under last-write-wins, so it applies anyway.
assert await _note_revision(app_client, nid) > before, "the note re-syncs without it"
async def test_a_child_delete_cannot_reach_another_owners_rows(app_client, db):
await _signed_in(app_client, "prober")
stranger = User(email="other@example.test", display_name="Other")
db.add(stranger)
await db.flush()
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
db.add(theirs)
await db.flush()
att = NoteAttachment(note_id=theirs.id, path="x/y.bin", mime="application/octet-stream", size=1)
preview = NoteLinkPreview(note_id=theirs.id, url="https://example.com/")
db.add_all([att, preview])
await db.commit()
resp = await app_client.post(
"/api/sync/push",
json={"changes": [
{"entity": "attachment", "id": str(att.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
{"entity": "preview", "id": str(preview.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
{"entity": "preview", "id": str(uuid.uuid4()), "op": "upsert", "edited_at": "2030-01-01T00:00:00Z"},
]},
)
statuses = [r["status"] for r in (await resp.get_json())["results"]]
# Someone else's row answers exactly like a missing one: nothing to learn here.
assert statuses == ["noop", "noop", "rejected"]
assert await db.scalar(select(func.count()).select_from(NoteAttachment)) == 1
assert await db.scalar(select(func.count()).select_from(NoteLinkPreview)) == 1
async def test_a_preview_arriving_or_leaving_moves_its_note_in_the_feed(app_client, db):
"""0031: before it, a preview fetched after the save, or dismissed on the web,
never reached a device that had already pulled the note."""
await _signed_in(app_client, "previews")
nid = await _pushed_note(app_client, "read https://example.com/a")
before = await _note_revision(app_client, nid)
async with session_scope() as other: # as the background unfurl does
other.add(NoteLinkPreview(note_id=uuid.UUID(nid), url="https://example.com/a", title="A"))
await other.commit()
arrived = await _note_revision(app_client, nid)
assert arrived > before
preview_id = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["previews"][0]["id"]
await app_client.delete(f"/api/notes/{nid}/previews/{preview_id}")
assert await _note_revision(app_client, nid) > arrived
# --- the export format, pinned against the shared fixture (#5170) ---------------
async def test_an_export_writes_the_keys_the_shared_fixture_names(app_client, db):
"""The desktop exports offline with the core's copy of this format, and both
copies are held to core/testdata/portable.json. This is the server's side."""
import io
import json
import zipfile
from pathlib import Path
keys = json.loads(
(Path(__file__).resolve().parents[1] / "core" / "testdata" / "portable.json").read_text(encoding="utf-8")
)["export"]
await _signed_in(app_client, "exporter")
assert (await app_client.post("/api/labels", json={"name": "travel"})).status_code == 201
nid = await _pushed_note(app_client, "exported")
raw = b"%PDF-1"
assert (await _put(app_client, str(uuid.uuid4()), nid, raw)).status_code == 201
resp = await app_client.get("/api/notes/export")
assert resp.status_code == 200
with zipfile.ZipFile(io.BytesIO(await resp.get_data())) as zf:
doc = json.loads(zf.read("notes.json"))
assert sorted(doc) == sorted(keys["document"])
assert doc["app"] == "inkwell"
[note] = doc["notes"]
assert sorted(note) == sorted(keys["note"])
assert [sorted(lb) for lb in doc["labels"]] == [sorted(keys["label"])]
[att] = note["attachments"]
assert sorted(att) == sorted(keys["attachment"])
assert zf.read(att["file"]) == raw, "the listed file is in the archive"
async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db):
"""It used to be skipped as empty. The core's importer keeps it as well."""
import io
import json
import zipfile
from werkzeug.datastructures import FileStorage
await _signed_in(app_client, "keeper")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Takeout/Keep/Photo.json", json.dumps({"textContent": "", "attachments": [{"filePath": "p.png"}]}))
zf.writestr("Takeout/Keep/p.png", b"png bytes")
zf.writestr("Takeout/Keep/Empty.json", json.dumps({"textContent": " "}))
resp = await app_client.post(
"/api/notes/import", files={"file": FileStorage(io.BytesIO(buf.getvalue()), filename="takeout.zip")}
)
assert resp.status_code == 201
assert await resp.get_json() == {"source": "keep", "imported": 1, "skipped": 1}
[note] = (await db.scalars(select(Note))).all()
[att] = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
assert att.mime == "image/png"
# ---- invites (#5172) ---------------------------------------------------------------
_PASSWORD = "a-long-enough-password"
async def _admin_with_invite(app_client, **body) -> str:
"""Register the instance's first account (the admin, signed in on `app_client`)
and have it issue an invite. Returns the token."""
created = await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
assert created.status_code == 201
resp = await app_client.post("/api/invites", json=body)
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["token"]
async def _register(email: str, invite: str | None = None):
"""Register from a separate client, so the admin's session stays where it is."""
body = {"email": email, "password": _PASSWORD}
if invite is not None:
body["invite"] = invite
return await create_app().test_client().post("/api/auth/register", json=body)
async def test_an_invite_lets_one_person_in_while_registration_stays_closed(app_client, db):
token = await _admin_with_invite(app_client)
# Registration closed itself behind the admin; a stranger with no invite is out.
stranger = await _register("stranger@example.test")
assert stranger.status_code == 403
invited = await _register("guest@example.test", token)
assert invited.status_code == 201, await invited.get_data(as_text=True)
assert (await invited.get_json())["is_admin"] is False
# Single use: the same link again, for anyone, is refused with the generic answer.
again = await _register("someone-else@example.test", token)
assert again.status_code == 403
assert (await again.get_json())["error"] == "invalid or expired invite"
# The admin's list says who used it.
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
assert [(i["status"], i["redeemed_by_email"]) for i in listed] == [("redeemed", "guest@example.test")]
async with session_scope() as fresh:
assert await get_setting(fresh, "allow_registration") is False
async def test_only_an_admin_handles_invites(app_client, db):
token = await _admin_with_invite(app_client)
guest = create_app().test_client()
joined = await guest.post(
"/api/auth/register", json={"email": "guest@example.test", "password": _PASSWORD, "invite": token}
)
assert joined.status_code == 201
# Signed in as the guest now, who is not an admin.
assert (await guest.post("/api/invites", json={})).status_code == 403
assert (await guest.get("/api/invites")).status_code == 403
async def test_an_invite_pinned_to_an_email_admits_only_that_email(app_client, db):
token = await _admin_with_invite(app_client, email="Pinned@Example.test")
wrong = await _register("other@example.test", token)
assert wrong.status_code == 403
assert (await wrong.get_json())["error"] == "invalid or expired invite"
# Any capitalisation of the pinned address, since emails compare case-insensitively.
right = await _register("PINNED@example.test", token)
assert right.status_code == 201
async def test_revoked_and_expired_invites_are_refused(app_client, db):
revoked = await _admin_with_invite(app_client)
expiring = (await (await app_client.post("/api/invites", json={"days": 1})).get_json())["token"]
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
by_status = {i["status"] for i in listed}
assert by_status == {"pending"}
# The invite made first is the last in the list (newest first).
revoked_id = listed[-1]["id"]
resp = await app_client.delete(f"/api/invites/{revoked_id}")
assert resp.status_code == 200
assert (await resp.get_json())["status"] == "revoked"
async with session_scope() as fresh:
await fresh.execute(
update(Invite)
.where(Invite.id != uuid.UUID(revoked_id))
.values(expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
)
await fresh.commit()
assert (await _register("a@example.test", revoked)).status_code == 403
assert (await _register("b@example.test", expiring)).status_code == 403
statuses = sorted(i["status"] for i in (await (await app_client.get("/api/invites")).get_json())["invites"])
assert statuses == ["expired", "revoked"]
async def test_an_invite_lifetime_outside_the_bounds_is_refused(app_client, db):
await _admin_with_invite(app_client)
for days in (0, 31, "a week", True):
resp = await app_client.post("/api/invites", json={"days": days})
assert resp.status_code == 400, days
resp = await app_client.post("/api/invites", json={"email": "not-an-address"})
assert resp.status_code == 400
async def test_a_taken_email_leaves_the_invite_unused(app_client, db):
"""The redemption and the new account are one transaction: an account that can't
be created must not use up the link."""
token = await _admin_with_invite(app_client)
taken = await _register("owner@example.test", token)
assert taken.status_code == 409
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
assert listed[0]["status"] == "pending"
assert (await _register("guest@example.test", token)).status_code == 201
async def test_two_people_racing_one_invite_cannot_both_get_in(app_client, db):
token = await _admin_with_invite(app_client)
results = await asyncio.gather(
_register("first@example.test", token),
_register("second@example.test", token),
)
assert sorted(r.status_code for r in results) == [201, 403]
async with session_scope() as fresh:
count = await fresh.scalar(select(func.count()).select_from(User))
assert count == 2, "the admin and exactly one of the two"
# --- Admin-issued password reset links (#5173) ---------------------------------
async def _admin_and_guest(app_client):
"""The admin, signed in on `app_client`, and a second account signed in on a
client of its own. Returns the guest's client and account id."""
token = await _admin_with_invite(app_client)
guest = create_app().test_client()
joined = await guest.post(
"/api/auth/register", json={"email": "guest@example.test", "password": _PASSWORD, "invite": token}
)
assert joined.status_code == 201
return guest, (await joined.get_json())["id"]
async def _reset_link(app_client, account_id: str) -> str:
resp = await app_client.post(f"/api/accounts/{account_id}/reset-link")
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["token"]
async def test_a_reset_link_sets_the_password_and_signs_the_account_out_everywhere(app_client, db):
guest, guest_id = await _admin_and_guest(app_client)
device = await guest.post("/api/auth/devices", json={"name": "Phone"})
bearer = {"Authorization": f"Bearer {(await device.get_json())['token']}"}
assert (await guest.get("/api/auth/me")).status_code == 200
assert (await create_app().test_client().get("/api/auth/me", headers=bearer)).status_code == 200
token = await _reset_link(app_client, guest_id)
browser = create_app().test_client()
reset = await browser.post("/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"})
assert reset.status_code == 200, await reset.get_data(as_text=True)
# The browser that used the link is signed in as the account it was made for.
assert (await (await browser.get("/api/auth/me")).get_json())["email"] == "guest@example.test"
# The session from before the reset is over, and so is the linked device.
assert (await guest.get("/api/auth/me")).status_code == 401
assert (await create_app().test_client().get("/api/auth/me", headers=bearer)).status_code == 401
# The new password signs in; the old one doesn't.
fresh = create_app().test_client()
old = await fresh.post("/api/auth/login", json={"email": "guest@example.test", "password": _PASSWORD})
assert old.status_code == 401
new = await fresh.post("/api/auth/login", json={"email": "guest@example.test", "password": "a-brand-new-password"})
assert new.status_code == 200
# The admin is untouched, and the link works once.
assert (await app_client.get("/api/auth/me")).status_code == 200
again = await create_app().test_client().post(
"/api/auth/reset-password", json={"token": token, "password": "yet-another-password"}
)
assert again.status_code == 403
assert (await again.get_json())["error"] == "invalid or expired reset link"
async def test_only_an_admin_lists_accounts_and_makes_reset_links(app_client, db):
guest, guest_id = await _admin_and_guest(app_client)
listed = (await (await app_client.get("/api/accounts")).get_json())["accounts"]
assert [(a["email"], a["is_admin"]) for a in listed] == [
("owner@example.test", True),
("guest@example.test", False),
]
assert (await guest.get("/api/accounts")).status_code == 403
assert (await guest.post(f"/api/accounts/{guest_id}/reset-link")).status_code == 403
assert (await app_client.post(f"/api/accounts/{uuid.uuid4()}/reset-link")).status_code == 404
assert (await app_client.post("/api/accounts/not-an-id/reset-link")).status_code == 404
async def test_an_expired_or_superseded_reset_link_is_refused(app_client, db):
_, guest_id = await _admin_and_guest(app_client)
first = await _reset_link(app_client, guest_id)
second = await _reset_link(app_client, guest_id)
async def use(token: str):
return await create_app().test_client().post(
"/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"}
)
# Making a second link closed the first.
assert (await use(first)).status_code == 403
async with session_scope() as fresh:
await fresh.execute(
update(PasswordReset).values(expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
)
await fresh.commit()
assert (await use(second)).status_code == 403
# Nothing changed: the old password still signs in.
signed = await create_app().test_client().post(
"/api/auth/login", json={"email": "guest@example.test", "password": _PASSWORD}
)
assert signed.status_code == 200
async def test_a_short_password_leaves_the_reset_link_usable(app_client, db):
_, guest_id = await _admin_and_guest(app_client)
token = await _reset_link(app_client, guest_id)
client = create_app().test_client()
short = await client.post("/api/auth/reset-password", json={"token": token, "password": "short"})
assert short.status_code == 400
ok = await client.post("/api/auth/reset-password", json={"token": token, "password": "a-brand-new-password"})
assert ok.status_code == 200
async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_client, db):
"""A session-cookie caller holds no device token, so "revoke the one I'm using"
has nothing to name. Moved here from the unit lane when the session check began
reading the account (#5173)."""
await _signed_in(app_client, "web")
resp = await app_client.delete("/api/auth/devices/self")
assert resp.status_code == 400
# --- Sharing a note (#5174) ---------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.
async def _three_people(app_client):
"""The owner (admin, signed in on `app_client`), a recipient and a stranger, each
signed in on a client of their own. Returns (recipient, stranger, ids by name)."""
first = await _admin_with_invite(app_client)
second = (await (await app_client.post("/api/invites", json={})).get_json())["token"]
people = {}
clients = []
for name, token in (("recipient", first), ("stranger", second)):
client = create_app().test_client()
joined = await client.post(
"/api/auth/register",
json={"email": f"{name}@example.test", "password": _PASSWORD, "display_name": name.title(), "invite": token},
)
assert joined.status_code == 201
people[name] = (await joined.get_json())["id"]
clients.append(client)
return clients[0], clients[1], people
async def _owners_note(app_client, body: str = "a shared thought #idea") -> str:
resp = await app_client.post("/api/notes", json={"body": body})
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["id"]
async def _share(app_client, nid: str, user_id: str, permission: str = "view"):
return await app_client.post(f"/api/notes/{nid}/shares", json={"user_id": user_id, "permission": permission})
async def _board_ids(client, query: str = "") -> list[str]:
return [n["id"] for n in (await (await client.get(f"/api/notes?{query}")).get_json())["notes"]]
async def test_a_shared_note_reaches_the_recipient_and_no_one_else(app_client, db):
recipient, stranger, people = await _three_people(app_client)
nid = await _owners_note(app_client)
# The directory is everyone but you.
members = (await (await app_client.get("/api/users/directory")).get_json())["members"]
assert sorted(m["email"] for m in members) == ["recipient@example.test", "stranger@example.test"]
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
shared = await _share(app_client, nid, people["recipient"])
assert shared.status_code == 201, await shared.get_data(as_text=True)
assert [(s["member"]["email"], s["permission"]) for s in (await shared.get_json())["shares"]] == [
("recipient@example.test", "view")
]
seen = await (await recipient.get(f"/api/notes/{nid}")).get_json()
assert seen["permission"] == "view"
assert seen["shared_by"]["display_name"] == "owner"
# Labels are personal: the owner's #idea doesn't come with the note.
assert seen["labels"] == []
assert nid in await _board_ids(recipient)
assert await _board_ids(recipient, "shared=with_me") == [nid]
mine = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert (mine["permission"], mine["shared"], mine["shared_by"]) == ("owner", True, None)
assert [lb["name"] for lb in mine["labels"]] == ["idea"]
assert await _board_ids(app_client, "shared=with_me") == []
# The stranger, on the same instance, sees nothing of it.
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
assert nid not in await _board_ids(stranger)
async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line\n- [ ] milk")
await _share(app_client, nid, people["recipient"], "view")
# View: every write is a 404, the same answer a stranger gets (#1984).
writes = [
recipient.patch(f"/api/notes/{nid}", json={"body": "mine now"}),
recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"}),
recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True}),
recipient.post(f"/api/notes/{nid}/trash"),
recipient.put(f"/api/notes/{nid}/labels", json={"label_ids": []}),
recipient.get(f"/api/notes/{nid}/shares"),
recipient.post(f"/api/notes/{nid}/shares", json={"user_id": people["stranger"]}),
]
for pending in writes:
assert (await pending).status_code == 404
# Sharing again changes the permission rather than adding a second grant.
upgraded = await _share(app_client, nid, people["recipient"], "edit")
assert [s["permission"] for s in (await upgraded.get_json())["shares"]] == ["edit"]
# Edit: the text and the checklist.
edited = await recipient.patch(f"/api/notes/{nid}", json={"body": "first line, edited #fromguest\n- [ ] milk"})
assert edited.status_code == 200, await edited.get_data(as_text=True)
assert (await edited.get_json())["labels"] == []
ticked = await recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True})
assert ticked.status_code == 200
assert (await recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"})).status_code == 201
# A #tag typed into someone else's note files it under the OWNER's tags.
owner_tags = [lb["name"] for lb in (await (await app_client.get("/api/labels")).get_json())["labels"]]
assert "fromguest" in owner_tags
assert (await (await recipient.get("/api/labels")).get_json())["labels"] == []
# Everything else but their own pin and archive (#5176) stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"remind_at": "2099-01-01T00:00:00Z"})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "recurrence": "daily"})).status_code == 403
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
assert (await recipient.get(f"/api/notes/{nid}/revisions")).status_code == 404
body = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"]
assert body.startswith("first line, edited")
assert "- [x] milk" in body and "eggs" in body
async def test_owner_and_recipient_each_pin_archive_and_order_their_own(app_client, db):
recipient, stranger, people = await _three_people(app_client)
older = await _owners_note(app_client, "older")
nid = await _owners_note(app_client, "newer")
for note_id in (older, nid):
await _share(app_client, note_id, people["recipient"], "view")
async def pinned(client) -> bool:
return (await (await client.get(f"/api/notes/{nid}")).get_json())["pinned"]
# A view share is enough: pinning is organizing your own board, not changing the note.
mine = await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})
assert mine.status_code == 200, await mine.get_data(as_text=True)
assert (await mine.get_json())["pinned"] is True
assert (await pinned(recipient), await pinned(app_client)) == (True, False)
await app_client.patch(f"/api/notes/{nid}", json={"pinned": True})
await recipient.patch(f"/api/notes/{nid}", json={"pinned": False})
assert (await pinned(recipient), await pinned(app_client)) == (False, True)
assert (await stranger.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 404
# Archived by the recipient, it leaves their board and never the owner's.
await recipient.patch(f"/api/notes/{nid}", json={"archived": True})
assert await _board_ids(recipient) == [older]
assert await _board_ids(recipient, "filter=archived") == [nid]
assert nid in await _board_ids(app_client)
await recipient.patch(f"/api/notes/{nid}", json={"archived": False})
# Until they move them, a recipient sees the owner's order; after, their own.
await app_client.patch(f"/api/notes/{nid}", json={"pinned": False})
assert await _board_ids(recipient) == [nid, older]
assert (await recipient.post("/api/notes/reorder", json={"ids": [older, nid]})).status_code == 200
assert await _board_ids(recipient) == [older, nid]
assert await _board_ids(app_client) == [nid, older]
async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
share_id = (await (await _share(app_client, nid, people["recipient"])).get_json())["shares"][0]["id"]
left = await app_client.delete(f"/api/notes/{nid}/shares/{share_id}")
assert left.status_code == 200
assert (await left.get_json())["shares"] == []
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["shared"] is False
# Trashed by its owner, it leaves the recipient's board without reaching their Trash.
await _share(app_client, nid, people["recipient"])
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
assert nid not in await _board_ids(recipient)
assert await _board_ids(recipient, "filter=trash") == []
# Deleted for good, it is shared with nobody.
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
async with session_scope() as fresh:
assert await fresh.scalar(select(func.count()).select_from(Share)) == 0
async def test_a_share_names_someone_else_on_this_instance(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
me = (await (await app_client.get("/api/auth/me")).get_json())["id"]
assert (await _share(app_client, nid, me)).status_code == 400
assert (await _share(app_client, nid, str(uuid.uuid4()))).status_code == 400
assert (await _share(app_client, nid, "not-an-id")).status_code == 400
assert (await _share(app_client, nid, people["recipient"], "admin")).status_code == 400
# Only the owner shares: a recipient re-sharing gets the stranger's 404.
await _share(app_client, nid, people["recipient"], "edit")
assert (await _share(recipient, nid, people["stranger"])).status_code == 404
# A share someone else's note doesn't hold can't be removed through this one.
other = await _owners_note(app_client, "another")
sid = (await (await _share(app_client, nid, people["stranger"])).get_json())["shares"][-1]["id"]
assert (await app_client.delete(f"/api/notes/{other}/shares/{sid}")).status_code == 404
# --- Shared notes over sync (#5175) -------------------------------------------
async def _feed(client, since: int = 0, shares: bool = True) -> dict:
query = f"since={since}" + ("&shares=1" if shares else "")
resp = await client.get(f"/api/sync/changes?{query}")
assert resp.status_code == 200
return await resp.get_json()
def _feed_note(page: dict, nid: str) -> dict | None:
return next((n for n in page["notes"] if n["id"] == nid), None)
async def test_a_share_reaches_the_recipients_feed_and_a_revoke_takes_it_back(app_client, db):
recipient, stranger, people = await _three_people(app_client)
nid = await _owners_note(app_client)
start = (await _feed(recipient))["cursor"]
share_id = (await (await _share(app_client, nid, people["recipient"])).get_json())["shares"][0]["id"]
granted = await _feed(recipient, start)
held = _feed_note(granted, nid)
assert held is not None, "the grant moved the note past the recipient's cursor"
assert (held["permission"], held["shared_by"]["display_name"], held["labels"]) == ("view", "owner", [])
assert granted["revoked"] == []
# A client that never asked for shares gets only its own notes, as before.
assert _feed_note(await _feed(recipient, shares=False), nid) is None
assert "revoked" not in await _feed(recipient, shares=False)
assert _feed_note(await _feed(stranger), nid) is None
# The owner's devices learn the note is shared.
mine = _feed_note(await _feed(app_client), nid)
assert (mine["permission"], mine["shared"], [lb["name"] for lb in mine["labels"]]) == ("owner", True, ["idea"])
await app_client.delete(f"/api/notes/{nid}/shares/{share_id}")
revoked = await _feed(recipient, granted["cursor"])
assert revoked["revoked"] == [nid]
assert _feed_note(revoked, nid) is None
assert _feed_note(await _feed(app_client), nid)["shared"] is False
assert (await _feed(stranger))["revoked"] == []
# Shared again: a device that never heard of the revocation isn't told to delete it.
await _share(app_client, nid, people["recipient"], "edit")
fresh = await _feed(recipient, start)
assert fresh["revoked"] == []
assert _feed_note(fresh, nid)["permission"] == "edit"
async def test_an_edit_share_pushes_text_and_nothing_else(app_client, db):
recipient, stranger, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line")
await _share(app_client, nid, people["recipient"], "view")
def change(**fields) -> dict:
return {"changes": [{"entity": "note", "id": nid, "op": "upsert", "edited_at": "2099-01-01T00:00:00Z", **fields}]}
async def push(client, payload: dict) -> dict:
return (await (await client.post("/api/sync/push", json=payload)).get_json())["results"][0]
viewed = await push(recipient, change(body="mine now"))
assert (viewed["status"], viewed["error"]) == ("rejected", "only its owner can change that")
probed = await push(stranger, change(body="mine now"))
assert (probed["status"], probed["error"]) == ("rejected", "cannot apply")
await _share(app_client, nid, people["recipient"], "edit")
edited = await push(recipient, change(body="first line, from the phone", pinned=True, archived=True, label_ids=[]))
assert edited["status"] == "applied", edited
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert note["body"] == "first line, from the phone"
# Everything but the text stays the owner's.
assert (note["pinned"], note["archived"]) == (False, False)
deleted = await push(recipient, {"changes": [{"entity": "note", "id": nid, "op": "delete", "edited_at": "2099-01-02T00:00:00Z"}]})
assert deleted["status"] == "rejected"
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
async def test_a_recipients_own_state_syncs_to_their_devices_only(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line")
await _share(app_client, nid, people["recipient"], "edit")
start = (await _feed(recipient))["cursor"]
async def push(change: dict) -> dict:
payload = {"changes": [{"entity": "note", "id": nid, "op": "upsert", **change}]}
return (await (await recipient.post("/api/sync/push", json=payload)).get_json())["results"][0]
# The owner edits; the recipient's phone, a version behind, pins its stale copy.
await app_client.patch(f"/api/notes/{nid}", json={"body": "first line, the owner's edit"})
owners = (await _feed(app_client))["cursor"]
pinned = await push(
{
"edited_at": "2000-01-01T00:00:00Z",
"body": "first line",
"pinned": True,
"archived": False,
"position": 7,
"state_at": "2099-01-01T00:00:00Z",
}
)
assert pinned["status"] == "applied", pinned
# The pin's newer time is the state's alone, so the stale text lost to the edit.
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"] == "first line, the owner's edit"
held = _feed_note(await _feed(recipient, start), nid)
assert (held["pinned"], held["position"], held["sync_revision"]) == (True, 7, pinned["sync_revision"])
# Nothing about the note changed for its owner, so their devices aren't sent it.
assert _feed_note(await _feed(app_client, owners), nid) is None
assert _feed_note(await _feed(app_client), nid)["pinned"] is False
# Another device's older unpin loses to the newer pin.
stale = await push({"edited_at": "2000-01-01T00:00:00Z", "pinned": False, "state_at": "2098-01-01T00:00:00Z"})
assert stale["status"] == "kept"
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["pinned"] is True
async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
await _share(app_client, nid, people["recipient"])
seen = await _feed(recipient)
assert _feed_note(seen, nid) is not None
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
trashed = _feed_note(await _feed(recipient, seen["cursor"]), nid)
assert trashed is not None and trashed["trashed"] is True
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
gone = await _feed(recipient, seen["cursor"])
assert gone["revoked"] == [nid]
assert _feed_note(gone, nid) is None
# --- Password reset by email (#5266) ------------------------------------------
#
# The SMTP hand-off is replaced by a list; everything up to it is real.
_MAIL_SETTINGS = {
"smtp_host": "smtp.example.test",
"smtp_from": "inkwell@example.test",
"smtp_password": "hunter2",
"public_url": "https://notes.example.test/",
}
async def _mail_off() -> None:
"""Settings outlive the per-test truncate, so each email test starts from none."""
async with session_scope() as fresh:
await fresh.execute(delete(Setting).where(Setting.key.in_([*_MAIL_SETTINGS, "smtp_security"])))
await fresh.commit()
def _outbox(monkeypatch) -> list:
sent: list = []
monkeypatch.setattr(mailer, "_deliver", lambda cfg, msg: sent.append(msg))
return sent
async def _forgot(email: str):
return await create_app().test_client().post("/api/auth/forgot-password", json={"email": email})
async def test_the_smtp_password_never_leaves_the_server(app_client, db):
await _mail_off()
await _admin_with_invite(app_client)
saved = await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
assert saved.status_code == 200, await saved.get_data(as_text=True)
rows = {r["key"]: r for r in (await saved.get_json())["settings"]}
assert (rows["smtp_password"]["value"], rows["smtp_password"]["is_set"]) == ("", True)
assert rows["public_url"]["value"] == "https://notes.example.test"
# Saving the form again sends the password field empty, which keeps it.
assert (await app_client.patch("/api/settings", json={"smtp_password": ""})).status_code == 200
async with session_scope() as fresh:
assert await get_setting(fresh, "smtp_password") == "hunter2"
assert (await app_client.patch("/api/settings", json={"smtp_security": "ssl3"})).status_code == 400
assert (await app_client.patch("/api/settings", json={"public_url": "notes.example.test"})).status_code == 400
async def test_a_forgotten_password_is_reset_from_an_emailed_link(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)
token = await _admin_with_invite(app_client)
assert (await _register("guest@example.test", token)).status_code == 201
# Off until the server can send: no link on sign-in, and the route says so.
assert (await (await app_client.get("/api/config")).get_json())["password_reset_by_email"] is False
assert (await _forgot("guest@example.test")).status_code == 400
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
assert (await (await app_client.get("/api/config")).get_json())["password_reset_by_email"] is True
known = await _forgot("Guest@Example.test")
unknown = await _forgot("nobody@example.test")
assert known.status_code == unknown.status_code == 200
assert await known.get_json() == await unknown.get_json()
await mailer.drain()
assert [m["To"] for m in outbox] == ["guest@example.test"]
text = outbox[0].get_content()
link = re.search(r"https://notes\.example\.test/reset-password\?token=([\w-]+)", text)
assert link, text
async with session_scope() as fresh:
assert await fresh.scalar(select(PasswordReset.created_by)) is None
reset = await create_app().test_client().post(
"/api/auth/reset-password", json={"token": link.group(1), "password": "chosen-by-email"}
)
assert reset.status_code == 200
assert (await reset.get_json())["email"] == "guest@example.test"
async def test_reset_emails_stop_at_the_cap_without_saying_so(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)
token = await _admin_with_invite(app_client)
assert (await _register("guest@example.test", token)).status_code == 201
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
answers = [await _forgot("guest@example.test") for _ in range(4)]
assert [a.status_code for a in answers] == [200, 200, 200, 200]
await mailer.drain()
assert len(outbox) == 3 # reset_emails_per_account defaults to 3
async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client, db, monkeypatch):
await _mail_off()
outbox = _outbox(monkeypatch)
await _admin_with_invite(app_client)
assert (await app_client.post("/api/settings/test-email")).status_code == 400
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
sent = await app_client.post("/api/settings/test-email")
assert sent.status_code == 200
assert [m["To"] for m in outbox] == ["owner@example.test"]
def refuse(cfg, msg):
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
monkeypatch.setattr(mailer, "_deliver", refuse)
failed = await app_client.post("/api/settings/test-email")
assert failed.status_code == 502
assert "bad credentials" in (await failed.get_json())["error"]