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_browser_idle_signout.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_browser_idle_signout.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_browser_idle_signout.py | 130 |
1 files changed, 130 insertions, 0 deletions
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") |