aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
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
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')
-rw-r--r--packages/meshbay-hub/tests/conftest.py2
-rw-r--r--packages/meshbay-hub/tests/test_browser_idle_signout.py130
-rw-r--r--packages/meshbay-hub/tests/test_session_lifetime.py183
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py17
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py1
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",