sync: shared notes in the feed, revocations, and text pushes from an edit share
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
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 15s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:25:13 -04:00
co-authored by Claude Opus 5.5
parent 2e2d8667dd
commit 75928c7afd
10 changed files with 407 additions and 46 deletions
@@ -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")
+40
View File
@@ -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": ["<note id>", ...],
"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
+1
View File
@@ -16,5 +16,6 @@ from . import ( # noqa: F401
saved_filter,
settings,
share,
share_revocation,
user,
)
+33
View File
@@ -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())
+4 -1
View File
@@ -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 = ""
+69
View File
@@ -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)
+9
View File
@@ -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)})
+95 -40
View File
@@ -2,6 +2,8 @@
Pull: `GET /api/sync/changes?since=<cursor>` 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/<id>` 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:
+95 -1
View File
@@ -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.
+11 -4
View File
@@ -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) ---------------------------------------------