summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_transport_contracts.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_transport_contracts.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_transport_contracts.py')
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py54
1 files changed, 54 insertions, 0 deletions
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