""" 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