aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_link_invites.py
blob: 10f9ca880f8512278adb8d4b0e39d1515954263c (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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""
Invitation links: a code bound to no account until somebody redeems it.

A link is sent to someone who may not have an account yet, so its code cannot
name one. That makes it a bearer code at the node — the hub's ticket, bound to
a verified address, is what decides who can reach the node at all
(docs/MESHBAY_DESIGN.md §3.4). Everything here is a way a bearer code could be made
to mean more than "one new member of this group, once", or a way an unbound row
could leak into the code paths written for bound ones. The second family is the
one to watch: `user_id = ''` must never read as "anyone" (AV1).
"""

import sqlite3
from datetime import UTC, datetime, timedelta

import pytest
from meshbay_common.crypto import generate_gek, unwrap_gek_aes
from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
from meshbay_node.roster import (
    KIND_ACCOUNT,
    KIND_LINK,
    MAX_LINK_INVITES_PER_GROUP,
    LinkInviteLimit,
    Roster,
    hash_code,
)
from test_roster_pairing import _join_msg, _keypair, _keypair_full, _last, _session, _x_raw

GROUP_A = "a" * 32
GROUP_B = "b" * 32


@pytest.fixture
async def roster(tmp_path):
    r = Roster(db_path=tmp_path / "roster.db")
    await r.open()
    yield r
    await r.close()


async def _link(roster, group_id=GROUP_B):
    code, invite_id, _expires = await roster.create_link_invite(group_id, "cbesson")
    return code, invite_id


# ── The roster ───────────────────────────────────────────────────────────────

async def test_a_link_is_redeemed_once_and_then_names_its_redeemer(roster):
    code, _ = await _link(roster)
    invite = await roster.consume_invite(code, "alice", group_id=GROUP_B)
    assert invite and invite["role"] == ROLE_MEMBER and invite["group_id"] == GROUP_B
    assert invite["user_id"] == "alice"

    assert await roster.consume_invite(code, "mallory", group_id=GROUP_B) is None
    used = [i for i in await roster.list_invites(include_used=True)
            if i["code_hash"] == hash_code(code)]
    assert used[0]["user_id"] == "alice" and used[0]["used_at"]


async def test_a_link_is_good_for_its_own_group_only(roster):
    code, _ = await _link(roster, GROUP_B)
    for other in (GROUP_A, ""):
        assert await roster.consume_invite(code, "alice", group_id=other) is None
    # Not spent by the refusals.
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B)


async def test_an_active_member_cannot_spend_somebody_elses_link(roster):
    await roster.set_member(GROUP_B, "bob", ROLE_MEMBER, "active", "cbesson")
    code, _ = await _link(roster)
    assert await roster.consume_invite(code, "bob", group_id=GROUP_B) is None
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B)


async def test_a_link_never_carries_operator_authority(roster, tmp_path):
    code, _ = await _link(roster)
    rows = [i for i in await roster.list_invites() if i["kind"] == KIND_LINK]
    assert rows and all(r["role"] == ROLE_MEMBER for r in rows)

    # Even a row edited on disk to say otherwise is refused, not honoured.
    con = sqlite3.connect(tmp_path / "roster.db")
    con.execute("UPDATE invites SET role = ? WHERE code_hash = ?",
                (ROLE_OPERATOR, hash_code(code)))
    con.commit()
    con.close()
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None


async def test_an_empty_account_on_a_bound_invite_is_nobody(roster, tmp_path):
    """
    AV1's shape: an account invitation whose `user_id` is empty must match no
    one — not whoever turns up. Only `kind = 'link'` makes a row a bearer code.
    """
    code = await roster.create_invite(GROUP_B, "", ROLE_MEMBER, "cbesson")
    for who in ("alice", ""):
        assert await roster.consume_invite(code, who, group_id=GROUP_B) is None


async def test_bound_invitations_and_links_do_not_cancel_each_other(roster):
    code, invite_id = await _link(roster)
    # Re-inviting an account supersedes that account's earlier code — and must
    # not take the group's unredeemed links with it.
    await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson")
    await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson")
    # Cancelling "nobody's" invitations is not cancelling the links.
    assert await roster.drop_invites(GROUP_B, "") == 0
    assert invite_id in {i["invite_id"] for i in await roster.list_invites()}
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B)


async def test_links_are_capped_per_group(roster):
    codes = [(await _link(roster, GROUP_B))[0] for _ in range(MAX_LINK_INVITES_PER_GROUP)]
    with pytest.raises(LinkInviteLimit):
        await _link(roster, GROUP_B)
    # Another group has its own allowance.
    await _link(roster, GROUP_A)
    # A redeemed link is no longer outstanding, so it frees a place.
    assert await roster.consume_invite(codes[0], "alice", group_id=GROUP_B)
    await _link(roster, GROUP_B)


async def test_a_link_needs_a_group(roster):
    with pytest.raises(ValueError):
        await roster.create_link_invite("", "cbesson")


async def test_cancel_takes_back_an_unredeemed_link_of_its_own_group(roster):
    code, invite_id = await _link(roster, GROUP_B)
    assert not await roster.cancel_invite(GROUP_A, invite_id)
    assert await roster.cancel_invite(GROUP_B, invite_id)
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None

    code, invite_id = await _link(roster, GROUP_B)
    await roster.consume_invite(code, "alice", group_id=GROUP_B)
    assert not await roster.cancel_invite(GROUP_B, invite_id), (
        "a redeemed link is the record of the join, not something to cancel")


async def test_an_expired_link_is_refused(roster):
    code, _, _ = await roster.create_link_invite(GROUP_B, "cbesson", ttl=-1)
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None


async def test_a_roster_from_before_links_opens_and_keeps_its_codes(tmp_path):
    """The two columns arrive by ALTER TABLE; an existing code stays a bound one."""
    path = tmp_path / "old.db"
    con = sqlite3.connect(path)
    con.execute("""CREATE TABLE invites (
        code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL,
        username TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, created_by TEXT NOT NULL,
        created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT)""")
    expires = (datetime.now(UTC) + timedelta(days=1)).isoformat(timespec="seconds")
    con.execute("INSERT INTO invites VALUES (?, ?, ?, '', ?, 'op', ?, ?, NULL)",
                (hash_code("K7P2-9WQX"), GROUP_B, "alice", ROLE_MEMBER,
                 datetime.now(UTC).isoformat(), expires))
    con.commit()
    con.close()

    r = Roster(db_path=path)
    await r.open()
    try:
        [row] = await r.list_invites()
        assert row["kind"] == KIND_ACCOUNT
        assert await r.consume_invite("K7P2-9WQX", "mallory", group_id=GROUP_B) is None
        assert await r.consume_invite("K7P2-9WQX", "alice", group_id=GROUP_B)
    finally:
        await r.close()


# ── The join ─────────────────────────────────────────────────────────────────

async def test_a_newcomer_joins_with_a_link_code(tmp_path, roster):
    gek = generate_gek()
    code, _ = await _link(roster)
    sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
    session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, gek=gek)

    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
                  user_id="alice", group_id=GROUP_B))

    reply = _last(session)
    assert reply["ok"] is True and reply["gek"] is True
    assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek
    member = await roster.get_member(GROUP_B, "alice")
    assert member and member["role"] == ROLE_MEMBER


async def test_a_link_code_opens_no_other_group(tmp_path, roster):
    code, _ = await _link(roster, GROUP_A)
    sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
    session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B,
                       gek=generate_gek())
    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
                  user_id="alice", group_id=GROUP_B))
    assert _last(session).get("reason") == "code_invalid"
    assert await roster.get_member(GROUP_A, "alice") is None


async def test_someone_already_pinned_elsewhere_joins_with_a_link(tmp_path, roster):
    """The common case: known to this node through another group."""
    gek_b = generate_gek()
    sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
    await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
    await roster.set_member(GROUP_A, "grenet", ROLE_MEMBER, "active", "cbesson")
    code, _ = await _link(roster, GROUP_B)

    session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek_b)
    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
                  user_id="grenet", group_id=GROUP_B))

    reply = _last(session)
    assert reply["ok"] is True and reply["gek"] is True
    assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek_b
    assert await roster.get_member(GROUP_B, "grenet")


async def test_a_member_opening_the_group_with_a_link_leaves_it_unspent(tmp_path, roster):
    sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
    await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
    await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "active", "cbesson")
    code, _ = await _link(roster, GROUP_B)

    session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B,
                       gek=generate_gek())
    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
                  user_id="grenet", group_id=GROUP_B))
    assert _last(session)["ok"] is True
    assert await roster.consume_invite(code, "alice", group_id=GROUP_B)


async def test_a_known_device_with_a_wrong_code_is_told_so(tmp_path, roster):
    """Not the flat `not_authorized_for_group`: a code was offered and refused,
    and the refusal counts against the attempt budget like any other."""
    sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
    await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code")
    await roster.set_member(GROUP_A, "eve", ROLE_MEMBER, "active", "cbesson")
    session = _session(tmp_path, roster, user_id="eve", group_id=GROUP_B,
                       gek=generate_gek())
    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA",
                  user_id="eve", group_id=GROUP_B))
    assert _last(session).get("reason") == "code_invalid"
    assert session._join_attempts == 1


async def test_someone_removed_can_come_back_with_a_link(tmp_path, roster):
    """A revoked member still has a member row here. A link sent to bring them
    back must work — it is what the operator asked for."""
    gek = generate_gek()
    sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
    await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
    await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "revoked", "cbesson")
    session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek)

    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id="grenet", group_id=GROUP_B))
    assert _last(session).get("reason") == "not_authorized_for_group"

    code, _ = await _link(roster, GROUP_B)
    await session._do_join_request(
        _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
                  user_id="grenet", group_id=GROUP_B))
    reply = _last(session)
    assert reply["ok"] is True and reply["gek"] is True
    assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek
    assert (await roster.get_member(GROUP_B, "grenet"))["status"] == "active"