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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
|
"""
Account deletion, by the owner and by an administrator.
Deletion is the one action here that cannot be undone from the UI, so the tests
state what survives it as carefully as what does not. Two things survive on
purpose: the IP log, which exists for a year to answer legal requests and would
be useless if it could no longer say whose connection it recorded, and everything
on a node — files and pinned identities live on machines the hub does not
command.
"""
import hashlib
import pytest
from sqlalchemy import select
from meshbay_hub.db.models import GroupMember, Notification, RefreshToken, User
def _auth_key(password: str, username: str) -> str:
import base64
salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
return base64.b64encode(
hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
async def _register(client, username, password="a-long-enough-passphrase"):
r = await client.post("/v1/users/register", json={
"username": username, "email": f"{username}@example.com",
"auth_key": _auth_key(password, username),
})
assert r.status_code in (200, 201), r.text
login = await client.post("/v1/users/login", json={
"username": username, "auth_key": _auth_key(password, username)})
return login.json()["access_token"], password
@pytest.mark.asyncio
async def test_owner_can_delete_their_account(client, db_session):
token, password = await _register(client, "leaver_test")
headers = {"Authorization": f"Bearer {token}"}
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "leaver_test")})
assert r.status_code == 200, r.text
user = (await db_session.execute(
select(User).where(User.status == "deleted"))).scalar_one()
assert user.username.startswith("deleted-")
assert user.email == ""
assert user.pw_hash == b""
assert user.pk_node_ed25519 is None
@pytest.mark.asyncio
async def test_deleting_needs_the_passphrase_not_just_a_session(client):
"""
A live token may be a borrowed laptop or a tab left open. Something
irreversible asks again.
"""
token, _ = await _register(client, "careful_test")
r = await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key("wrong one", "careful_test")})
assert r.status_code == 403
me = await client.get("/v1/users/me",
headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 200, "the account must survive a failed attempt"
@pytest.mark.asyncio
async def test_the_username_is_released(client):
token, password = await _register(client, "recycled")
await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key(password, "recycled")})
again = await client.post("/v1/users/register", json={
"username": "recycled", "email": "new@example.com",
"auth_key": _auth_key("another passphrase entirely", "recycled"),
})
assert again.status_code in (200, 201), "the name should be free again"
@pytest.mark.asyncio
async def test_owning_a_group_blocks_deletion(client):
"""
Deleting an account that owns groups would strand their members, so it is
refused with the list rather than cascading into other people's data.
"""
token, password = await _register(client, "owner_test")
headers = {"Authorization": f"Bearer {token}"}
r = await client.post("/v1/groups", json={"name": "orphans"}, headers=headers)
assert r.status_code in (200, 201), r.text
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "owner_test")})
assert r.status_code == 409
assert "orphans" in r.json()["detail"]
@pytest.mark.asyncio
async def test_deletion_clears_memberships_notifications_and_tokens(
client, db_session):
token, password = await _register(client, "member1_test")
owner_token, _ = await _register(client, "grouper_test")
g = await client.post("/v1/groups", json={"name": "shared"},
headers={"Authorization": f"Bearer {owner_token}"})
gid = g.json()["group_id"]
await client.post(f"/v1/groups/{gid}/members/member1_test", json={},
headers={"Authorization": f"Bearer {owner_token}"})
uid = (await db_session.execute(
select(User.id).where(User.username == "member1_test"))).scalar_one()
await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key(password, "member1_test")})
for model in (GroupMember, Notification, RefreshToken):
rows = (await db_session.execute(
select(model).where(model.user_id == uid))).scalars().all()
assert rows == [], f"{model.__name__} survived the deletion"
def _device_pk() -> str:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import pk_to_b64
return pk_to_b64(Ed25519PrivateKey.generate().public_key())
@pytest.mark.asyncio
async def test_deletion_clears_device_keys_and_swarm_sources(client, db_session):
"""
The privacy statement says every account row goes but the IP log. Swarm
sources are keyed by the *user* id despite the column's name, and carry the
node's transport and port — `webrtc:<port>`, which is what `daemon.py`
actually sends. This asked with `192.0.2.7:4433`, from the days when the
field was free text documented as "ip:port": a shape no node has ever
produced, and one that let a caller name a third party's address.
"""
from meshbay_hub.db.models import SwarmSource, UserDevice
token, password = await _register(client, "devicer_test")
headers = {"Authorization": f"Bearer {token}"}
uid = (await db_session.execute(
select(User.id).where(User.username == "devicer_test"))).scalar_one()
r = await client.post("/v1/users/devices", headers=headers,
json={"pk_auth_ed25519": _device_pk(), "label": "desktop"})
assert r.status_code == 201, r.text
r = await client.post("/v1/swarm/register", headers=headers,
json={"content_hash": "ab" * 32, "endpoint": "webrtc:4433"})
assert r.status_code == 201, r.text
# Present before, or the emptiness asserted below proves nothing.
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all()
assert (await db_session.execute(
select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all()
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "devicer_test")})
assert r.status_code == 200, r.text
db_session.expire_all()
assert (await db_session.execute(
select(UserDevice).where(UserDevice.user_id == uid))).scalars().all() == []
assert (await db_session.execute(
select(SwarmSource).where(SwarmSource.node_id == uid))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_new_account_can_reuse_the_deleted_accounts_device(client):
"""
The desktop client keeps its device key after the account is deleted. Left
on the tombstone, that key was "another account's", and the next account
created from the same installation was refused its device with a 409 that
only reached the console.
"""
pk = _device_pk()
token, password = await _register(client, "firstlife")
r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk},
headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 201, r.text
await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key(password, "firstlife")})
token2, _ = await _register(client, "secondlife")
r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk},
headers={"Authorization": f"Bearer {token2}"})
assert r.status_code == 201, r.text
@pytest.mark.asyncio
async def test_the_ip_log_survives_and_stays_attributable(client, db_session):
"""
Kept on purpose. These rows exist for a year to answer legal requests, and
detaching them would keep the data while losing the only thing it is for.
"""
from meshbay_hub.db.models import IPLog
token, password = await _register(client, "logged_test")
uid = (await db_session.execute(
select(User.id).where(User.username == "logged_test"))).scalar_one()
before = (await db_session.execute(
select(IPLog).where(IPLog.user_id == uid))).scalars().all()
assert before, "registration should have been logged"
await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key(password, "logged_test")})
after = (await db_session.execute(
select(IPLog).where(IPLog.user_id == uid))).scalars().all()
assert len(after) >= len(before), "the compliance log must survive deletion"
@pytest.mark.asyncio
async def test_a_deleted_account_cannot_keep_using_its_token(client):
"""
Refresh tokens are removed, but an access token lives up to an hour. The
status check refuses it straight away — a deleted account must not keep
reading groups until its token happens to expire.
"""
token, password = await _register(client, "gone_test")
headers = {"Authorization": f"Bearer {token}"}
r = await client.request("DELETE", "/v1/users/me", headers=headers,
json={"auth_key": _auth_key(password, "gone_test")})
assert r.status_code == 200
after = await client.get("/v1/groups/mine", headers=headers)
assert after.status_code in (401, 403), "the session outlived the account"
@pytest.mark.asyncio
async def test_only_an_admin_may_delete_someone_else(client, db_session):
token, _ = await _register(client, "ordinary")
victim_token, _ = await _register(client, "victim_test")
victim_id = (await db_session.execute(
select(User.id).where(User.username == "victim_test"))).scalar_one()
r = await client.delete(f"/v1/admin/users/{victim_id}",
headers={"Authorization": f"Bearer {token}"})
assert r.status_code in (401, 403), "a plain user must not delete accounts"
me = await client.get("/v1/users/me",
headers={"Authorization": f"Bearer {victim_token}"})
assert me.status_code == 200
@pytest.mark.asyncio
async def test_the_log_still_says_who_it_was(client, db_session):
"""
The point of keeping the log is being able to answer who did what. Taking
the name from a join meant the answer became "deleted-3f9a1c" the moment
anyone deleted their account — for exactly the records that get asked about.
"""
from meshbay_hub.db.models import IPLog
token, password = await _register(client, "traceable")
uid = (await db_session.execute(
select(User.id).where(User.username == "traceable"))).scalar_one()
await client.request("DELETE", "/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
json={"auth_key": _auth_key(password, "traceable")})
rows = (await db_session.execute(
select(IPLog).where(IPLog.user_id == uid))).scalars().all()
assert rows, "registration should have been logged"
assert all(r.username == "traceable" for r in rows), \
"the log lost the name it exists to record"
admin_token, _ = await _register(client, "logreader")
from meshbay_hub.db.models import User as U
admin = (await db_session.execute(
select(U).where(U.username == "logreader"))).scalar_one()
admin.role = "admin"
await db_session.commit()
r = await client.get(f"/v1/admin/logs?user_id={uid}",
headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == 200, r.text
names = {e["username"] for e in r.json()["logs"]}
assert names == {"traceable"}, f"admin view shows {names}"
|