sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s

The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:01:53 -04:00
co-authored by Claude Opus 5.5
parent f100e5ef85
commit f53d377766
24 changed files with 911 additions and 50 deletions
+159 -1
View File
@@ -30,6 +30,7 @@ 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.share import Share
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
@@ -47,7 +48,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, invites, password_resets, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, invites, password_resets, users"
@pytest_asyncio.fixture
@@ -1179,3 +1180,160 @@ async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_clie
await _signed_in(app_client, "web")
resp = await app_client.delete("/api/auth/devices/self")
assert resp.status_code == 400
# --- Sharing a note (#5174) ---------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.
async def _three_people(app_client):
"""The owner (admin, signed in on `app_client`), a recipient and a stranger, each
signed in on a client of their own. Returns (recipient, stranger, ids by name)."""
first = await _admin_with_invite(app_client)
second = (await (await app_client.post("/api/invites", json={})).get_json())["token"]
people = {}
clients = []
for name, token in (("recipient", first), ("stranger", second)):
client = create_app().test_client()
joined = await client.post(
"/api/auth/register",
json={"email": f"{name}@example.test", "password": _PASSWORD, "display_name": name.title(), "invite": token},
)
assert joined.status_code == 201
people[name] = (await joined.get_json())["id"]
clients.append(client)
return clients[0], clients[1], people
async def _owners_note(app_client, body: str = "a shared thought #idea") -> str:
resp = await app_client.post("/api/notes", json={"body": body})
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["id"]
async def _share(app_client, nid: str, user_id: str, permission: str = "view"):
return await app_client.post(f"/api/notes/{nid}/shares", json={"user_id": user_id, "permission": permission})
async def _board_ids(client, query: str = "") -> list[str]:
return [n["id"] for n in (await (await client.get(f"/api/notes?{query}")).get_json())["notes"]]
async def test_a_shared_note_reaches_the_recipient_and_no_one_else(app_client, db):
recipient, stranger, people = await _three_people(app_client)
nid = await _owners_note(app_client)
# The directory is everyone but you.
members = (await (await app_client.get("/api/users/directory")).get_json())["members"]
assert sorted(m["email"] for m in members) == ["recipient@example.test", "stranger@example.test"]
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
shared = await _share(app_client, nid, people["recipient"])
assert shared.status_code == 201, await shared.get_data(as_text=True)
assert [(s["member"]["email"], s["permission"]) for s in (await shared.get_json())["shares"]] == [
("recipient@example.test", "view")
]
seen = await (await recipient.get(f"/api/notes/{nid}")).get_json()
assert seen["permission"] == "view"
assert seen["shared_by"]["display_name"] == "owner"
# Labels are personal: the owner's #idea doesn't come with the note.
assert seen["labels"] == []
assert nid in await _board_ids(recipient)
assert await _board_ids(recipient, "shared=with_me") == [nid]
mine = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert (mine["permission"], mine["shared"], mine["shared_by"]) == ("owner", True, None)
assert [lb["name"] for lb in mine["labels"]] == ["idea"]
assert await _board_ids(app_client, "shared=with_me") == []
# The stranger, on the same instance, sees nothing of it.
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
assert nid not in await _board_ids(stranger)
async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line\n- [ ] milk")
await _share(app_client, nid, people["recipient"], "view")
# View: every write is a 404, the same answer a stranger gets (#1984).
writes = [
recipient.patch(f"/api/notes/{nid}", json={"body": "mine now"}),
recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"}),
recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True}),
recipient.post(f"/api/notes/{nid}/trash"),
recipient.put(f"/api/notes/{nid}/labels", json={"label_ids": []}),
recipient.get(f"/api/notes/{nid}/shares"),
recipient.post(f"/api/notes/{nid}/shares", json={"user_id": people["stranger"]}),
]
for pending in writes:
assert (await pending).status_code == 404
# Sharing again changes the permission rather than adding a second grant.
upgraded = await _share(app_client, nid, people["recipient"], "edit")
assert [s["permission"] for s in (await upgraded.get_json())["shares"]] == ["edit"]
# Edit: the text and the checklist.
edited = await recipient.patch(f"/api/notes/{nid}", json={"body": "first line, edited #fromguest\n- [ ] milk"})
assert edited.status_code == 200, await edited.get_data(as_text=True)
assert (await edited.get_json())["labels"] == []
ticked = await recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True})
assert ticked.status_code == 200
assert (await recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"})).status_code == 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 stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "archived": True})).status_code == 403
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
assert (await recipient.get(f"/api/notes/{nid}/revisions")).status_code == 404
body = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"]
assert body.startswith("first line, edited")
assert "- [x] milk" in body and "eggs" in body
async def test_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