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
|
"""
Two things one participant must not be able to decide for another.
**That you are in a group.** An owner could add any username and the account
became a member at once: the group was in its sidebar, named in its MNP tokens,
and its client dialled the group's nodes — nodes the owner chose. So an owner's
addition is an invitation until the invitee accepts it.
**That a node hosts a group.** A node could register for any group its account
belonged to, and clients keep the first registered node that completes the
handshake — which any member's node does, since every member holds the group
key. So a node hosts a group only if its account owns it or the owner approved
it; the rest of its claim is a request the owner sees.
Each test is two accounts or more, because a one-member test proves a
one-member property (CLAUDE.md, "ask who pays").
"""
import base64
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from membership import accept_invitation
from meshbay_common.crypto import pk_to_b64
from meshbay_hub.db.models import GroupHost, Notification
from sqlalchemy import select
KEY = base64.b64encode(b"k" * 32).decode()
async def _user(client, username):
sk = Ed25519PrivateKey.generate()
r = await client.post("/v1/users/register", json={
"username": username, "email": f"{username}@example.test", "auth_key": KEY})
assert r.status_code == 201, r.text
uid = r.json()["user_id"]
r = await client.post("/v1/users/login", json={"username": username, "auth_key": KEY})
return {"id": uid, "name": username, "sk": sk, "pk": pk_to_b64(sk.public_key()),
"H": {"Authorization": f"Bearer {r.json()['access_token']}"}}
async def _group(client, owner, name="family"):
"""A group that a node already hosts: `/mine` hides an unhosted group from
everyone but its owner, which would make "not in /mine" prove nothing."""
from meshbay_hub.api.revocation import _mark_hosted
r = await client.post("/v1/groups", headers=owner["H"],
json={"name": name, "visibility": "private", "join_policy": "invite"})
gid = r.json()["group_id"]
await _mark_hosted([gid])
return gid
async def _node(client, user):
ts = int(time.time())
msg = f"meshbay:node_announce:{user['id']}:{user['pk']}:{ts}".encode()
r = await client.post("/v1/nodes/announce", headers=user["H"], json={
"pk_node": user["pk"], "timestamp": ts,
"signature": base64.b64encode(user["sk"].sign(msg)).decode()})
assert r.status_code == 201, r.text
return r.json()["node_id"]
def _node_token(user):
from meshbay_hub.auth import issue_access_token
return issue_access_token(user["id"], scope="node")
async def _mine(client, user):
return [g["id"] for g in (await client.get("/v1/groups/mine",
headers=user["H"])).json()["groups"]]
# ── Invitations ──────────────────────────────────────────────────────────────
async def test_being_added_is_an_invitation_not_a_membership(client):
owner, invitee = await _user(client, "inv_owner"), await _user(client, "inv_guest")
gid = await _group(client, owner)
r = await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"])
assert r.json()["status"] == "invited"
assert gid not in await _mine(client, invitee)
r = await client.get(f"/v1/groups/{gid}/nodes", headers=invitee["H"])
assert r.status_code == 403, "an invitee must not be handed a node to dial"
listed = (await client.get("/v1/groups/invitations", headers=invitee["H"])).json()
assert [i["group_id"] for i in listed["invitations"]] == [gid]
async def test_accepting_makes_a_member_and_declining_leaves_nothing(client):
owner = await _user(client, "acc_owner")
yes, no = await _user(client, "acc_yes_user"), await _user(client, "acc_no_user")
gid = await _group(client, owner)
for u in (yes, no):
await client.post(f"/v1/groups/{gid}/members/{u['name']}", headers=owner["H"])
await accept_invitation(client, gid, yes["name"])
r = await client.post(f"/v1/groups/{gid}/invitation/decline", headers=no["H"])
assert r.status_code == 200
assert gid in await _mine(client, yes)
assert gid not in await _mine(client, no)
assert (await client.get("/v1/groups/invitations", headers=no["H"])).json()[
"invitations"] == []
r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=no["H"])
assert r.status_code == 404, "a declined invitation cannot be accepted afterwards"
async def test_nobody_else_can_accept_an_invitation(client):
owner, invitee = await _user(client, "only_owner"), await _user(client, "only_guest")
other = await _user(client, "only_other")
gid = await _group(client, owner)
await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"])
r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=other["H"])
assert r.status_code == 404
assert gid not in await _mine(client, other)
async def test_the_owner_sees_and_can_take_back_an_invitation(client):
owner, invitee = await _user(client, "back_owner"), await _user(client, "back_guest")
member = await _user(client, "back_member")
gid = await _group(client, owner)
await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"])
await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"])
await accept_invitation(client, gid, member["name"])
seen = (await client.get(f"/v1/groups/{gid}/members", headers=owner["H"])).json()
assert [u["username"] for u in seen["invited"]] == [invitee["name"]]
# Another member is not told who was asked.
assert "invited" not in (await client.get(f"/v1/groups/{gid}/members",
headers=member["H"])).json()
r = await client.delete(f"/v1/groups/{gid}/members/{invitee['name']}",
headers=owner["H"])
assert r.status_code == 200
assert (await client.get("/v1/groups/invitations", headers=invitee["H"])).json()[
"invitations"] == []
# ── Hosts ────────────────────────────────────────────────────────────────────
async def test_a_members_node_does_not_host_a_group_it_does_not_own(client, db_session):
from meshbay_hub.api.revocation import resolve_node_groups
owner, member = await _user(client, "host_owner"), await _user(client, "host_member")
gid = await _group(client, owner)
await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"])
await accept_invitation(client, gid, member["name"])
member_node = await _node(client, member)
owner_node = await _node(client, owner)
assert await resolve_node_groups(member_node, member["id"], [gid]) == []
assert await resolve_node_groups(owner_node, owner["id"], [gid]) == [gid]
row = await db_session.get(GroupHost, (gid, member_node))
assert row is not None and row.status == "pending"
notes = (await db_session.execute(select(Notification).where(
Notification.user_id == owner["id"], Notification.kind == "host_request"))).all()
assert len(notes) == 1
async def test_the_owner_approves_a_host_and_can_take_it_back(client, db_session):
from meshbay_hub.api import revocation
owner, member = await _user(client, "appr_owner"), await _user(client, "appr_member")
gid = await _group(client, owner)
await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"])
await accept_invitation(client, gid, member["name"])
node = await _node(client, member)
await revocation.resolve_node_groups(node, member["id"], [gid])
# A connected node, as the socket handler records it.
revocation._connected_nodes[node] = object()
revocation._node_claims[node] = [gid]
revocation._node_users[node] = member["id"]
try:
# Not the member's call to make.
r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=member["H"])
assert r.status_code == 403
hosts = (await client.get(f"/v1/groups/{gid}/hosts", headers=owner["H"])).json()
assert [(h["node_id"], h["status"]) for h in hosts["hosts"]] == [(node, "pending")]
r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"])
assert r.status_code == 200
assert revocation._node_groups[node] == [gid], "approval must apply at once"
r = await client.delete(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"])
assert r.status_code == 200
assert revocation._node_groups[node] == []
# Refused, and asking again does not notify the owner a second time.
await revocation.resolve_node_groups(node, member["id"], [gid])
notes = (await db_session.execute(select(Notification).where(
Notification.user_id == owner["id"], Notification.kind == "host_request"))).all()
assert len(notes) == 1
finally:
revocation.forget_node(node)
async def test_only_a_node_that_asked_can_be_approved(client):
owner, member = await _user(client, "ask_owner"), await _user(client, "ask_member")
gid = await _group(client, owner)
node = await _node(client, member)
r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"])
assert r.status_code == 404
|