""" 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 UTC, datetime, timedelta import pytest from meshbay_hub.api.deps import set_admin_usernames from meshbay_hub.db.models import RefreshToken, User from sqlalchemy import select, update 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=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(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(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(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