diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_link_invites.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_link_invites.py | 270 |
1 files changed, 270 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_link_invites.py b/packages/meshbay-node/tests/test_link_invites.py new file mode 100644 index 0000000..f5081d2 --- /dev/null +++ b/packages/meshbay-node/tests/test_link_invites.py @@ -0,0 +1,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/invite-links.md §3). 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" |