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 | |
| 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')
| -rw-r--r-- | packages/meshbay-hub/tests/conftest.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_browser_idle_signout.py | 130 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_session_lifetime.py | 183 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_session_renewal.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_unauthenticated_surface.py | 1 |
5 files changed, 326 insertions, 7 deletions
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index 69c3c5b..bf94464 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -33,7 +33,7 @@ def hub_config(hub_key_path, tmp_path): db=DatabaseConfig(url="sqlite+aiosqlite:///:memory:"), server=ServerConfig(host="127.0.0.1", port=8000), identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path), - jwt=JWTConfig(access_token_ttl=3600, refresh_token_ttl=86400), + jwt=JWTConfig(access_token_ttl=3600), ) return cfg diff --git a/packages/meshbay-hub/tests/test_browser_idle_signout.py b/packages/meshbay-hub/tests/test_browser_idle_signout.py new file mode 100644 index 0000000..50f7f04 --- /dev/null +++ b/packages/meshbay-hub/tests/test_browser_idle_signout.py @@ -0,0 +1,130 @@ +""" +A browser signs itself out after a stretch with nobody at it (design §7.7). + +`idle.js` is executed, not read: a fake document and localStorage stand in for +the browser, and a clock the test moves stands in for time. What is modelled is +the environment — the module under test is the real file. The wiring in app.js +is checked by reading it, the only evidence there is for the shell. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +IDLE = STATIC / "idle.js" +APP = STATIC / "app.js" +NODE = shutil.which("node") or ("/opt/nodejs/bin/node" + if Path("/opt/nodejs/bin/node").exists() else None) + +HARNESS = r""" +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), +}; +const media = []; +globalThis.document = { + querySelectorAll: () => media, addEventListener() {}, removeEventListener() {}, +}; +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +let now = 1_700_000_000_000; +Date.now = () => now; +const ticks = []; +globalThis.setInterval = (fn) => { ticks.push(fn); return ticks.length; }; +globalThis.clearInterval = () => {}; +const HOUR = 3600e3; +const { startIdleWatch, markActive, LAST_ACTIVE_KEY } = await import(process.argv[1]); +const out = {}; + +// Signed in just now, then left alone. +markActive(true); +let fired = 0; +const stop1 = startIdleWatch(HOUR, () => fired++); +now += HOUR - 1000; ticks.at(-1)(); out.justBefore = fired; +now += 2000; ticks.at(-1)(); out.justAfter = fired; +ticks.at(-1)(); out.firesOnce = fired; +stop1(); + +// A film nobody touches for two hours, then paused and left. +store.set(LAST_ACTIVE_KEY, String(now)); +let filmFired = 0; +startIdleWatch(HOUR, () => filmFired++); +media.push({ paused: false, ended: false }); +for (let i = 0; i < 120; i++) { now += 60e3; ticks.at(-1)(); } +out.duringFilm = filmFired; +media[0].paused = true; +now += HOUR + 60e3; ticks.at(-1)(); +out.afterPause = filmFired; +media.length = 0; + +// A browser closed without signing out and opened again the next day. +store.set(LAST_ACTIVE_KEY, String(now - 20 * HOUR)); +let reopened = 0; +startIdleWatch(HOUR, () => reopened++); +out.reopened = reopened; + +// A browser that has never recorded anything is not idle. +store.delete(LAST_ACTIVE_KEY); +let fresh = 0; +startIdleWatch(HOUR, () => fresh++); +out.noRecord = fresh; + +console.log(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def outcome(): + if NODE is None: + pytest.skip("node is not available") + proc = subprocess.run( + [NODE, "--input-type=module", "--eval", HARNESS, IDLE.as_uri()], + capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_nobody_at_it_for_the_delay_signs_out_once(outcome): + assert outcome["justBefore"] == 0 + assert outcome["justAfter"] == 1 + assert outcome["firesOnce"] == 1 + + +def test_a_film_playing_is_somebody_watching(outcome): + assert outcome["duringFilm"] == 0, "two hours of film signed the browser out" + assert outcome["afterPause"] == 1, "a paused film kept the session forever" + + +def test_a_browser_closed_without_signing_out_is_caught_when_reopened(outcome): + assert outcome["reopened"] == 1 + + +def test_a_browser_with_no_record_is_not_idle(outcome): + assert outcome["noRecord"] == 0 + + +def test_the_desktop_application_is_not_watched(): + src = APP.read_text(encoding="utf-8") + m = re.search(r"useEffect\(\(\) => \{\n(.*?)startIdleWatch\(", src, re.S) + assert m, "app.js no longer starts the idle watch in an effect" + assert "platform.isNative" in m.group(1) + + +def test_a_sign_in_resets_the_clock_before_the_session_lands(): + src = APP.read_text(encoding="utf-8") + login = src[src.index("login: async (username, password)"):src.index("logout: () =>")] + assert login.index("markActive(true)") < login.index("setAuth(u)"), ( + "the idle watch would read the previous user's last-active time") + + +def test_signing_out_revokes_on_the_hub_before_forgetting_the_token(): + src = APP.read_text(encoding="utf-8") + logout = src[src.index("logout: () =>"):] + logout = logout[:logout.index("},")] + assert logout.index("logoutOnHub()") < logout.index("setAuth(null)"), ( + "the refresh token is cleared before it can be sent for revocation") 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 diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py index a839f1e..5d4bcfd 100644 --- a/packages/meshbay-hub/tests/test_session_renewal.py +++ b/packages/meshbay-hub/tests/test_session_renewal.py @@ -168,13 +168,18 @@ def test_the_access_token_outlives_a_film_on_its_own(): def test_the_session_is_much_longer_than_the_token(): - """The two must not be confused: the session is the refresh token.""" + """The two must not be confused: the session is the refresh token. + + Its lifetime is an admin setting (`hub_settings.SESSION_*`); what is held + here is what an instance starts with. + """ + from meshbay_hub import hub_settings from meshbay_hub.config import JWTConfig - cfg = JWTConfig() - assert cfg.refresh_token_ttl >= 7 * 86400 - assert cfg.refresh_token_ttl > cfg.access_token_ttl * 20, ( - "the refresh token is barely longer than the access token, so renewing " - "buys almost nothing and signing in again comes round just as fast") + access = JWTConfig().access_token_ttl + assert hub_settings.SESSION_DEFAULTS["refresh_idle_hours"] * 3600 > access, ( + "the refresh token lapses before the access token it is meant to renew") + assert hub_settings.SESSION_DEFAULTS["max_hours"] >= 7 * 24, ( + "a session capped under a week makes signing in again a weekly chore") # ── The margin ──────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index 2c9541a..f2da42b 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -35,6 +35,7 @@ PUBLIC = { ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout", ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp", ("POST", "/v1/users/token/refresh"): "the refresh token is the credential", + ("POST", "/v1/users/logout"): "the refresh token is the credential; it can only revoke", ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped", ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits", ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped", |