summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py35
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py44
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py25
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.