diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_node_ws_auth.py | 120 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 54 |
2 files changed, 174 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) == [] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index f2f4372..c506ff1 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -452,3 +452,57 @@ def test_the_index_is_never_reported_from_a_failed_decrypt(transport): assert "catch" not in body, ( "_applyIndexMessage swallows its own failure instead of letting " "_queueIndexMessage end the session") + + +# ── One node refusing must not take a group down (2026-09-11) ──────────────── +# +# `/v1/groups/{id}/nodes` returns every node registered for the group, in hub +# registration order. GroupPage took `nodesData.nodes[0]` and stopped there, so +# a node that could not serve the group — refusing the handshake with "Group +# not hosted on this node" — made the group unopenable while the node that +# *did* host it sat second in the same list. +# +# These strip comments first. The lesson this repeats otherwise is the CSP read +# out of the comment above the meta tag, and the packaging unit whose test +# matched the comment explaining why `User=` was absent: a source-level check +# that can match prose is not a check. + +def _code_only(src: str) -> str: + """Source with // and /* */ comments removed. Crude, and enough here: no + string literal in these files carries a comment marker.""" + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return re.sub(r"^\s*//.*$", "", src, flags=re.M) + + +def test_the_group_page_tries_every_node_the_hub_offers(group_page): + code = _code_only(group_page) + assert "for (const n of nodesData.nodes)" in code, ( + "the connect effect must walk the list, not index into it") + assert "nodesData.nodes[0]" not in code, ( + "taking the head and stopping is the defect — one wrongly registered " + "node captured the whole group's traffic") + + +def test_a_not_hosted_refusal_moves_on_to_the_next_node(group_page): + code = _code_only(group_page) + body = code[code.index("for (const n of nodesData.nodes)"):] + body = body[:body.index("if (!transport)")] + assert "not_hosted" in body, ( + "without the code, a refusal this browser cannot act on is " + "indistinguishable from one it must stop for") + assert "throw e" in body, ( + "a refusal naming a state of this browser — a pairing code, a " + "passphrase, a device — is the same from every node and must stop here") + + +def test_the_last_refusal_is_what_the_reader_is_told(group_page): + code = _code_only(group_page) + assert "if (!transport) throw (lastErr" in code, ( + "exhausting the list must report why, not fall through silently") + + +def test_the_refusal_the_loop_keys_on_has_a_message(transport): + """A code the page routes on, with nothing to show, is a blank error.""" + refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):] + refusals = refusals[:refusals.index("};")] + assert "not_hosted:" in refusals |