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