aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-13 21:18:23 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-13 21:18:23 +0200
commitdd7d9c80baafe08287516fd4d486223b5199b102 (patch)
tree5a9fd048b86d6d8794caa6d82ae4d1d7cdf4d327 /packages/meshbay-hub/tests
parent729d28030746bb8b3688f85833c228acd304be61 (diff)
downloadmeshbay-dd7d9c80baafe08287516fd4d486223b5199b102.tar.gz
fix(hub): a node that hosts nothing is not a free target, and a private group's hosts are its members'
Two checks that were not performed, in the same shape: something true of the public case, written so it did not apply to the rest. **Signaling.** The membership check read `if node_group_ids:`, so an empty set skipped it — membership, group status and the public-group gate together — and the offer was relayed. Since AV1 made an empty claim mean "no groups" rather than "all of my owner's", that is the *normal* registration of a node hosting nothing: the unconfigured node left running, the machine in this register's founding incident, and the one least able to absorb the work. Each offer makes it allocate an RTCPeerConnection and gather ICE, 30 a minute, which is H6 restored in exactly the case AV1 made common. It is refused now. Nothing legitimate is lost: such a node refuses the handshake anyway — `group_id` is mandatory (M1) and a node with no group key refuses (NS8) — so this declines work the node would decline one step later at its own expense. **The node list.** `GET /v1/groups/{id}/nodes` checked membership only for a public group with public groups switched off. A private one answered any authenticated account that knew the id — which an ex-member knows for ever — with the ids and public keys of the machines hosting it. §7.4 already stated the property for the public case. Membership is required now unless the group is public and public groups are on. Nothing needs the list before joining: an open join writes the membership row first, and an invitation registers the invitee's when the code is created, both checked before making the change. `test_webrtc_signaling_roundtrip` had to be repaired, and is the finding restated: it registered a node in `_connected_nodes` and never in `_node_groups`, so it drove the relay through the hole rather than through the door — the check it routed around was the defect. It now registers the group a real node registers. §7.2 and §7.3 gain the rules, the register AV24 and AV25, and test_availability_between_members.py two cases that fail on the property against the previous source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py123
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py12
2 files changed, 135 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 282169d..bfbfcc0 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -518,3 +518,126 @@ async def test_a_list_cannot_be_asked_for_the_whole_table(client):
r = await client.get("/v1/notifications?limit=20", headers=headers)
assert r.status_code == 200, r.text
+
+
+# ── A node that hosts nothing is not a free target ──────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_node_hosting_nothing_is_not_brokered_to_a_stranger(client):
+ """
+ Signaling read its membership check as `if node_group_ids:` — so when the
+ set was empty, the membership check, the group-status check and the
+ public-group gate were all skipped and the offer was relayed.
+
+ Since AV1, an empty claim is the *normal* registration of a node that hosts
+ nothing: the unconfigured node left running, which is the machine in this
+ register's founding incident. Each offer makes it allocate an
+ `RTCPeerConnection` and gather ICE, which is finding H6 restored in exactly
+ the case AV1 made common — and the stranger paying nothing for it.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_sig_owner")
+ stranger = await _make_user(client, "av_sig_stranger")
+ node_id = await _announce_node(client, owner)
+
+ class _FakeWS:
+ def __init__(self):
+ self.sent = []
+
+ async def send_text(self, text):
+ self.sent.append(text)
+
+ ws = _FakeWS()
+ rev._connected_nodes[node_id] = ws
+ rev._node_groups[node_id] = [] # hosts nothing, as in the incident
+ try:
+ r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0\r\noffer", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {stranger['token']}"})
+ assert r.status_code == 403, r.text
+ assert not ws.sent, (
+ "the node was made to negotiate for someone with no group on it")
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_a_member_still_reaches_the_node_they_share_a_group_with(client):
+ """The other half, so the test above is about the claim and not about
+ refusing everyone."""
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_sig_owner2")
+ member = await _make_user(client, "av_sig_member2")
+ group_id = await _make_group(client, owner, "shared-one")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ answered = []
+
+ class _AnsweringWS:
+ async def send_text(self, text):
+ import json as _json
+ from meshbay_hub.api.signaling import handle_webrtc_answer
+ msg = _json.loads(text)
+ answered.append(msg)
+ handle_webrtc_answer({"peer_id": msg["peer_id"], "sdp": "v=0\r\nanswer",
+ "ice_candidates": []}, node_id)
+
+ rev._connected_nodes[node_id] = _AnsweringWS()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0\r\noffer", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {member['token']}"})
+ assert r.status_code == 200, r.text
+ assert answered
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+# ── A private group's hosts are not public knowledge ────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_private_groups_node_list_is_for_its_members(client):
+ """
+ `GET /v1/groups/{id}/nodes` checked membership only for a public group with
+ public groups switched off. A private group answered any authenticated
+ account that knew the id — which an ex-member knows for ever — with the ids
+ and public keys of the machines hosting it.
+
+ §7.4 already states the property for the public case: a non-member is handed
+ no node to connect to. This is that sentence, for the groups the whole
+ design optimises for.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "av_nodes_owner")
+ member = await _make_user(client, "av_nodes_member")
+ stranger = await _make_user(client, "av_nodes_stranger")
+ group_id = await _make_group(client, owner, "private-hosts")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ rev._node_groups[node_id] = [group_id]
+ rev._connected_nodes[node_id] = object()
+ try:
+ for who in (owner, member):
+ r = await client.get(
+ f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {who['token']}"})
+ assert r.status_code == 200, r.text
+ assert [n["node_id"] for n in r.json()["nodes"]] == [node_id]
+
+ r = await client.get(
+ f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {stranger['token']}"})
+ assert r.status_code == 403, (
+ "a stranger who knows the group id learned which machines host it: "
+ + r.text)
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 5bb6af9..73f7fea 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -682,6 +682,17 @@ async def test_webrtc_signaling_roundtrip(client, app):
fake_ws = FakeWS(node_id)
_connected_nodes[node_id] = fake_ws
+ # The caller must share an active group with the node, which is what this
+ # test used to get away without: a node registered for no group skipped the
+ # membership check entirely, so this exercised the relay through the hole
+ # rather than through the door. Registering the group is what a real node
+ # does on its socket.
+ from meshbay_hub.api.revocation import _node_groups
+ r = await client.post("/v1/groups", json={"name": "sig-group"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 201, r.text
+ _node_groups[node_id] = [r.json()["group_id"]]
+
try:
r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
json={"sdp": "v=0\r\noffer-sdp", "ice_candidates": []},
@@ -693,6 +704,7 @@ async def test_webrtc_signaling_roundtrip(client, app):
assert "peer_id" in data
finally:
_connected_nodes.pop(node_id, None)
+ _node_groups.pop(node_id, None)
# ── IP log cleanup (8.9) ────────────────────────────────────────────────────