summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
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-node/tests
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-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_hub_ws_group_claim.py100
1 files changed, 100 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_hub_ws_group_claim.py b/packages/meshbay-node/tests/test_hub_ws_group_claim.py
new file mode 100644
index 0000000..49ac776
--- /dev/null
+++ b/packages/meshbay-node/tests/test_hub_ws_group_claim.py
@@ -0,0 +1,100 @@
+"""
+What the node claims to host, on the wire.
+
+2026-09-11: the node omitted `group_ids` from its WS auth frame whenever it
+hosted nothing (`if gids:`), and the hub read an absent claim as "every group
+this account belongs to". An unconfigured node was therefore registered as a
+source for other people's groups, could serve none of them, and — being listed
+first — took them down for every member.
+
+The hub no longer widens an absent claim. This pins the node's half: the empty
+set is stated, not left to be inferred from a missing field.
+"""
+
+import asyncio
+import json
+
+import pytest
+
+from meshbay_node.hub_client import HubClient, HubConfig, HubSession
+
+
+class _FakeWS:
+ """Just enough of a websockets connection for one auth round trip."""
+
+ def __init__(self, sent: list, authed: asyncio.Event):
+ self._sent = sent
+ self._authed = authed
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_):
+ return False
+
+ async def send(self, raw: str) -> None:
+ self._sent.append(json.loads(raw))
+
+ async def recv(self) -> str:
+ return json.dumps({"type": "auth_ok", "node_id": "node-1"})
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ # Registered; nothing more to drive. Hold here rather than closing, so
+ # the reconnect path does not run and muddy what was sent.
+ self._authed.set()
+ await asyncio.sleep(3600)
+ raise StopAsyncIteration
+
+
+async def _auth_frame(monkeypatch, group_ids) -> dict:
+ import websockets
+
+ sent: list = []
+ authed = asyncio.Event()
+ monkeypatch.setattr(
+ websockets, "connect", lambda *a, **kw: _FakeWS(sent, authed))
+
+ client = HubClient(HubConfig(hub_url="https://hub.test", username="u"),
+ keys=None)
+ client._session = HubSession(
+ hub_url="https://hub.test", username="u", user_id="user-1",
+ access_token="tok", refresh_token="ref", hub_pk_pem=b"", node_id="node-1")
+
+ task = asyncio.create_task(client.maintain_ws(group_ids=group_ids))
+ try:
+ await asyncio.wait_for(authed.wait(), timeout=5)
+ finally:
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ await client.close()
+
+ assert sent, "no auth frame was sent"
+ return sent[0]
+
+
+@pytest.mark.asyncio
+async def test_a_node_hosting_nothing_says_so(monkeypatch):
+ """The empty set on the wire, rather than a key the hub has to interpret."""
+ frame = await _auth_frame(monkeypatch, lambda: [])
+ assert "group_ids" in frame, (
+ "an absent claim is what the hub used to read as 'all of them'")
+ assert frame["group_ids"] == []
+
+
+@pytest.mark.asyncio
+async def test_a_node_declares_the_groups_it_hosts(monkeypatch):
+ frame = await _auth_frame(monkeypatch, lambda: ["g-1", "g-2"])
+ assert frame["group_ids"] == ["g-1", "g-2"]
+
+
+@pytest.mark.asyncio
+async def test_a_caller_with_nothing_to_declare_sends_no_key(monkeypatch):
+ """`None` is not the same as `[]`: it is "I am not answering that question"."""
+ frame = await _auth_frame(monkeypatch, None)
+ assert "group_ids" not in frame