diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_invitations_and_hosts.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_invitations_and_hosts.py | 204 |
1 files changed, 204 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_invitations_and_hosts.py b/packages/meshbay-hub/tests/test_invitations_and_hosts.py new file mode 100644 index 0000000..6460d09 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invitations_and_hosts.py @@ -0,0 +1,204 @@ +""" +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 |