aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 11:25:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 11:25:38 +0200
commite3c68b3416f8ea2c033d41ac263552833370ba18 (patch)
treeb0549de294ea5c66466152d6c9465826633323f9 /packages/meshbay-hub/tests
parente0607a9cee6ce9596e775baaed43eb67e1e5a293 (diff)
downloadmeshbay-e3c68b3416f8ea2c033d41ac263552833370ba18.tar.gz
fix(hub): account deletion left device keys and swarm sources behind
erase_account cleared memberships, notifications, tokens and node registrations, but not user_devices or swarm_sources. A device key left on the tombstone still belonged to it, so an account created later from the same desktop installation - which keeps its private half - was refused that device with a 409 that only reached the console. swarm_sources is keyed by the user id despite its column name and carries the node's ip:port. Both are now erased, which is what the privacy statement promises: every account row goes except the one-year IP log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py67
1 files changed, 67 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py
index cddb5d0..b97417d 100644
--- a/packages/meshbay-hub/tests/test_account_deletion.py
+++ b/packages/meshbay-hub/tests/test_account_deletion.py
@@ -124,6 +124,73 @@ async def test_deletion_clears_memberships_notifications_and_tokens(
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 ip:port.
+ """
+ from meshbay_hub.db.models import SwarmSource, UserDevice
+
+ token, password = await _register(client, "devicer")
+ headers = {"Authorization": f"Bearer {token}"}
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "devicer"))).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": "192.0.2.7: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")})
+ 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):
"""