CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 45s
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118): Practice 3, CI fails on the wrong signer. The signing step printed the certificate and went on. It now fails unless the APK has exactly one signer and that signer is the release certificate (SHA-256 408a5835…, pinned from run 8753). The steps that publish come after it in the same job, so a wrongly signed build is never staged or published. Practice 6, app downloads are throttled and carry a sha256 ETag. - The download route counts per account and answers 429 with Retry-After past the limit. The limit is a new Settings → Security value, "App downloads per account per hour" (default 30), live like the sign-in limits. - The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a phone resuming a download across a redeploy is not told its partial copy is stale. Quart's own ETag and conditional handling are off, and the route runs the conditional pass after setting the ETag, so Range and If-Range are judged against the content. Practice 9, the update offer and debug builds. - The install-permission notice re-reads the grant each time the app comes back, as ReminderNotice does. Read once, it stayed up after someone granted the permission in Settings and came back. - A debuggable build says it can't update itself and checks for nothing. Android would refuse the release-signed APK over a debug signature anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1703 lines
77 KiB
Python
1703 lines
77 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",
|
||
"client_downloads_per_hour",
|
||
}
|
||
# 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.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
|
||
|
||
# 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 == "first line, edited #fromguest\n- [x] milk"
|
||
|
||
|
||
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"]
|