aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 14:24:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 14:24:35 +0200
commit2dbff20818d171242a15e3567e14292c0d63aea2 (patch)
treedcfe9607809c95ff15435ec684f0954a2d777798 /packages/meshbay-hub/tests
parent5949532e6450d1aebc681caa8db2192a43b11f12 (diff)
downloadmeshbay-2dbff20818d171242a15e3567e14292c0d63aea2.tar.gz
fix(hub): a phone's poll secret ends with the account's sessions
The secret a phone polls /v1/push/poll with is a credential of its own, outside any session, and it outlived the ones that should have ended it: "sign out everywhere" — what someone who lost a phone presses — a passphrase change and an admin revocation left every push row in place, so the lost phone went on reading "X posted in <group>" lines. Each now deletes the account's rows, as a reset and an erasure already did, and the poll refuses a revoked or deleted account whatever deleted the row or not. A suspended one still reads: the line telling it so is one of them. A phone still in hand is not affected for long: a refused poll makes it forget the row, and the page registers it again at its next start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_push.py79
1 files changed, 79 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_push.py b/packages/meshbay-hub/tests/test_push.py
index c3bca4f..3450aa2 100644
--- a/packages/meshbay-hub/tests/test_push.py
+++ b/packages/meshbay-hub/tests/test_push.py
@@ -453,3 +453,82 @@ async def test_an_endpoint_without_its_keys_is_refused(client):
json={"endpoint": "https://push.example.net/up/k"},
headers={"Authorization": f"Bearer {mine}"})
assert r.status_code == 422
+
+
+# ── A phone's poll secret ends with the account's sessions ───────────────────
+#
+# The secret is a credential of its own, outside any session. A lost phone is
+# exactly what "sign out everywhere" and a passphrase change are pressed for;
+# a revoked account is not told anything more.
+
+
+async def _registered(client, username):
+ token = await _user(client, username)
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {token}"})).json()
+ await _subscribe(client, token)
+ assert (await _poll(client, reg)).status_code == 200
+ return token, reg
+
+
+async def _rows(db_session, username):
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ db_session.expire_all()
+ return (await db_session.execute(
+ select(PushSubscription).where(PushSubscription.user_id == uid))).scalars().all()
+
+
+@pytest.mark.asyncio
+async def test_signing_out_everywhere_ends_every_phone(client, db_session, unthrottled):
+ token, reg = await _registered(client, "lost_phone")
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "lost_phone") == [], "pushed rows go too"
+
+
+@pytest.mark.asyncio
+async def test_a_passphrase_change_ends_every_phone(client, db_session, unthrottled):
+ token, reg = await _registered(client, "new_passphrase")
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("a-long-enough-passphrase", "new_passphrase"),
+ "new_auth_key": _auth_key("another-long-passphrase", "new_passphrase"),
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "new_passphrase") == []
+
+
+@pytest.mark.asyncio
+async def test_a_revoked_account_is_told_nothing(client, db_session, unthrottled):
+ from meshbay_hub.api.deps import set_admin_usernames
+
+ _, reg = await _registered(client, "revoked_phone")
+ admin = await _user(client, "push_admin")
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "revoked_phone"))).scalar_one()
+ set_admin_usernames(["push_admin"])
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": uid, "reason": "test"},
+ headers={"Authorization": f"Bearer {admin}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "revoked_phone") == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("status", "code"), [("suspended", 200), ("revoked", 404),
+ ("deleted", 404)])
+async def test_polling_follows_the_account_status(client, db_session, unthrottled,
+ status, code):
+ # The gate itself, apart from the paths that delete the row: a status set
+ # some other way still decides. Suspended still reads — the line saying so
+ # is one of the notifications.
+ _, reg = await _registered(client, f"status_{status}")
+ user = (await db_session.execute(
+ select(User).where(User.username == f"status_{status}"))).scalar_one()
+ user.status = status
+ await db_session.commit()
+ assert (await _poll(client, reg)).status_code == code