""" Two things one participant must not be able to decide for another. **That you are in a group.** An owner could add any username and the account became a member at once: the group was in its sidebar, named in its MNP tokens, and its client dialled the group's nodes — nodes the owner chose. So an owner's addition is an invitation until the invitee accepts it. **That a node hosts a group.** A node could register for any group its account belonged to, and clients keep the first registered node that completes the handshake — which any member's node does, since every member holds the group key. So a node hosts a group only if its account owns it or the owner approved it; the rest of its claim is a request the owner sees. Each test is two accounts or more, because a one-member test proves a one-member property (CLAUDE.md, "ask who pays"). """ import base64 import time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from membership import accept_invitation from meshbay_common.crypto import pk_to_b64 from meshbay_hub.db.models import GroupHost, Notification from sqlalchemy import select KEY = base64.b64encode(b"k" * 32).decode() async def _user(client, username): sk = Ed25519PrivateKey.generate() r = await client.post("/v1/users/register", json={ "username": username, "email": f"{username}@example.test", "auth_key": KEY}) assert r.status_code == 201, r.text uid = r.json()["user_id"] r = await client.post("/v1/users/login", json={"username": username, "auth_key": KEY}) return {"id": uid, "name": username, "sk": sk, "pk": pk_to_b64(sk.public_key()), "H": {"Authorization": f"Bearer {r.json()['access_token']}"}} async def _group(client, owner, name="family"): """A group that a node already hosts: `/mine` hides an unhosted group from everyone but its owner, which would make "not in /mine" prove nothing.""" from meshbay_hub.api.revocation import _mark_hosted r = await client.post("/v1/groups", headers=owner["H"], json={"name": name, "visibility": "private", "join_policy": "invite"}) gid = r.json()["group_id"] await _mark_hosted([gid]) return gid async def _node(client, user): ts = int(time.time()) msg = f"meshbay:node_announce:{user['id']}:{user['pk']}:{ts}".encode() r = await client.post("/v1/nodes/announce", headers=user["H"], json={ "pk_node": user["pk"], "timestamp": ts, "signature": base64.b64encode(user["sk"].sign(msg)).decode()}) assert r.status_code == 201, r.text return r.json()["node_id"] def _node_token(user): from meshbay_hub.auth import issue_access_token return issue_access_token(user["id"], scope="node") async def _mine(client, user): return [g["id"] for g in (await client.get("/v1/groups/mine", headers=user["H"])).json()["groups"]] # ── Invitations ────────────────────────────────────────────────────────────── async def test_being_added_is_an_invitation_not_a_membership(client): owner, invitee = await _user(client, "inv_owner"), await _user(client, "inv_guest") gid = await _group(client, owner) r = await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) assert r.json()["status"] == "invited" assert gid not in await _mine(client, invitee) r = await client.get(f"/v1/groups/{gid}/nodes", headers=invitee["H"]) assert r.status_code == 403, "an invitee must not be handed a node to dial" listed = (await client.get("/v1/groups/invitations", headers=invitee["H"])).json() assert [i["group_id"] for i in listed["invitations"]] == [gid] async def test_accepting_makes_a_member_and_declining_leaves_nothing(client): owner = await _user(client, "acc_owner") yes, no = await _user(client, "acc_yes_user"), await _user(client, "acc_no_user") gid = await _group(client, owner) for u in (yes, no): await client.post(f"/v1/groups/{gid}/members/{u['name']}", headers=owner["H"]) await accept_invitation(client, gid, yes["name"]) r = await client.post(f"/v1/groups/{gid}/invitation/decline", headers=no["H"]) assert r.status_code == 200 assert gid in await _mine(client, yes) assert gid not in await _mine(client, no) assert (await client.get("/v1/groups/invitations", headers=no["H"])).json()[ "invitations"] == [] r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=no["H"]) assert r.status_code == 404, "a declined invitation cannot be accepted afterwards" async def test_nobody_else_can_accept_an_invitation(client): owner, invitee = await _user(client, "only_owner"), await _user(client, "only_guest") other = await _user(client, "only_other") gid = await _group(client, owner) await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=other["H"]) assert r.status_code == 404 assert gid not in await _mine(client, other) async def test_the_owner_sees_and_can_take_back_an_invitation(client): owner, invitee = await _user(client, "back_owner"), await _user(client, "back_guest") member = await _user(client, "back_member") gid = await _group(client, owner) await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) await accept_invitation(client, gid, member["name"]) seen = (await client.get(f"/v1/groups/{gid}/members", headers=owner["H"])).json() assert [u["username"] for u in seen["invited"]] == [invitee["name"]] # Another member is not told who was asked. assert "invited" not in (await client.get(f"/v1/groups/{gid}/members", headers=member["H"])).json() r = await client.delete(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) assert r.status_code == 200 assert (await client.get("/v1/groups/invitations", headers=invitee["H"])).json()[ "invitations"] == [] # ── Hosts ──────────────────────────────────────────────────────────────────── async def test_a_members_node_does_not_host_a_group_it_does_not_own(client, db_session): from meshbay_hub.api.revocation import resolve_node_groups owner, member = await _user(client, "host_owner"), await _user(client, "host_member") gid = await _group(client, owner) await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) await accept_invitation(client, gid, member["name"]) member_node = await _node(client, member) owner_node = await _node(client, owner) assert await resolve_node_groups(member_node, member["id"], [gid]) == [] assert await resolve_node_groups(owner_node, owner["id"], [gid]) == [gid] row = await db_session.get(GroupHost, (gid, member_node)) assert row is not None and row.status == "pending" notes = (await db_session.execute(select(Notification).where( Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() assert len(notes) == 1 async def test_the_owner_approves_a_host_and_can_take_it_back(client, db_session): from meshbay_hub.api import revocation owner, member = await _user(client, "appr_owner"), await _user(client, "appr_member") gid = await _group(client, owner) await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) await accept_invitation(client, gid, member["name"]) node = await _node(client, member) await revocation.resolve_node_groups(node, member["id"], [gid]) # A connected node, as the socket handler records it. revocation._connected_nodes[node] = object() revocation._node_claims[node] = [gid] revocation._node_users[node] = member["id"] try: # Not the member's call to make. r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=member["H"]) assert r.status_code == 403 hosts = (await client.get(f"/v1/groups/{gid}/hosts", headers=owner["H"])).json() assert [(h["node_id"], h["status"]) for h in hosts["hosts"]] == [(node, "pending")] r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) assert r.status_code == 200 assert revocation._node_groups[node] == [gid], "approval must apply at once" r = await client.delete(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) assert r.status_code == 200 assert revocation._node_groups[node] == [] # Refused, and asking again does not notify the owner a second time. await revocation.resolve_node_groups(node, member["id"], [gid]) notes = (await db_session.execute(select(Notification).where( Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() assert len(notes) == 1 finally: revocation.forget_node(node) async def test_only_a_node_that_asked_can_be_approved(client): owner, member = await _user(client, "ask_owner"), await _user(client, "ask_member") gid = await _group(client, owner) node = await _node(client, member) r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) assert r.status_code == 404