aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_ops_links.py
blob: 9ad01d25325de3d415f8bbe68d13fd9b5da9b5e1 (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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
"""
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, then
the link. The address is optional and only labels 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
from datetime import UTC, datetime, timedelta

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_optional_but_must_be_one_when_given(roster):
    hub = _Hub()
    out = await ops.create_link_invitation(_state(roster, hub), GROUP)
    assert LINK.match(out["link"]), out["link"]
    assert hub.created == [(GROUP, "", out["invite_id"])]
    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_a_link_lives_seven_days_whatever_the_invite_setting(roster):
    class _Node:
        invite_ttl_hours = 24 * 30

    class _Config:
        node = _Node()

    state = _state(roster, _Hub())
    state["config"] = _Config()
    out = await ops.create_link_invite(state, GROUP)
    left = datetime.fromisoformat(out["expires_at"]) - datetime.now(UTC)
    assert timedelta(days=6, hours=23) < left <= timedelta(days=7)


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