aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_invitations_and_hosts.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
commitd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch)
tree95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub/tests/test_invitations_and_hosts.py
parent69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff)
downloadmeshbay-d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4.tar.gz
fix: only the owner decides who hosts a group, and nobody is made a member unasked
- hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_invitations_and_hosts.py')
-rw-r--r--packages/meshbay-hub/tests/test_invitations_and_hosts.py204
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