1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
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
|