diff options
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) |