groups: admin-managed groups, sharing a note with one, and membership in the feed

/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.

A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:25:16 -04:00
co-authored by Claude Opus 5.5
parent 2e2714a50b
commit 5f3cfe8bb7
6 changed files with 358 additions and 34 deletions
+81 -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, share_revocations, note_user_state, invites, password_resets, users"
_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
@@ -1511,6 +1511,86 @@ async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client,
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.