""" 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)