summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 22:41:02 +0200
commit8d8f85b4bf976249266a89f692408027216711b7 (patch)
tree79cdc211a7791e5f25f96879606329eee03ddb11 /packages/meshbay-hub/tests
parent38f91818f876c51dcd7eb7911b65fc7bf5154c83 (diff)
downloadmeshbay-8d8f85b4bf976249266a89f692408027216711b7.tar.gz
feat(account): a user can delete their own account, an admin can delete one
Both go through the same erasure, so there is one description of what happens rather than two that drift. Gone: credentials, email, node key, group memberships, notifications, refresh tokens, node registrations. The username is released. Kept, on purpose and stated in the UI: the row itself, emptied, and the IP log that points at it. Those logs exist for a year to answer legal requests, and a log that can no longer say whose connection it recorded keeps the data while losing the only thing it is for. So the account becomes a tombstone rather than a hole in the table. Out of reach, also stated: files uploaded to nodes, and the identity keys nodes pinned. Those are on machines the hub does not command, and only their operators can remove them — `member unpin` and a delete on their own disk. Saying so in the confirmation matters more than the button. Owning groups blocks deletion, with the list. Cascading would delete other people's groups out from under them; the account holder can hand them over or delete them first, deliberately. Self-deletion re-checks the passphrase. A live token may be a borrowed laptop or a tab left open, and it is not consent to something irreversible. Admin deletion requires admin rather than moderator: suspension is the reversible moderation tool and stays one click away. A deleted account's access token stops working at once — the status check already refuses anything but "active", which the tests now pin down, because refresh tokens being gone would otherwise leave up to an hour of usable session. Tests: 8 covering what survives and what does not, plus a db_session fixture for assertions that cannot honestly be made through the API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/conftest.py14
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py182
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