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
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:
+159
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user