aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_ops_links.py
blob: 00258d34d7c2686974ccd894d65b912b7cee7895 (plain) (blame)
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