diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_cli_dispatch.py | 3 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_ops_links.py | 107 |
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 |