summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 18:16:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 18:16:02 +0200
commit1d169141f3a5542efda2f7fdb0bb88308c06191b (patch)
tree86162905ad819d8c9dd325ccf9629bcdacc50bbf /packages/meshbay-node/tests
parent35a7764db3f58a93c32206cb3ce74bb2f03967e7 (diff)
downloadmeshbay-1d169141f3a5542efda2f7fdb0bb88308c06191b.tar.gz
feat(node): member invite --link and member cancel in the CLI
The CLI makes both halves itself — the node's code, then the hub's ticket bound to the address — and prints the link; a refused ticket takes the code back, and cancel takes back both. The CLI never asks the hub to mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py3
-rw-r--r--packages/meshbay-node/tests/test_ops_links.py107
2 files changed, 110 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index cf2fd6e..a0b2a64 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -38,6 +38,8 @@ VERBS = [
["gek-init"],
["member", "list"],
["member", "invite", "bob"],
+ ["member", "invite", "bob@example.test", "--link"],
+ ["member", "cancel", "ab" * 16],
["member", "revoke", "bob"],
["member", "unpin", "bob"],
# Removed, and it has to say so rather than offering a username for a verb
@@ -91,6 +93,7 @@ def stub_daemon(monkeypatch, tmp_path):
"groups": [], "files": [], "identities": [], "members": [],
"invites": [], "users": [], "jtis": [], "count": 0, "removed": 0,
"subject": "all", "code": "TEST-CODE", "expires_at": "",
+ "link": "https://example.invalid/#/invite?v=1", "invite_id": "ab" * 16,
"user_id": "u", "authorized_members": 0, "errors": [],
"name": "g", "group_id": "g", "shared_dir": str(tmp_path),
"config": str(tmp_path / "node.toml"),
diff --git a/packages/meshbay-node/tests/test_ops_links.py b/packages/meshbay-node/tests/test_ops_links.py
new file mode 100644
index 0000000..00258d3
--- /dev/null
+++ b/packages/meshbay-node/tests/test_ops_links.py
@@ -0,0 +1,107 @@
+"""
+Invitation links from the operator's own machine (`member invite --link`).
+
+The CLI makes both halves itself: the node's code, then the hub's ticket bound
+to an address, then the link. What can go wrong is a half left behind — a code
+the hub never ticketed, occupying one of the group's places, or a ticket whose
+code was cancelled — and the CLI asking the hub to mail, which it may not.
+"""
+
+import re
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_node import ops
+from meshbay_node.roster import KIND_LINK, Roster
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+LINK = re.compile(
+ r"^https://hub\.example/#/invite\?v=1&g=" + GROUP
+ + r"&t=[A-Za-z0-9_-]{22}&n=[A-Za-z0-9_-]{43}&c=[0-9A-Z]{4}-[0-9A-Z]{4}$")
+
+
+class _Hub:
+ hub_url = "https://hub.example/some/path"
+ _session = True
+
+ def __init__(self, refuse=False, unreachable=False):
+ self.refuse, self.unreachable = refuse, unreachable
+ self.created, self.deleted, self.links = [], [], []
+
+ async def create_invite_link(self, group_id, email, expires_at, node_invite_id, **kw):
+ assert not kw, "the CLI asks the hub for nothing else — no mail"
+ if self.refuse:
+ raise RuntimeError("429 too many links")
+ self.created.append((group_id, email, node_invite_id))
+ self.links.append({"link_id": f"L{len(self.links)}", "status": "pending",
+ "node_invite_id": node_invite_id})
+ return {"ticket": "AbCdEfGhIjKlMnOpQr-_12", "expires_at": expires_at}
+
+ async def list_invite_links(self, group_id):
+ if self.unreachable:
+ raise RuntimeError("hub down")
+ return self.links
+
+ async def delete_invite_link(self, group_id, link_id):
+ self.deleted.append(link_id)
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = Roster(db_path=tmp_path / "roster.db")
+ await r.open()
+ yield r
+ await r.close()
+
+
+def _state(roster, hub):
+ return {"roster": roster, "groups_ctx": {GROUP: {}}, "hub": hub,
+ "sk_node": Ed25519PrivateKey.generate(), "config": None}
+
+
+async def test_a_whole_link_names_the_code_the_node_holds(roster):
+ hub = _Hub()
+ out = await ops.create_link_invitation(_state(roster, hub), GROUP, " Alice@Example.test ")
+ assert LINK.match(out["link"]), out["link"]
+ assert hub.created == [(GROUP, "Alice@Example.test", out["invite_id"])]
+ code = out["link"].rsplit("&c=", 1)[1]
+ assert await roster.consume_invite(code, "alice", group_id=GROUP)
+
+
+async def test_a_refused_ticket_takes_the_code_back(roster):
+ with pytest.raises(ops.OpError) as refused:
+ await ops.create_link_invitation(_state(roster, _Hub(refuse=True)), GROUP,
+ "alice@example.test")
+ assert refused.value.status == 502
+ assert [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] == []
+
+
+async def test_an_address_is_required(roster):
+ with pytest.raises(ops.OpError) as refused:
+ await ops.create_link_invitation(_state(roster, _Hub()), GROUP, "alice")
+ assert refused.value.status == 422
+
+
+async def test_cancelling_takes_back_both_halves(roster):
+ hub = _Hub()
+ state = _state(roster, hub)
+ out = await ops.create_link_invitation(state, GROUP, "alice@example.test")
+ code = out["link"].rsplit("&c=", 1)[1]
+ done = await ops.cancel_link_invitation(state, GROUP, out["invite_id"])
+ assert done["node"] and done["hub"] and hub.deleted == ["L0"]
+ assert await roster.consume_invite(code, "alice", group_id=GROUP) is None
+
+
+async def test_an_unreachable_hub_does_not_stop_the_node_half(roster):
+ hub = _Hub()
+ state = _state(roster, hub)
+ out = await ops.create_link_invitation(state, GROUP, "alice@example.test")
+ hub.unreachable = True
+ done = await ops.cancel_link_invitation(state, GROUP, out["invite_id"])
+ assert done["node"] is True and done["hub"] is False
+
+
+async def test_an_unknown_link_is_a_refusal_not_a_success(roster):
+ with pytest.raises(ops.OpError) as refused:
+ await ops.cancel_link_invitation(_state(roster, _Hub()), GROUP, "ab" * 16)
+ assert refused.value.status == 404