diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/conftest.py | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_account_deletion.py | 182 |
2 files changed, 196 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index ffd6c2e..4593f86 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -70,3 +70,17 @@ async def client(app): base_url="http://test", ) as c: yield c + + +@pytest_asyncio.fixture +async def db_session(app): + """ + A session on the same in-memory database the app is using. + + For assertions that cannot be made through the API — what a deletion left + behind, for instance, which is exactly the sort of thing worth checking + directly rather than inferring. + """ + from meshbay_hub.db.engine import get_session_factory + async with get_session_factory()() as session: + yield session diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py new file mode 100644 index 0000000..0ae70f4 --- /dev/null +++ b/packages/meshbay-hub/tests/test_account_deletion.py @@ -0,0 +1,182 @@ +""" +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") + headers = {"Authorization": f"Bearer {token}"} + + r = await client.request("DELETE", "/v1/users/me", headers=headers, + json={"auth_key": _auth_key(password, "leaver")}) + 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") + r = await client.request("DELETE", "/v1/users/me", + headers={"Authorization": f"Bearer {token}"}, + json={"auth_key": _auth_key("wrong one", "careful")}) + 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") + 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")}) + 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") + owner_token, _ = await _register(client, "grouper") + 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", json={}, + headers={"Authorization": f"Bearer {owner_token}"}) + + uid = (await db_session.execute( + select(User.id).where(User.username == "member1"))).scalar_one() + + await client.request("DELETE", "/v1/users/me", + headers={"Authorization": f"Bearer {token}"}, + json={"auth_key": _auth_key(password, "member1")}) + + 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" + + +@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") + uid = (await db_session.execute( + select(User.id).where(User.username == "logged"))).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")}) + + 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") + headers = {"Authorization": f"Bearer {token}"} + r = await client.request("DELETE", "/v1/users/me", headers=headers, + json={"auth_key": _auth_key(password, "gone")}) + 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") + victim_id = (await db_session.execute( + select(User.id).where(User.username == "victim"))).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 |