aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_session_lifetime.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 02:16:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 02:21:01 +0200
commit73ad8e4eb566fe682107fa7e50ef624591199e99 (patch)
treeff0017d014d46d8835487c080dca55c6def7fd6b /packages/meshbay-hub/tests/test_session_lifetime.py
parentbdefcd025604f2c3009fe5e0cc01213c2ba62a6a (diff)
downloadmeshbay-73ad8e4eb566fe682107fa7e50ef624591199e99.tar.gz
feat(hub): session lifetime is an admin setting, and a browser signs out when idle
Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-hub/tests/test_session_lifetime.py')
-rw-r--r--packages/meshbay-hub/tests/test_session_lifetime.py183
1 files changed, 183 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_session_lifetime.py b/packages/meshbay-hub/tests/test_session_lifetime.py
new file mode 100644
index 0000000..af73767
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_session_lifetime.py
@@ -0,0 +1,183 @@
+"""
+How long a session lasts, and how it ends (design §7.7).
+
+Three admin settings in hours — browser idle sign-out, refresh idle window,
+maximum — plus a sign-out the hub honours and "sign out everywhere". The
+browser half is `test_browser_idle_signout.py`; this is the hub's.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select, update
+
+from meshbay_hub.api.deps import set_admin_usernames
+from meshbay_hub.db.models import RefreshToken, User
+
+AUTH_KEY = "s" * 44
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com", "auth_key": AUTH_KEY})
+ assert r.status_code == 201, r.text
+
+
+async def _login(client, username):
+ r = await client.post("/v1/users/login", json={"username": username, "auth_key": AUTH_KEY})
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+async def _admin(client):
+ await _register(client, "session_admin")
+ set_admin_usernames(["session_admin"])
+ return {"Authorization": f"Bearer {(await _login(client, 'session_admin'))['access_token']}"}
+
+
+async def _refresh(client, token):
+ return await client.post("/v1/users/token/refresh", json={"refresh_token": token})
+
+
+def _aware(dt):
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
+
+
+async def _expiry(db_session, username):
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ rows = (await db_session.execute(
+ select(RefreshToken.expires_at).where(RefreshToken.user_id == uid))).scalars().all()
+ return _aware(max(rows))
+
+
+@pytest.mark.asyncio
+async def test_the_defaults_are_in_the_panel_and_the_browser_delay_is_public(client):
+ admin = await _admin(client)
+ r = await client.get("/v1/admin/settings", headers=admin)
+ assert r.json()["session"] == {
+ "browser_idle_hours": 1, "refresh_idle_hours": 24, "max_hours": 720}
+
+ info = (await client.get("/v1/hub/info")).json()
+ assert info["browser_idle_hours"] == 1
+
+
+@pytest.mark.asyncio
+async def test_a_refresh_token_lasts_the_idle_window(client, db_session):
+ await _register(client, "idle_window")
+ await _login(client, "idle_window")
+ left = await _expiry(db_session, "idle_window") - datetime.now(timezone.utc)
+ assert timedelta(hours=23, minutes=58) < left <= timedelta(hours=24)
+
+
+@pytest.mark.asyncio
+async def test_the_idle_window_never_undercuts_the_access_token(client, db_session):
+ """One hour idle with a one-hour access token would lapse between renewals."""
+ admin = await _admin(client)
+ await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"refresh_idle_hours": 1}})
+ await _register(client, "short_idle")
+ await _login(client, "short_idle")
+ left = await _expiry(db_session, "short_idle") - datetime.now(timezone.utc)
+ # The test hub's access token lives 3600 s; the floor is that plus an hour.
+ assert left > timedelta(hours=1, minutes=58)
+
+
+@pytest.mark.asyncio
+async def test_no_session_renews_past_its_maximum(client, db_session):
+ await _register(client, "long_session")
+ first = await _login(client, "long_session")
+
+ # Renewal while young works, and slides the window.
+ r = await _refresh(client, first["refresh_token"])
+ assert r.status_code == 200, r.text
+ current = r.json()["refresh_token"]
+
+ # The family's sign-in is now older than the 720 h maximum.
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "long_session"))).scalar_one()
+ await db_session.execute(
+ update(RefreshToken).where(RefreshToken.user_id == uid)
+ .values(created_at=datetime.now(timezone.utc) - timedelta(hours=721)))
+ await db_session.commit()
+
+ r = await _refresh(client, current)
+ assert r.status_code == 401
+ assert r.json()["detail"] == "Session expired"
+
+
+@pytest.mark.asyncio
+async def test_signing_out_ends_the_session_on_the_hub(client):
+ await _register(client, "signs_out")
+ tokens = await _login(client, "signs_out")
+
+ r = await client.post("/v1/users/logout", json={"refresh_token": tokens["refresh_token"]})
+ assert r.status_code == 200
+ assert (await _refresh(client, tokens["refresh_token"])).status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_token_gets_the_same_answer(client):
+ r = await client.post("/v1/users/logout", json={"refresh_token": "not-a-token"})
+ assert r.status_code == 200
+ assert r.json() == {"status": "signed_out"}
+
+
+@pytest.mark.asyncio
+async def test_signing_out_ends_only_that_session(client):
+ await _register(client, "two_browsers")
+ here = await _login(client, "two_browsers")
+ there = await _login(client, "two_browsers")
+
+ await client.post("/v1/users/logout", json={"refresh_token": here["refresh_token"]})
+ assert (await _refresh(client, there["refresh_token"])).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_sign_out_everywhere_ends_every_session(client):
+ await _register(client, "everywhere")
+ here = await _login(client, "everywhere")
+ there = await _login(client, "everywhere")
+
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": f"Bearer {here['access_token']}"})
+ assert r.status_code == 200
+ assert r.json()["revoked"] == 2
+ assert (await _refresh(client, here["refresh_token"])).status_code == 401
+ assert (await _refresh(client, there["refresh_token"])).status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_sign_out_everywhere_needs_the_account(client):
+ # No header at all is refused before the route runs, as a missing field.
+ r = await client.post("/v1/users/me/sessions/revoke")
+ assert r.status_code in (401, 403, 422)
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": "Bearer not-a-real-token"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_values_are_clamped_and_unknown_keys_refused(client):
+ admin = await _admin(client)
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"browser_idle_hours": 0, "max_hours": 10**9}})
+ assert r.status_code == 200
+ bounds = r.json()["session_bounds"]
+ assert r.json()["session"]["browser_idle_hours"] == bounds["browser_idle_hours"][0]
+ assert r.json()["session"]["max_hours"] == bounds["max_hours"][1]
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"idle_minutes": 5}})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_only_an_admin_changes_them(client):
+ await _admin(client)
+ await _register(client, "not_an_admin")
+ token = (await _login(client, "not_an_admin"))["access_token"]
+ r = await client.patch("/v1/admin/settings",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"session": {"browser_idle_hours": 168}})
+ assert r.status_code == 403