diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:16:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:21:01 +0200 |
| commit | 73ad8e4eb566fe682107fa7e50ef624591199e99 (patch) | |
| tree | ff0017d014d46d8835487c080dca55c6def7fd6b /packages/meshbay-hub/tests/test_session_lifetime.py | |
| parent | bdefcd025604f2c3009fe5e0cc01213c2ba62a6a (diff) | |
| download | meshbay-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.py | 183 |
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 |