summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_node_ws_auth.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 09:47:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit4cce50f09a73739387d5058a6f8183ebac65ae2c (patch)
tree30e9d72280295d913983910a956b0d3306696ec8 /packages/meshbay-hub/tests/test_node_ws_auth.py
parent9cc2909cb4a360c81b471ceab1d9578a7655a88e (diff)
downloadmeshbay-4cce50f09a73739387d5058a6f8183ebac65ae2c.tar.gz
fix: an empty group claim is a claim on nothing
A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/tests/test_node_ws_auth.py')
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py120
1 files changed, 120 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 1391722..4ead0d7 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -344,3 +344,123 @@ async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
assert second.status_code == 201, second.text
assert second.json()["node_id"] == first.json()["node_id"], (
"re-announcing the same key must not create a second node record (M8)")
+
+
+# ── An empty claim is not a claim on everything ──────────────────────────────
+#
+# 2026-09-11, found on a live deployment. `_claimable` used to read
+# `set(claimed_groups or authorized)`, and the node omits `group_ids` entirely
+# when it hosts nothing — so "I host no groups" was read as "I host all of
+# yours". The node cannot serve any of them (no GEK, and its own handshake
+# refuses them), but `/v1/groups/{id}/nodes` lists nodes in registration order,
+# so whenever such a node won the reconnection race after a hub restart it
+# became `nodes[0]` and the group stopped opening for every member.
+
+
+@pytest.mark.asyncio
+async def test_ws_absent_claim_registers_no_groups(client):
+ """A node that declares nothing hosts nothing — it must not inherit the set."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty1")
+ node_id = await _announce_node(client, user)
+ for name in ("has-one", "has-two"):
+ r = await client.post(
+ "/v1/groups",
+ json={"name": name, "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
+ assert resolved == node_id
+ assert groups == [], (
+ "a node hosting nothing was registered as a host for its owner's groups")
+
+
+@pytest.mark.asyncio
+async def test_ws_explicit_empty_claim_registers_no_groups(client):
+ """And the same when the node says so out loud, which it now does."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty2")
+ node_id = await _announce_node(client, user)
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "lonely", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, [])
+ assert resolved == node_id
+ assert groups == []
+
+
+@pytest.mark.asyncio
+async def test_empty_node_cannot_shadow_another_members_group(client):
+ """
+ The outage itself: two members, one group, and only one of them hosts it.
+ The other's node — running, configured with nothing — must not appear as a
+ source for that group, because it is the one clients would reach first.
+ """
+ from meshbay_hub.api.revocation import (
+ _authorize_node_ws, _node_groups, get_online_nodes_for_group)
+
+ host = await _make_user(client, "hoster")
+ guest = await _make_user(client, "guest")
+ host_node = await _announce_node(client, host)
+ guest_node = await _announce_node(client, guest)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "shared", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ group_id = r.json()["group_id"]
+
+ r = await client.post(
+ f"/v1/groups/{group_id}/members/{'guest'}",
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ # The guest's node registers first — the order that made this fatal.
+ _, guest_groups = await _authorize_node_ws(_node_token(guest), guest_node, None)
+ _, host_groups = await _authorize_node_ws(
+ _node_token(host), host_node, [group_id])
+ _node_groups[guest_node] = guest_groups
+ _node_groups[host_node] = host_groups
+ try:
+ assert get_online_nodes_for_group(group_id) == [host_node], (
+ "a member's empty node shadowed the node actually hosting the group")
+ finally:
+ _node_groups.pop(guest_node, None)
+ _node_groups.pop(host_node, None)
+
+
+@pytest.mark.asyncio
+async def test_update_groups_is_held_to_the_same_ceiling(client):
+ """
+ `update_groups` assigned the message's list verbatim, so the C2 ceiling held
+ at authentication could be stepped over one message later: a node had only
+ to reload to claim any group on the hub. It now goes through the same gate,
+ which is what this asserts — the socket loop itself needs a WebSocket the
+ ASGI harness has not got (see this module's docstring).
+ """
+ from meshbay_hub.api.revocation import _authorized_groups, _claimable
+
+ user = await _make_user(client, "reloader")
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "owned", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ own = r.json()["group_id"]
+
+ authorized = await _authorized_groups(user["user_id"])
+ assert _claimable([own, "someone-elses-group"], authorized) == [own]
+ assert _claimable([], authorized) == []
+ assert _claimable(None, authorized) == []