/api/groups (admin) creates, renames and deletes groups and adds or removes members. The member directory lists every group, and a share may name a group_id instead of a user_id; a note's shares answer with `member` or `group`. A note shared with a group reaches whoever is in it now, so membership is what the feed follows: joining grants each of the group's notes to the new member's devices, and leaving (or the group being deleted) revokes them unless a direct share or another group still reaches that person. recipients() never counts the note's own owner, who may sit in a group it is shared with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1705 lines
78 KiB
Python
1705 lines
78 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 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, group_members, groups, 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
|
||
|
||
|
||
# --- Groups (#5177) ------------------------------------------------------------
|
||
|
||
|
||
async def _group(app_client, name: str, *member_ids: str) -> str:
|
||
made = await app_client.post("/api/groups", json={"name": name})
|
||
assert made.status_code == 201, await made.get_data(as_text=True)
|
||
gid = (await made.get_json())["id"]
|
||
for uid in member_ids:
|
||
added = await app_client.post(f"/api/groups/{gid}/members", json={"user_id": uid})
|
||
assert added.status_code == 201, await added.get_data(as_text=True)
|
||
return gid
|
||
|
||
|
||
async def test_only_an_admin_manages_groups_and_everyone_can_share_with_one(app_client, db):
|
||
recipient, _, people = await _three_people(app_client)
|
||
assert (await recipient.get("/api/groups")).status_code == 403
|
||
assert (await recipient.post("/api/groups", json={"name": "Mine"})).status_code == 403
|
||
|
||
gid = await _group(app_client, "Family", people["recipient"])
|
||
assert (await app_client.post("/api/groups", json={"name": "family"})).status_code == 409
|
||
assert (await app_client.post("/api/groups", json={"name": " "})).status_code == 400
|
||
listed = (await (await app_client.get("/api/groups")).get_json())["groups"]
|
||
assert [(gr["name"], [m["email"] for m in gr["members"]]) for gr in listed] == [
|
||
("Family", ["recipient@example.test"])
|
||
]
|
||
renamed = await app_client.patch(f"/api/groups/{gid}", json={"name": "Household"})
|
||
assert (await renamed.get_json())["name"] == "Household"
|
||
|
||
# Anyone signed in sees each group to share with, and how many are in it.
|
||
directory = await (await recipient.get("/api/users/directory")).get_json()
|
||
assert directory["groups"] == [{"id": gid, "name": "Household", "member_count": 1}]
|
||
|
||
|
||
async def test_a_note_shared_with_a_group_follows_who_is_in_it(app_client, db):
|
||
recipient, stranger, people = await _three_people(app_client)
|
||
gid = await _group(app_client, "Family", people["recipient"])
|
||
nid = await _owners_note(app_client)
|
||
start = (await _feed(stranger))["cursor"]
|
||
|
||
shared = await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid, "permission": "edit"})
|
||
assert shared.status_code == 201, await shared.get_data(as_text=True)
|
||
[entry] = (await shared.get_json())["shares"]
|
||
assert (entry["member"], entry["group"]["name"], entry["group"]["member_count"]) == (None, "Family", 1)
|
||
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["permission"] == "edit"
|
||
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
|
||
both = await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid, "user_id": people["stranger"]})
|
||
assert both.status_code == 400
|
||
|
||
# Joining reaches the new member's devices; leaving takes it back.
|
||
await app_client.post(f"/api/groups/{gid}/members", json={"user_id": people["stranger"]})
|
||
joined = await _feed(stranger, start)
|
||
assert _feed_note(joined, nid)["permission"] == "edit"
|
||
left = await app_client.delete(f"/api/groups/{gid}/members/{people['stranger']}")
|
||
assert left.status_code == 200
|
||
gone = await _feed(stranger, joined["cursor"])
|
||
assert gone["revoked"] == [nid]
|
||
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
|
||
|
||
# Someone also shared with directly keeps the note when they leave the group.
|
||
await _share(app_client, nid, people["recipient"], "view")
|
||
before = (await _feed(recipient))["cursor"]
|
||
await app_client.delete(f"/api/groups/{gid}/members/{people['recipient']}")
|
||
assert (await _feed(recipient, before))["revoked"] == []
|
||
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["permission"] == "view"
|
||
|
||
|
||
async def test_deleting_a_group_ends_every_share_made_to_it(app_client, db):
|
||
recipient, _, people = await _three_people(app_client)
|
||
gid = await _group(app_client, "Family", people["recipient"])
|
||
nid = await _owners_note(app_client)
|
||
await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid})
|
||
seen = await _feed(recipient)
|
||
assert _feed_note(seen, nid) is not None
|
||
|
||
assert (await app_client.delete(f"/api/groups/{gid}")).status_code == 200
|
||
assert (await _feed(recipient, seen["cursor"]))["revoked"] == [nid]
|
||
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
|
||
|
||
|
||
# --- 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"]
|