diff options
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 35 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 44 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 25 |
3 files changed, 96 insertions, 8 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 6d53edb..7a8e8ac 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -180,6 +180,34 @@ async def create_invite(state: dict, group_id: str, username: str, *, raise OpError(f"Unknown user {username!r}: {e}", status=404) from e user_id = account["user_id"] + # Hub membership first, and fatal if it fails. + # + # `/v1/groups/mine` joins `GroupMember`, so someone who was never registered + # does not see the group at all and can never redeem the code. Creating the + # invite first and tolerating a failed registration — which is what this did + # — hands the operator a code that cannot work, and says nothing. Worse, an + # unreachable hub raised *after* the roster write, leaving a valid code + # nobody was ever given; every retry left another. + # + # Registering before the roster write 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` + # (group-settings.js) costs nothing either. + # + # Skipped only when there is no username to register with: the MNP path + # allows an empty one (`username || ''` in transport.js), and there the SPA + # is the one that registers. + if username: + try: + await _hub(state).add_group_member(group_id, username) + except Exception as e: + raise OpError( + f"Could not register {username!r} on the hub, so the invite " + f"could not be redeemed: {e}", status=502) from e + config = state.get("config") ttl = (config.node.invite_ttl_hours if config else 168) * 3600 code = await roster.create_invite( @@ -191,13 +219,6 @@ async def create_invite(state: dict, group_id: str, username: str, *, username=username, ) - hub = _hub(state) - try: - await hub.add_group_member(group_id, username) - except Exception: - log.warning("Could not register %s as hub member of %s", - username, group_id[:8]) - invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites if i["user_id"] == user_id 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. |