diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:28:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:28:28 +0200 |
| commit | 0bd3f805ffbd04b40b5150474336a5e5d200e72b (patch) | |
| tree | 2a92a21e02bae823e9ed52d489d74ba8db3de9b6 /packages/meshbay-hub/tests/test_group_leave_and_quota.py | |
| parent | 0231d240b92a2a11042fa62c0222d4c4b96a859d (diff) | |
| download | meshbay-0bd3f805ffbd04b40b5150474336a5e5d200e72b.tar.gz | |
feat(hub): leaving a group, a cap on public ones, and hosting as a precondition
Leaving is its own endpoint rather than a relaxation of the owner's removal
check — an authorization rule with an exception in it is the one that gets read
wrong later. The owner cannot leave: the group would be left with nobody able
to admit, edit or delete it, which is the answer removal and account deletion
already give.
Public groups are capped at ten live ones per owner. They are the ones that
cost other people something — listed in Discover, joinable by anyone — so a
script that opens hundreds fills the directory for everybody. Private groups
are invisible to non-members and are not capped. Hub staff are exempt; the cap
is anti-spam, not a rule about running an instance. Creation is the only place
it can be checked, and deliberately so, because PATCH refuses to change
visibility at all.
A group is now listed only once a node has announced that it hosts it. Before
that it has no files, no key and nothing to connect to, so showing it to a
member produces a name they cannot open and cannot be told why; its owner still
sees it while they set the node up. `meshbay-hub prune-groups` collects the
ones that never got a node, meant for cron, with --dry-run. The migration
backfills hosted_at from created_at: without that the first run would have
deleted every live group.
Presence rides on the group list itself, read from the signaling registry the
hub already keeps — no poll, no timer. It says a node is connected *to the hub*,
which is not a promise that this browser can reach it and not something a
dishonest hub could not fake; the client downgrades it on a connection it tried
and failed, which is the evidence that concerns the reader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_group_leave_and_quota.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_leave_and_quota.py | 258 |
1 files changed, 258 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_group_leave_and_quota.py b/packages/meshbay-hub/tests/test_group_leave_and_quota.py new file mode 100644 index 0000000..dfa18b8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_leave_and_quota.py @@ -0,0 +1,258 @@ +""" +Leaving a group of your own accord, and the cap on public groups. + +Sits beside `test_group_membership.py`, which covers the owner removing someone +else. The two are deliberately different endpoints rather than one with an +exception for the self case, so they are tested apart. + +The refusals are the interesting half: the owner who cannot walk out and leave +the group unmanageable, and the eleventh public group. What leaving does *not* +do is asserted too — the hub can only drop a membership row. The node keeps the +identity it pinned, the keypair bundle and the uploaded files until its operator +removes them, and whoever left still holds the group key they were served. +""" + +import base64 +import hashlib +from datetime import datetime, timezone + +import pytest +from sqlalchemy import select + +from meshbay_hub.api.groups import MAX_PUBLIC_GROUPS +from meshbay_hub.db.models import Group, GroupMember, User + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +async def _group(client, owner, name, visibility="private"): + # A public group must be open to join — invite-only is refused, because a + # group everyone can find and nobody can enter is a dead end. + body = {"name": name, "visibility": visibility} + if visibility == "public": + body["join_policy"] = "open" + r = await client.post("/v1/groups", json=body, headers=owner) + assert r.status_code == 201, r.text + return r.json()["group_id"] + + +# ── Leaving ─────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_member_can_leave(client, db_session): + owner = await _user(client, "owner1") + member = await _user(client, "member1") + gid = await _group(client, owner, "readers") + await client.post(f"/v1/groups/{gid}/members/member1", json={}, headers=owner) + + # Marked hosted, or the member would not see the group in the first place + # and the assertion below would hold whether or not leaving worked. + g = await db_session.get(Group, gid) + g.hosted_at = datetime.now(timezone.utc) + await db_session.commit() + + before = await client.get("/v1/groups/mine", headers=member) + assert [g["id"] for g in before.json()["groups"]] == [gid], "precondition" + + r = await client.post(f"/v1/groups/{gid}/leave", headers=member) + assert r.status_code == 200, r.text + assert r.json()["status"] == "left" + + mine = await client.get("/v1/groups/mine", headers=member) + assert [g["id"] for g in mine.json()["groups"]] == [] + + +@pytest.mark.asyncio +async def test_leaving_removes_only_that_membership_row(client, db_session): + """The group and everyone else in it are untouched — this is not a deletion.""" + owner = await _user(client, "owner2") + member = await _user(client, "member2") + gid = await _group(client, owner, "still-here") + await client.post(f"/v1/groups/{gid}/members/member2", json={}, headers=owner) + + await client.post(f"/v1/groups/{gid}/leave", headers=member) + + assert await db_session.get(Group, gid) is not None + rows = (await db_session.execute( + select(GroupMember).where(GroupMember.group_id == gid))).scalars().all() + assert len(rows) == 1, "the owner should still be a member" + + mine = await client.get("/v1/groups/mine", headers=owner) + assert [g["id"] for g in mine.json()["groups"]] == [gid] + + +@pytest.mark.asyncio +async def test_leaving_does_not_touch_the_account_or_its_other_groups(client, db_session): + owner = await _user(client, "owner3") + member = await _user(client, "member3") + elsewhere = await _user(client, "owner3b") + gid = await _group(client, owner, "leaving") + other = await _group(client, elsewhere, "staying") + await client.post(f"/v1/groups/{gid}/members/member3", json={}, headers=owner) + await client.post(f"/v1/groups/{other}/members/member3", json={}, headers=elsewhere) + + await client.post(f"/v1/groups/{gid}/leave", headers=member) + + user = (await db_session.execute( + select(User).where(User.username == "member3"))).scalar_one() + assert user.status == "active" + assert await db_session.get(GroupMember, (other, user.id)) is not None + + +@pytest.mark.asyncio +async def test_the_owner_cannot_leave_their_own_group(client): + """It would leave the group with nobody able to admit, edit or delete it.""" + owner = await _user(client, "owner4") + gid = await _group(client, owner, "orphan-risk") + + r = await client.post(f"/v1/groups/{gid}/leave", headers=owner) + assert r.status_code == 409, r.text + assert "own this group" in r.json()["detail"] + + mine = await client.get("/v1/groups/mine", headers=owner) + assert [g["id"] for g in mine.json()["groups"]] == [gid] + + +@pytest.mark.asyncio +async def test_leaving_twice_is_refused(client): + owner = await _user(client, "owner5") + member = await _user(client, "member5") + gid = await _group(client, owner, "once") + await client.post(f"/v1/groups/{gid}/members/member5", json={}, headers=owner) + + assert (await client.post(f"/v1/groups/{gid}/leave", + headers=member)).status_code == 200 + assert (await client.post(f"/v1/groups/{gid}/leave", + headers=member)).status_code == 404 + + +@pytest.mark.asyncio +async def test_leaving_a_group_you_were_never_in_is_refused(client): + owner = await _user(client, "owner6") + stranger = await _user(client, "stranger6") + gid = await _group(client, owner, "not-yours") + + r = await client.post(f"/v1/groups/{gid}/leave", headers=stranger) + assert r.status_code == 404 + + +# ── Public group cap ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_public_groups_are_capped(client): + owner = await _user(client, "prolific") + for i in range(MAX_PUBLIC_GROUPS): + await _group(client, owner, f"public-{i}", visibility="public") + + r = await client.post("/v1/groups", + json={"name": "one-too-many", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 409, r.text + detail = r.json()["detail"] + assert str(MAX_PUBLIC_GROUPS) in detail + assert "private" in detail, "the message should name the way out" + + +@pytest.mark.asyncio +async def test_private_groups_are_not_capped(client): + """Private groups cost other people nothing — they are invisible to non-members.""" + owner = await _user(client, "hoarder") + for i in range(MAX_PUBLIC_GROUPS + 5): + await _group(client, owner, f"private-{i}") + + mine = await client.get("/v1/groups/mine", headers=owner) + assert len(mine.json()["groups"]) == MAX_PUBLIC_GROUPS + 5 + + +@pytest.mark.asyncio +async def test_a_suspended_public_group_does_not_hold_a_slot(client, db_session): + """Moderation already dealt with the owner; the slot should not punish twice.""" + owner = await _user(client, "moderated") + ids = [await _group(client, owner, f"pub-{i}", visibility="public") + for i in range(MAX_PUBLIC_GROUPS)] + + suspended = await db_session.get(Group, ids[0]) + suspended.status = "suspended" + await db_session.commit() + + r = await client.post("/v1/groups", + json={"name": "replacement", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_the_cap_is_per_owner(client): + """Being a member of someone else's public groups costs nothing.""" + a = await _user(client, "ownera") + await _user(client, "ownerb") + b = await _user(client, "ownerb2") + for i in range(MAX_PUBLIC_GROUPS): + gid = await _group(client, a, f"a-pub-{i}", visibility="public") + await client.post(f"/v1/groups/{gid}/members/ownerb2", json={}, headers=a) + + r = await client.post("/v1/groups", + json={"name": "b-first", "visibility": "public", + "join_policy": "open"}, + headers=b) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_hub_staff_are_exempt(client, db_session): + """The cap is anti-spam, not a rule about running an instance.""" + owner = await _user(client, "instanceadmin") + user = (await db_session.execute( + select(User).where(User.username == "instanceadmin"))).scalar_one() + user.role = "admin" + await db_session.commit() + + for i in range(MAX_PUBLIC_GROUPS + 2): + r = await client.post("/v1/groups", + json={"name": f"admin-pub-{i}", "visibility": "public", + "join_policy": "open"}, + headers=owner) + assert r.status_code == 201, r.text + + +# ── Presence in the group list ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_the_group_list_reports_node_presence(client): + """`node_online` rides on the request the sidebar already makes. + + It reflects the hub's signaling registry, so it says a node is connected + *to the hub* — not that this browser can reach it, and not something a + dishonest hub could not fake. The client downgrades it on a connection it + tried and failed, which is the evidence that concerns the reader. + """ + owner = await _user(client, "watcher") + gid = await _group(client, owner, "quiet") + + mine = await client.get("/v1/groups/mine", headers=owner) + entry = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert entry["node_online"] is False + + from meshbay_hub.api import revocation + revocation._node_groups["node-x"] = [gid] + try: + mine = await client.get("/v1/groups/mine", headers=owner) + entry = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert entry["node_online"] is True + finally: + revocation._node_groups.pop("node-x", None) |