From 1d169141f3a5542efda2f7fdb0bb88308c06191b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 18:16:02 +0200 Subject: feat(node): member invite --link and member cancel in the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-node/tests/test_ops_links.py | 107 ++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/meshbay-node/tests/test_ops_links.py (limited to 'packages/meshbay-node/tests/test_ops_links.py') 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 -- cgit v1.2.3