summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 11:26:00 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 11:26:00 +0200
commitba523e6ba3cb02f9b9612b74596d09c45e22dbdb (patch)
tree56906d4d21734afa6113785b5a52ed01ad86ba2f /packages/meshbay-node/tests
parent75669dcc4f060733f0fcf3b6de574b8f4630d4a1 (diff)
downloadmeshbay-ba523e6ba3cb02f9b9612b74596d09c45e22dbdb.tar.gz
fix(node): an invitation the hub never registered is a code nobody can use
`create_invite` wrote the invite to the roster and *then* asked for the hub. An unreachable hub therefore raised "Hub not connected" after the code was already stored: the operator saw an error and no code, and a valid invitation sat in the roster that nobody had been given. Every retry left another. Registering first means a failure costs nothing — no code exists to be orphaned. A membership row without an invite is harmless: without the code there is still no group key. The endpoint is idempotent (`if not mem: db.add(...)`, no 409), so the SPA registering the same membership again right after createInvite costs nothing either. The registration is now fatal rather than swallowed, which is the part that matters. `/v1/groups/mine` joins GroupMember, so someone who was never registered does not see the group at all and can never redeem the code. Tolerating that failure handed the operator a code that cannot work and said nothing — a worse outcome than the error, because it is silent. Skipped only when there is no username to register with: the MNP path allows an empty one and there the SPA is the one that registers. Found by test_invite_then_join_delivers_the_gek, whose fixture had no hub and which passed only because the failure was swallowed. It has one now. And 0443cf8 added this registration to the CLI path without any test asserting it happened, which is how it came to be skipped whenever the hub was merely absent — test_cli_invite_asks_the_hub_for_an_account_ never_a_key checks it now, and test_an_unreachable_hub_leaves_no_invite_behind covers the orphan (verified failing against the previous ordering). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py44
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py25
2 files changed, 68 insertions, 1 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index e1b8a38..8bedfad 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -751,18 +751,31 @@ async def test_cli_invite_asks_the_hub_for_an_account_never_a_key(tmp_path, rost
"""
class _Hub:
_session = object()
+ added = []
async def get_user_pubkeys(self, username):
return {"user_id": f"id-of-{username}",
"pk_x25519": "SHOULD-NOT-BE-USED",
"pk_ed25519": "SHOULD-NOT-BE-USED"}
- client, _ = _ui_client(tmp_path, roster, hub=_Hub())
+ async def add_group_member(self, group_id, username):
+ self.added.append((group_id, username))
+ return {"status": "stored"}
+
+ hub = _Hub()
+ client, _ = _ui_client(tmp_path, roster, hub=hub)
resp = client.post(f"/api/groups/{GROUP}/invites?username=bob&t=tok")
assert resp.status_code == 200
body = resp.json()
assert body["user_id"] == "id-of-bob"
+ # The CLI path is the one with no browser to register the membership, so
+ # the node must do it — `/v1/groups/mine` joins `GroupMember`, and without
+ # a row there the invitee never sees the group. Nothing checked this when
+ # the registration was added, which is how it came to be skipped whenever
+ # the hub was merely absent.
+ assert hub.added == [(GROUP, "bob")]
+
invites = await roster.list_invites()
assert [i["user_id"] for i in invites] == ["id-of-bob"]
# Whatever the hub said about keys was never stored anywhere.
@@ -770,6 +783,35 @@ async def test_cli_invite_asks_the_hub_for_an_account_never_a_key(tmp_path, rost
assert await roster.get_identity("id-of-bob") is None
+async def test_an_unreachable_hub_leaves_no_invite_behind(tmp_path, roster):
+ """
+ A code the invitee could never redeem must not exist.
+
+ `create_invite` used to write the invite to the roster and *then* ask for
+ the hub, so an unreachable hub raised `Hub not connected` after the code was
+ already stored: the operator saw an error, no code, and a valid invitation
+ sat in the roster that nobody had been given. Every retry left another.
+
+ The registration now happens first, so a hub that is down costs nothing.
+ """
+ class _DeadHub:
+ _session = object()
+
+ async def get_user_pubkeys(self, username):
+ return {"user_id": f"id-of-{username}"}
+
+ async def add_group_member(self, group_id, username):
+ raise ConnectionError("hub is down")
+
+ client, _ = _ui_client(tmp_path, roster, hub=_DeadHub())
+ resp = client.post(f"/api/groups/{GROUP}/invites?username=bob&t=tok")
+
+ assert resp.status_code != 200, "an invite was issued that cannot be redeemed"
+ assert await roster.list_invites() == [], (
+ "the hub was unreachable and an invitation was left in the roster "
+ "anyway — a code nobody was handed, and nobody can use")
+
+
def _run_cli(monkeypatch, tmp_path, argv, responses):
"""Drive the real CLI with the daemon API stubbed, capturing the calls."""
import sys as _sys
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 159ba63..a4c7f61 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -1105,6 +1105,23 @@ def x25519_keypair():
return sk_raw, pk_raw
+class _InviteHub:
+ """Just enough hub for `ops.create_invite`: a live session, and a member
+ registration that records what it was asked to do."""
+
+ class _S:
+ user_id = "node-user"
+
+ _session = _S()
+
+ def __init__(self):
+ self.added = []
+
+ async def add_group_member(self, group_id, username):
+ self.added.append((group_id, username))
+ return {"status": "stored"}
+
+
@pytest.mark.asyncio
async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_dir,
tmp_path, x25519_keypair):
@@ -1134,9 +1151,17 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di
transport._ctx["groups"] = {
TEST_GROUP: {"gek": gek, "roots": shared_dir, "index": indexer.index},
}
+ # `create_invite` registers the invitee as a hub member *before* writing the
+ # invite, and fails the whole operation if it cannot: `/v1/groups/mine`
+ # joins `GroupMember`, so someone never registered does not see the group
+ # and could never redeem the code. Without a hub here the operation is
+ # correctly refused — this test used to have none, and passed only because
+ # the registration failure was swallowed and the unredeemable code returned
+ # anyway.
transport._ctx["daemon_state"] = {
"roster": roster,
"groups_ctx": transport._ctx["groups"],
+ "hub": _InviteHub(),
}
# A paired operator, as `meshbay-node operator pair` would have left it.