diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 11:26:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 11:26:00 +0200 |
| commit | ba523e6ba3cb02f9b9612b74596d09c45e22dbdb (patch) | |
| tree | 56906d4d21734afa6113785b5a52ed01ad86ba2f /packages/meshbay-node/tests/test_roster_pairing.py | |
| parent | 75669dcc4f060733f0fcf3b6de574b8f4630d4a1 (diff) | |
| download | meshbay-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/test_roster_pairing.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 44 |
1 files changed, 43 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 |