From 75928c7afd96a035e8a02378681213f4a7ae017e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 15:25:13 -0400 Subject: [PATCH] sync: shared notes in the feed, revocations, and text pushes from an edit share The change feed answers `?shares=1` with every note the caller can see, each saying how it is held, plus a `revoked` list of notes that left them. Granting a share moves the note past the recipient's cursor; ending one, or the owner deleting the note, leaves a revocation on the same cursor. A recipient at edit may push the note's text, and nothing else. Protocol 6, feature `shares`; opt-in, so the floor stays at 3. Co-Authored-By: Claude Opus 5.5 --- alembic/versions/0035_share_revocations.py | 50 ++++++++ docs/sync.md | 40 ++++++ src/inkwell/models/all.py | 1 + src/inkwell/models/share_revocation.py | 33 +++++ src/inkwell/retention.py | 5 +- src/inkwell/share_sync.py | 69 +++++++++++ src/inkwell/shares_api.py | 9 ++ src/inkwell/sync.py | 135 +++++++++++++++------ tests/test_integration.py | 96 ++++++++++++++- tests/test_sync.py | 15 ++- 10 files changed, 407 insertions(+), 46 deletions(-) create mode 100644 alembic/versions/0035_share_revocations.py create mode 100644 src/inkwell/models/share_revocation.py create mode 100644 src/inkwell/share_sync.py diff --git a/alembic/versions/0035_share_revocations.py b/alembic/versions/0035_share_revocations.py new file mode 100644 index 0000000..2ed5c9a --- /dev/null +++ b/alembic/versions/0035_share_revocations.py @@ -0,0 +1,50 @@ +"""share_revocations: telling a recipient's devices a shared note has left them + +Revision ID: 0035 +Revises: 0034 +Create Date: 2026-10-07 + +The change feed carries every note a person can see, including notes shared with +them (#5175). When a share ends, the note stops being visible, so it simply stops +appearing in the feed. A device that already holds a copy would keep it forever. A +revocation is that missing signal: one row per (note, person) who lost the note, +stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits +on the one cursor every client already pages by. + +Sharing the note with that person again deletes the row, so a device that never +heard of the revocation never gets told to delete a note it is meant to have. + +## Downgrade + +Drops the table. Devices that missed a revocation keep their copy. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0035" +down_revision = "0034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "share_revocations", + sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "sync_revision", + sa.BigInteger(), + nullable=False, + server_default=sa.text("nextval('sync_revision_seq')"), + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint("note_id", "user_id"), + ) + op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"]) + + +def downgrade() -> None: + op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations") + op.drop_table("share_revocations") diff --git a/docs/sync.md b/docs/sync.md index 6c249fb..d6dad80 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -64,6 +64,9 @@ syncs everything else. - v5 (#5168): attachments became a sync entity — an upload route keyed by the client's id, and `attachment`/`preview` deletes in push. Additive, so it is the `attachment_sync` feature and the floor stays. + - v6 (#5175): shared notes — see "Shared notes" below. Opt-in per request + (`changes?shares=1`), so it is the `shares` feature and the floor stays: a v5 + client never asks and gets exactly its own notes. - **Additive change** (a new field, a new capability) → add a `sync_features` name. Do **not** raise a minimum. Old clients keep working. - **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's @@ -196,6 +199,37 @@ boundaries, so nothing between `cursor` and the next pull is skipped. Loop while Note attachment metadata carries `{id, url, mime, size, sha256}`. +### Shared notes (`shares`, v6) + +With `?shares=1` the feed carries every note the caller can **see**: their own, plus +those shared with them directly or through a group. Each note then also says how +it is held: + +```json +{ "permission": "owner" | "edit" | "view", + "shared": true, + "shared_by": { "id": "...", "display_name": "..." } } +``` + +`shared_by` is null on the caller's own notes, and `shared` says whether they have +shared it with anyone. A note shared with the caller comes with `labels: []`: +labels are personal, and a recipient files nothing under the owner's tags. + +Granting or changing a share moves the note to a new revision (without touching +`updated_at`, so last-write-wins is unaffected), which is how it passes a +recipient's cursor. Ending a share — or the owner purging the note — adds the note +to the recipient's `revoked` list: + +```json +{ "notes": [ ... ], "labels": [ ... ], "revoked": ["", ...], + "cursor": 51, "has_more": false } +``` + +A client deletes its copy of each revoked note. Revocations draw from the same +sequence and page on the same cursor as notes and labels (three streams now; the +smallest full boundary wins). Sharing with the person again deletes their +revocation, so a device that never saw it is never told to drop a note it has. + ## Push — `POST /api/sync/push` Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change: @@ -233,6 +267,12 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change: the same as one that never existed. Removals are explicit rather than "the note's current attachment set" on purpose: push runs before pull, so a set would delete an attachment another device added that this one has not seen. +- **A note someone else owns** (`shares`): a recipient at `edit` may push an upsert + and only its `body` is applied, under the same last-write-wins — every other + field is ignored, since pinning, archiving, reminders, labels and deleting stay + the owner's. A `view` recipient's change, or any delete, is `rejected` ("only its + owner can change that"); a note the caller can't see at all answers the generic + "cannot apply". ### Conflict resolution — last-write-wins + history diff --git a/src/inkwell/models/all.py b/src/inkwell/models/all.py index a6f05ba..ad40538 100644 --- a/src/inkwell/models/all.py +++ b/src/inkwell/models/all.py @@ -16,5 +16,6 @@ from . import ( # noqa: F401 saved_filter, settings, share, + share_revocation, user, ) diff --git a/src/inkwell/models/share_revocation.py b/src/inkwell/models/share_revocation.py new file mode 100644 index 0000000..a25123c --- /dev/null +++ b/src/inkwell/models/share_revocation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, func, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class ShareRevocation(Base): + """A note that someone lost access to, for their devices to delete (#5175). + + The change feed only carries notes a person can see, so a share ending would + otherwise leave a stale copy on every device that had synced it. The revision + comes from the shared `sync_revision_seq`, so a revocation pages on the same + cursor as notes and labels. See `share_sync`. + """ + + __tablename__ = "share_revocations" + + note_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + sync_revision: Mapped[int] = mapped_column( + BigInteger(), nullable=False, server_default=text("nextval('sync_revision_seq')") + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/inkwell/retention.py b/src/inkwell/retention.py index 70c13a5..2dcafc4 100644 --- a/src/inkwell/retention.py +++ b/src/inkwell/retention.py @@ -36,6 +36,7 @@ from .models.note_link_preview import NoteLinkPreview from .models.note_revision import NoteRevision from .models.share import Share from .settings import get_setting +from .share_sync import recipients, revoke logger = logging.getLogger(__name__) @@ -88,7 +89,9 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None: await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id)) await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id)) await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id)) - # Its shares too: a deleted note is shared with nobody. + # Its shares too: a deleted note is shared with nobody. Each recipient's devices + # are told first, since the tombstone below only reaches the owner's. + await revoke(db, note.id, await recipients(db, note.id)) await db.execute(sa_delete(Share).where(Share.resource_type == "note", Share.resource_id == note.id)) note.body = "" note.display_title = "" diff --git a/src/inkwell/share_sync.py b/src/inkwell/share_sync.py new file mode 100644 index 0000000..fb0fa13 --- /dev/null +++ b/src/inkwell/share_sync.py @@ -0,0 +1,69 @@ +"""What sharing a note does to the change feed (#5175). + +The feed carries every note a person can see, so a share changing has to show up in +it: granting or changing a share moves the note past each device's cursor, and ending +one leaves a revocation for the person who lost it (see `ShareRevocation`). + +Kept apart from `shares_api` because `retention.purge_note` needs it too, and a +purge must not import a blueprint to tell recipients a note is gone. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import delete as sa_delete +from sqlalchemy import select, text +from sqlalchemy.dialects.postgresql import insert + +from .models.group import GroupMember +from .models.share import Share +from .models.share_revocation import ShareRevocation + + +async def bump_note(db, note_id: uuid.UUID) -> None: + """Move the note past every device's cursor without changing it. + + Raw SQL rather than `update(Note)`: the ORM would stamp `updated_at`, and that is + the edit time last-write-wins compares, so a share would beat a real edit made + offline a minute earlier. The row trigger draws the new `sync_revision`. + """ + await db.execute(text("UPDATE notes SET sync_revision = sync_revision WHERE id = :id"), {"id": note_id}) + + +async def recipients(db, note_id: uuid.UUID) -> set[uuid.UUID]: + """Everyone the note is shared with, directly or through a group.""" + direct = await db.scalars( + select(Share.shared_with_user_id).where( + Share.resource_type == "note", Share.resource_id == note_id, Share.shared_with_user_id.is_not(None) + ) + ) + grouped = await db.scalars( + select(GroupMember.user_id) + .join(Share, Share.shared_with_group_id == GroupMember.group_id) + .where(Share.resource_type == "note", Share.resource_id == note_id) + ) + return set(direct.all()) | set(grouped.all()) + + +async def revoke(db, note_id: uuid.UUID, user_ids) -> None: + """Tell each person's devices the note has left them. A second revocation for the + same person takes a fresh revision, so a device past the first still hears it.""" + for uid in user_ids: + await db.execute( + insert(ShareRevocation) + .values(note_id=note_id, user_id=uid) + .on_conflict_do_update( + index_elements=[ShareRevocation.note_id, ShareRevocation.user_id], + set_={"sync_revision": text("nextval('sync_revision_seq')")}, + ) + ) + + +async def granted(db, note_id: uuid.UUID, user_id: uuid.UUID) -> None: + """A share to `user_id` exists again: forget any revocation, so a device that + never synced it isn't told to delete a note it now has, and move the note on so + every device hears about the grant.""" + await db.execute( + sa_delete(ShareRevocation).where(ShareRevocation.note_id == note_id, ShareRevocation.user_id == user_id) + ) + await bump_note(db, note_id) diff --git a/src/inkwell/shares_api.py b/src/inkwell/shares_api.py index a1c12f8..c688507 100644 --- a/src/inkwell/shares_api.py +++ b/src/inkwell/shares_api.py @@ -23,6 +23,7 @@ from .models.share import Share from .models.user import User from .notes.helpers import _get_owned from .responses import json_error, not_found, parse_uuid +from .share_sync import bump_note, granted, recipients, revoke bp = Blueprint("shares", __name__, url_prefix="/api") @@ -105,6 +106,7 @@ async def share_note(note_id: str): set_={"permission": permission}, ) ) + await granted(db, note.id, target) await db.commit() return jsonify({"shares": await _shares_of(db, note.id)}), 201 @@ -122,6 +124,13 @@ async def unshare_note(note_id: str, share_id: str): ) if share is None: return not_found() + before = await recipients(db, note.id) await db.delete(share) + await db.flush() + # Only the people who can no longer see it: someone still reached through a + # group keeps the note on their devices. + await revoke(db, note.id, before - await recipients(db, note.id)) + # The owner's devices learn the note is no longer shared. + await bump_note(db, note.id) await db.commit() return jsonify({"shares": await _shares_of(db, note.id)}) diff --git a/src/inkwell/sync.py b/src/inkwell/sync.py index c5b0568..b590874 100644 --- a/src/inkwell/sync.py +++ b/src/inkwell/sync.py @@ -2,6 +2,8 @@ Pull: `GET /api/sync/changes?since=` returns every note + label the caller owns whose sync_revision advanced past the cursor, newest-revision last, paginated. +With `shares=1` it also carries the notes shared with the caller, and `revoked`: the +notes that stopped being shared with them (#5175). Notes and labels both draw from ONE shared sequence (sync_revision_seq), so the cursor is a single monotonic watermark across both entity types. @@ -19,6 +21,7 @@ from quart import Blueprint, g, jsonify, request from sqlalchemy import delete as sa_delete from sqlalchemy import func, select +from .acl import visible_to_user from .auth import login_required from .common import iso, parse_dt from .db import session_scope @@ -27,13 +30,14 @@ from .models.label import Label, NoteLabel from .models.note import Note from .models.note_attachment import NoteAttachment from .models.note_link_preview import NoteLinkPreview +from .models.share_revocation import ShareRevocation from .notes import ( _serialize_notes, normalize_color, normalize_recurrence, ) from .notes.body import write_body -from .notes.helpers import store_attachment, unlink_media +from .notes.helpers import _get_editable, store_attachment, unlink_media from .responses import json_error, not_found, parse_uuid from .settings import get_setting from .retention import purge_note @@ -77,7 +81,12 @@ MAX_PUSH = 1000 # per-batch change cap # v5 (#5168): attachments became a sync entity — `PUT /attachments/` uploads a file # attached offline, and push takes `attachment` and `preview` deletes. Additive, so the # floor stays: a v4 client never sends either, and gets the same notes it always did. -SYNC_PROTOCOL_VERSION = 5 +# v6 (#5175): shared notes. A client asking `changes?shares=1` gets the notes shared +# with it, each saying how it is held (`permission`, `shared`, `shared_by`), and a +# `revoked` list of notes that have left it; push takes body edits to notes shared at +# `edit`. Additive and opt-in, so the floor stays: a v5 client never asks, and gets +# its own notes exactly as before. +SYNC_PROTOCOL_VERSION = 6 MIN_CLIENT_PROTOCOL_VERSION = 3 # Named capabilities beyond the base protocol. An ADDITIVE change earns a name @@ -92,6 +101,7 @@ SYNC_FEATURES: tuple[str, ...] = ( "tombstones", # purge propagates as a content-less row "revisions", # an overwritten version snapshots into note history "attachment_sync", # upload by client id + attachment/preview deletes in push (v5) + "shares", # notes shared with the caller in the feed, `revoked`, edit-share pushes (v6) ) @@ -123,24 +133,22 @@ def _clamp_limit(raw: str | None) -> int: return DEFAULT_LIMIT -def _page_cursor(note_revs: list[int], label_revs: list[int], since: int, limit: int) -> tuple[int, bool]: - """Compute the next cursor + has_more when paging TWO revision streams that share - one sequence. Each stream is fetched `rev > since ORDER BY rev LIMIT limit`. +def _page_cursor(streams: list[list[int]], since: int, limit: int) -> tuple[int, bool]: + """Compute the next cursor + has_more when paging several revision streams that + share one sequence (notes, labels, and revocations). Each stream is fetched + `rev > since ORDER BY rev LIMIT limit`. - If either stream came back FULL (== limit) we're truncating, so the safe cursor is - the SMALLER of the two page boundaries — advancing only to where BOTH streams are - fully drained, so nothing between the cursor and the next pull is skipped. If - neither is full, everything ≤ max(returned) is drained. Both lists are ascending. + If any stream came back FULL (== limit) we're truncating, so the safe cursor is + the SMALLEST of the full streams' page boundaries — advancing only to where EVERY + stream is fully drained, so nothing between the cursor and the next pull is + skipped. If none is full, everything ≤ max(returned) is drained. Each list is + ascending. """ - boundaries = [] - if len(note_revs) == limit: - boundaries.append(note_revs[-1]) - if len(label_revs) == limit: - boundaries.append(label_revs[-1]) + boundaries = [revs[-1] for revs in streams if len(revs) == limit] if boundaries: return min(boundaries), True - all_revs = note_revs + label_revs - return (max(all_revs) if all_revs else since), False + every = [rev for revs in streams for rev in revs] + return (max(every) if every else since), False @bp.get("/changes") @@ -148,15 +156,20 @@ def _page_cursor(note_revs: list[int], label_revs: list[int], since: int, limit: async def changes(): since = _parse_since(request.args.get("since")) limit = _clamp_limit(request.args.get("limit")) + # `shares=1` is a client that understands shared notes (protocol 6, `shares`). + # Anything older gets only its own notes, as before, rather than someone else's + # notes it would treat as its own: pinning them, labelling them, pushing them. + with_shares = request.args.get("shares") == "1" + if with_shares: + visible = visible_to_user("note", Note.owner_id, Note.id, g.user_id) + else: + visible = Note.owner_id == g.user_id async with session_scope() as db: - # ALL of the owner's notes/labels (any state — active/archived/trash/purged), + # Every note the caller can see, in any state (active/archived/trash/purged), # since a client mirrors everything; ordered by the shared revision. note_rows = ( await db.scalars( - select(Note) - .where(Note.owner_id == g.user_id, Note.sync_revision > since) - .order_by(Note.sync_revision) - .limit(limit) + select(Note).where(visible, Note.sync_revision > since).order_by(Note.sync_revision).limit(limit) ) ).all() label_rows = ( @@ -167,33 +180,50 @@ async def changes(): .limit(limit) ) ).all() + revoked_rows = ( + ( + await db.execute( + select(ShareRevocation.note_id, ShareRevocation.sync_revision) + .where(ShareRevocation.user_id == g.user_id, ShareRevocation.sync_revision > since) + .order_by(ShareRevocation.sync_revision) + .limit(limit) + ) + ).all() + if with_shares + else [] + ) cursor, has_more = _page_cursor( - [n.sync_revision for n in note_rows], - [lb.sync_revision for lb in label_rows], + [ + [n.sync_revision for n in note_rows], + [lb.sync_revision for lb in label_rows], + [rev for _, rev in revoked_rows], + ], since, limit, ) - # Trim each stream to the shared watermark so the two feeds stay aligned. + # Trim each stream to the shared watermark so the feeds stay aligned. note_rows = [n for n in note_rows if n.sync_revision <= cursor] label_rows = [lb for lb in label_rows if lb.sync_revision <= cursor] + revoked = [str(nid) for nid, rev in revoked_rows if rev <= cursor] # Note bodies come from the shared note serializer; sync adds the two - # delta-only fields on top (folding these into the serializer itself waits on - # the notes.py serialization split). - notes_out = await _serialize_notes(db, note_rows) + # delta-only fields on top. With shares, each note also says how the caller + # holds it, and a note shared with them comes without the owner's labels. + notes_out = await _serialize_notes(db, note_rows, g.user_id if with_shares else None) for data, n in zip(notes_out, note_rows): data["sync_revision"] = n.sync_revision data["purged_at"] = iso(n.purged_at) - return jsonify( - { - "notes": notes_out, - "labels": [serialize_label_sync(lb) for lb in label_rows], - "cursor": cursor, - "has_more": has_more, - } - ) + page = { + "notes": notes_out, + "labels": [serialize_label_sync(lb) for lb in label_rows], + "cursor": cursor, + "has_more": has_more, + } + if with_shares: + page["revoked"] = revoked + return jsonify(page) # --- Push: apply client mutations (LWW by client edit-time, non-destructive) --- @@ -264,11 +294,7 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di note = await db.scalar(select(Note).where(Note.id == nid)) if note is not None and note.owner_id != g.user_id: - # A client only ever pushes ids of notes IT created, so this branch is only - # reached by a probe (or a ~0-probability UUID collision). Reject with a - # GENERIC message so the response doesn't confirm the id belongs to another - # user (don't leak existence via a distinctive "not yours"). - return {"id": str(nid), "entity": "note", "status": "rejected", "error": "cannot apply"} + return await _apply_shared_note(db, note, ch, edited_at, previews) if op == "delete": if note is None: @@ -319,6 +345,35 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di } +async def _apply_shared_note(db, note: Note, ch: dict, edited_at, previews: list[tuple[uuid.UUID, str]]) -> dict: + """Apply a pushed change to a note someone else owns (#5175). + + Someone it is shared with at `edit` may change its text, exactly as in the web + app: the body goes through `write_body` under the same last-write-wins, and every + other field in the change is ignored, since pin, archive, reminders, labels and + deletion are the owner's. Anything else is rejected. A note the caller cannot see + at all gets the same generic answer as an id that doesn't exist, so the reply + can't be used to learn that someone else's id is real. + """ + nid = str(note.id) + if ch.get("op", "upsert") == "upsert" and await _get_editable(db, nid) is not None: + if not client_wins(edited_at, note.updated_at): + return {"id": nid, "entity": "note", "status": "kept", "sync_revision": note.sync_revision} + body = ch["body"] if isinstance(ch.get("body"), str) else note.body + if await write_body(db, note, body): + previews.append((note.id, note.body)) + if edited_at is not None: + note.updated_at = edited_at + await db.flush() + await db.refresh(note, ["sync_revision"]) + return {"id": nid, "entity": "note", "status": "applied", "sync_revision": note.sync_revision} + visible = await db.scalar( + select(Note.id).where(Note.id == note.id, visible_to_user("note", Note.owner_id, Note.id, g.user_id)) + ) + error = "only its owner can change that" if visible is not None else "cannot apply" + return {"id": nid, "entity": "note", "status": "rejected", "error": error} + + async def _apply_label(db, ch: dict) -> dict: raw_id = ch.get("id") try: diff --git a/tests/test_integration.py b/tests/test_integration.py index a315524..72215ed 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -51,7 +51,7 @@ 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, invites, password_resets, users" +_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, invites, password_resets, users" @pytest_asyncio.fixture @@ -1343,6 +1343,100 @@ async def test_a_share_names_someone_else_on_this_instance(app_client, db): 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_deleting_a_shared_note_reaches_the_recipients_devices(app_client, db): + recipient, _, people = await _three_people(app_client) + nid = await _owners_note(app_client) + await _share(app_client, nid, people["recipient"]) + seen = await _feed(recipient) + assert _feed_note(seen, nid) is not None + + assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200 + trashed = _feed_note(await _feed(recipient, seen["cursor"]), nid) + assert trashed is not None and trashed["trashed"] is True + + assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200 + gone = await _feed(recipient, seen["cursor"]) + assert gone["revoked"] == [nid] + assert _feed_note(gone, nid) is None + + # --- Password reset by email (#5266) ------------------------------------------ # # The SMTP hand-off is replaced by a list; everything up to it is real. diff --git a/tests/test_sync.py b/tests/test_sync.py index 9ca0c37..f79fd37 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -68,32 +68,39 @@ def test_clamp_limit(): def test_page_cursor_all_drained(): # Neither stream is full → cursor is the max revision seen; nothing more to page. - cursor, more = _page_cursor([1, 3, 5], [2, 4], since=0, limit=500) + cursor, more = _page_cursor([[1, 3, 5], [2, 4], []], since=0, limit=500) assert cursor == 5 assert more is False def test_page_cursor_empty(): # No changes since the cursor → cursor stays put, no more pages. - cursor, more = _page_cursor([], [], since=7, limit=500) + cursor, more = _page_cursor([[], [], []], since=7, limit=500) assert cursor == 7 assert more is False def test_page_cursor_one_stream_full_advances_to_its_boundary(): # Notes came back full (limit=3) → truncate at its boundary; later labels defer. - cursor, more = _page_cursor([1, 2, 3], [4, 5], since=0, limit=3) + cursor, more = _page_cursor([[1, 2, 3], [4, 5], []], since=0, limit=3) assert cursor == 3 assert more is True def test_page_cursor_both_full_uses_min_boundary(): # Both full → advance only to the SMALLER boundary so neither stream skips a gap. - cursor, more = _page_cursor([1, 2, 10], [3, 4, 5], since=0, limit=3) + cursor, more = _page_cursor([[1, 2, 10], [3, 4, 5], []], since=0, limit=3) assert cursor == 5 assert more is True +def test_page_cursor_a_full_revocation_stream_holds_the_cursor_back(): + # Revocations page on the same cursor: a full one truncates like any other stream. + cursor, more = _page_cursor([[1, 9], [], [2, 3, 4]], since=0, limit=3) + assert cursor == 4 + assert more is True + + # --- protocol handshake (M10.6) ---------------------------------------------