diff --git a/README.md b/README.md index f987359..1d9dc1f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,9 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='' export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' # Optional; defaults to eight hours. export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 +# Optional; explicit pointer, keyboard, or touch activity renews this idle window. +# Background polling and queued delivery do not. Defaults to 15 minutes. +export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900 # Optional sign-in throttle: five failures per five minutes, up to 10,000 sources. export STACKCHAIN_LOGIN_MAX_FAILURES=5 export STACKCHAIN_LOGIN_WINDOW_SECONDS=300 @@ -166,8 +169,9 @@ storage shared by all dashboard workers. Operators name a device at sign-in and open **Active devices** to review creation/expiry times, identify the current device, and revoke one remote session without interrupting other trusted devices. The API exposes only independent management IDs and bounded labels—never cookie values, -session hashes, CSRF proofs, or source addresses. Existing two-column registries are -migrated in place and their live sessions remain valid. +session hashes, CSRF proofs, or source addresses. The registry also stores each +session's last explicit activity. Existing two-column registries are migrated in +place, their live sessions remain valid, and their idle clock starts at migration. High-impact actions—merging a pull request, closing an assigned issue, revoking a remote device, or signing out every device—require the operator access token again. @@ -177,7 +181,12 @@ first use. Expired, replayed, cross-session, and target-substituted grants fail Gitea or session state is changed. The browser preserves the pending request and retries it once after the built-in mobile/keyboard-accessible authorization prompt. -If an active session expires, the first authenticated API rejection replaces the +After 15 minutes without pointer, keyboard, or touch activity (configurable through +`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if +polling or background delivery continued. The dashboard locks and background outbox +delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on +the device; signing in resumes the existing account-bound work. If an active session +expires, the first authenticated API rejection replaces the dashboard with sign-in and explains that private drafts remain on the device; signing in again resumes account-bound queued delivery. Expiry recovery does not clear offline state. A selectively or globally revoked device instead receives a bounded revocation diff --git a/frontend/login.js b/frontend/login.js index 5c430a5..1aa61ef 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -35,6 +35,10 @@ status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; return; } + if (reason === 'session-idle') { + status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.'; + return; + } if (reason !== 'session-revoked') return; button.disabled = true; status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…'; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8f548b2..039eaab 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -115,6 +115,11 @@ async function purgeRevokedSessionData() { clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' })); } +async function notifyIdleSession() { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' })); +} + async function storeOfflineLease(expiresAt) { if (!Number.isInteger(expiresAt) || expiresAt <= 0) return; const cache = await caches.open(CACHE); @@ -177,6 +182,9 @@ async function fetchJson(url, options = {}) { if (response.status === 401 && code === 'session_revoked') { await purgeRevokedSessionData(); } + if (response.status === 401 && code === 'session_idle') { + await notifyIdleSession(); + } const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); error.status = response.status; error.code = code; diff --git a/frontend/session.js b/frontend/session.js index 167b4fd..a75ef09 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -15,6 +15,7 @@ serviceWorker: root.navigator?.serviceWorker, MessageChannel: root.MessageChannel, location: root.location, + addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options), confirmAction: message => root.confirm(message), promptAuthorization: details => root.prompt( `Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`, @@ -27,6 +28,7 @@ }); root.fetch = boundary.fetch; const attach = () => { + boundary.startActivityHeartbeat(); const button = root.document.getElementById('sign-out'); if (button) button.addEventListener('click', () => boundary.signOut()); const allDevicesButton = root.document.getElementById('sign-out-all'); @@ -91,16 +93,19 @@ root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener, promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, requestTimeoutMs = 15000, + activityHeartbeatIntervalMs = 60000, now = () => Date.now(), setTimer = (callback, delay) => setTimeout(callback, delay), }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); let expirationStarted = false; + let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY; + let activityHeartbeat = null; async function handleRemoteRevocation() { if (expirationStarted) return; @@ -117,10 +122,19 @@ location.replace(base + 'login?reason=session-expired'); } + function handleSessionIdle() { + if (expirationStarted) return; + expirationStarted = true; + location.replace(base + 'login?reason=session-idle'); + } + async function handleServiceWorkerMessage(event) { if (event?.data?.type === 'stackchain-session-revoked') { await handleRemoteRevocation(); } + if (event?.data?.type === 'stackchain-session-idle') { + handleSessionIdle(); + } if (event?.data?.type === 'stackchain-session-expired') { await handleSessionExpiry(); } @@ -253,6 +267,9 @@ if (payload.code === 'session_revoked') { expirationStarted = false; await handleRemoteRevocation(); + } else if (payload.code === 'session_idle') { + expirationStarted = false; + handleSessionIdle(); } else { expirationStarted = false; await handleSessionExpiry(); @@ -261,6 +278,27 @@ return response; } + function recordActivity() { + const current = now(); + if ( + expirationStarted + || activityHeartbeat + || current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs + ) return Promise.resolve(false); + lastActivityHeartbeatAt = current; + activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' }) + .then(response => response.ok) + .catch(() => false) + .finally(() => { activityHeartbeat = null; }); + return activityHeartbeat; + } + + function startActivityHeartbeat() { + ['pointerdown', 'keydown', 'touchstart'].forEach(type => { + addActivityListener?.(type, recordActivity, { passive: true }); + }); + } + async function refreshOfflineLease() { try { const response = await sessionFetch(base + 'api/v1/session'); @@ -391,5 +429,7 @@ handleServiceWorkerMessage, refreshOfflineLease, resumeQueuedWork, + recordActivity, + startActivityHeartbeat, }; }); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 834c2a7..acb7a98 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError SESSION_COOKIE = "stackchain_session" CSRF_COOKIE = "stackchain_csrf" DEFAULT_TTL_SECONDS = 8 * 60 * 60 +DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60 STEP_UP_TTL_SECONDS = 90 MIN_SECRET_LENGTH = 24 OPERATOR_MODE = "operator" @@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore: return SessionStore(database, clock=current) +def idle_timeout_seconds() -> int: + return max( + 1, + int( + os.getenv( + "STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", + str(DEFAULT_IDLE_TIMEOUT_SECONDS), + ) + ), + ) + + def issue_session( now: int | None = None, *, device_label: str = "This device" ) -> tuple[str, Session]: @@ -146,7 +159,14 @@ def verify_session_with_reason( or not session.csrf ): return SessionVerification(None) - if not _session_store(now).is_active(session.session_id, session.expires_at): + status = _session_store(now).status( + session.session_id, + session.expires_at, + idle_timeout_seconds=idle_timeout_seconds(), + ) + if status == "idle": + return SessionVerification(None, "session_idle") + if status != "active": return SessionVerification(None, "session_revoked") return SessionVerification(session) @@ -167,6 +187,15 @@ async def active_devices(session: Session): return await asyncio.to_thread(_session_store().list_active, session.session_id) +async def touch_session(session: Session) -> bool: + return await asyncio.to_thread( + _session_store().touch, + session.session_id, + session.expires_at, + idle_timeout_seconds=idle_timeout_seconds(), + ) + + async def revoke_managed_session(management_id: str) -> bool: return await asyncio.to_thread(_session_store().revoke_managed, management_id) diff --git a/src/main.py b/src/main.py index 8f03a82..e0bad49 100644 --- a/src/main.py +++ b/src/main.py @@ -670,18 +670,19 @@ async def require_operator_session(request: Request, call_next): if not public and session is None: if path.startswith("/api/"): payload = {"detail": "Authentication required"} - if session_reason == "session_revoked": + if session_reason in {"session_revoked", "session_idle"}: payload["code"] = session_reason return JSONResponse( payload, status_code=401, headers={"Cache-Control": "no-store"}, ) - login_redirect = ( - "login?reason=session-revoked" - if session_reason == "session_revoked" - else _share_target_login_redirect(request) - ) + if session_reason == "session_revoked": + login_redirect = "login?reason=session-revoked" + elif session_reason == "session_idle": + login_redirect = "login?reason=session-idle" + else: + login_redirect = _share_target_login_redirect(request) return RedirectResponse( login_redirect, status_code=303, @@ -937,6 +938,29 @@ async def session_status(request: Request): return payload +@app.post("/api/v1/session/activity") +async def record_session_activity(request: Request): + session = request.state.dashboard_session + try: + active = await dashboard_auth.touch_session(session) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if not active: + return JSONResponse( + {"detail": "Authentication required"}, + status_code=401, + headers={"Cache-Control": "no-store"}, + ) + return { + "active": True, + "idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(), + } + + def _today_store() -> TodayStore: return TodayStore( os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5 diff --git a/src/session_store.py b/src/session_store.py index 3b11f62..200d77e 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -58,7 +58,8 @@ class SessionStore: expires_at INTEGER NOT NULL, management_id TEXT, device_label TEXT, - created_at INTEGER + created_at INTEGER, + last_active_at INTEGER ) """ ) @@ -84,6 +85,7 @@ class SessionStore: "management_id": "TEXT", "device_label": "TEXT", "created_at": "INTEGER", + "last_active_at": "INTEGER", } for name, column_type in additions.items(): if name not in columns: @@ -102,6 +104,10 @@ class SessionStore: "UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL", (int(self.clock()),), ) + connection.execute( + "UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL", + (int(self.clock()),), + ) connection.execute( "CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id " "ON active_sessions(management_id)" @@ -122,14 +128,15 @@ class SessionStore: ) connection.execute( "INSERT INTO active_sessions(" - "session_hash, expires_at, management_id, device_label, created_at" - ") VALUES (?, ?, ?, ?, ?)", + "session_hash, expires_at, management_id, device_label, created_at, last_active_at" + ") VALUES (?, ?, ?, ?, ?, ?)", ( self._digest(session_id), expires_at, secrets.token_urlsafe(18), label, now, + now, ), ) except (OSError, sqlite3.Error) as exc: @@ -147,6 +154,55 @@ class SessionStore: raise SessionStoreError("Session registry is temporarily unavailable") from exc return row is not None and row[0] == expires_at and expires_at > now + def status( + self, session_id: str, expires_at: int, *, idle_timeout_seconds: int + ) -> str: + now = int(self.clock()) + query = ( + "SELECT expires_at, last_active_at FROM active_sessions " + "WHERE session_hash = ?" + ) + parameters = (self._digest(session_id),) + try: + try: + with self._connect() as connection: + row = connection.execute(query, parameters).fetchone() + except sqlite3.OperationalError as exc: + if "no such column: last_active_at" not in str(exc): + raise + with self._connect(initialize=True) as connection: + row = connection.execute(query, parameters).fetchone() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + if row is None or row[0] != expires_at or expires_at <= now: + return "revoked" + if row[1] + max(1, idle_timeout_seconds) <= now: + return "idle" + return "active" + + def touch( + self, session_id: str, expires_at: int, *, idle_timeout_seconds: int + ) -> bool: + now = int(self.clock()) + try: + with self._connect() as connection: + cursor = connection.execute( + "UPDATE active_sessions SET last_active_at = ? " + "WHERE session_hash = ? AND expires_at = ? AND expires_at > ? " + "AND last_active_at + ? > ?", + ( + now, + self._digest(session_id), + expires_at, + now, + max(1, idle_timeout_seconds), + now, + ), + ) + return cursor.rowcount == 1 + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + def revoke(self, session_id: str) -> None: try: with self._connect(initialize=True) as connection: diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 26d8629..c2bed7e 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -1,4 +1,5 @@ import asyncio +import sqlite3 import time from urllib.parse import parse_qs, urlsplit @@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m lookups = 0 class CountingStore: - def is_active(self, session_id, expires_at): + def status(self, session_id, expires_at, *, idle_timeout_seconds): nonlocal lookups lookups += 1 - return original_store.is_active(session_id, expires_at) + return original_store.status( + session_id, + expires_at, + idle_timeout_seconds=idle_timeout_seconds, + ) monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore()) response = await client.get("/api/v1/session") @@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro assert response.headers["cache-control"] == "no-store" +@pytest.mark.anyio +async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900") + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + with sqlite3.connect(main.dashboard_auth._session_store().path) as connection: + connection.execute( + "UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,) + ) + + api_response = await client.get("/api/v1/session") + page_response = await client.get("/") + + assert api_response.status_code == 401 + assert api_response.json() == { + "detail": "Authentication required", + "code": "session_idle", + } + assert page_response.status_code == 303 + assert page_response.headers["location"] == "login?reason=session-idle" + + +@pytest.mark.anyio +async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900") + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + heartbeat = await client.post( + "/api/v1/session/activity", + headers={ + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + }, + ) + + assert heartbeat.status_code == 200 + assert heartbeat.json()["active"] is True + assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899 + + @pytest.mark.anyio async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target( access_control, monkeypatch @@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont grant = await fresh_grant(client, "revoke_all_sessions", "all") class BrokenStore: - def is_active(self, session_id, expires_at): - return True + def status(self, session_id, expires_at, *, idle_timeout_seconds): + return "active" def consume_step_up(self, grant, session_id, *, action, target): return True @@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con ) class SlowStore: - def is_active(self, session_id, expires_at): + def status(self, session_id, expires_at, *, idle_timeout_seconds): time.sleep(0.15) - return True + return "active" monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore()) private_request = asyncio.create_task(client.get("/api/v1/session")) @@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop( csrf = client.cookies["stackchain_csrf"] class SlowStore: - def is_active(self, session_id, expires_at): + def status(self, session_id, expires_at, *, idle_timeout_seconds): time.sleep(0.02) - return True + return "active" def revoke(self, session_id): time.sleep(0.15) @@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea( ) class BrokenStore: - def is_active(self, session_id, expires_at): + def status(self, session_id, expires_at, *, idle_timeout_seconds): raise SessionStoreError("database path and secret details") monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) @@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control, csrf = client.cookies["stackchain_csrf"] class BrokenStore: - def is_active(self, session_id, expires_at): - return True + def status(self, session_id, expires_at, *, idle_timeout_seconds): + return "active" def revoke(self, session_id): raise SessionStoreError("database path and secret details") @@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo ) class BrokenStore: - def is_active(self, session_id, expires_at): + def status(self, session_id, expires_at, *, idle_timeout_seconds): raise SessionStoreError("public routes must not read the registry") monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index e1771b1..5d319d0 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js" def run_session_scenario(scenario: str) -> dict: harness = f""" const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); -const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{ }}, onClearError: error => state.clearErrors.push(error.message), onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }}, + addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }}, + now: () => state.now, setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }}, confirmAction: message => {{ state.confirmations.push(message); return true; }}, promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }}, @@ -232,6 +234,29 @@ process.stdout.write(JSON.stringify(state)); assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof" +def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events(): + result = run_session_scenario( + """ +boundary.startActivityHeartbeat(); +await state.activityListeners.pointerdown(); +await state.activityListeners.keydown(); +state.now += 60000; +await state.activityListeners.touchstart(); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert [request["url"] for request in result["requests"]] == [ + "/dashboard/api/v1/session/activity", + "/dashboard/api/v1/session/activity", + ] + assert all(request["method"] == "POST" for request in result["requests"]) + assert all( + request["headers"]["x-csrf-token"] == "csrf-proof" + for request in result["requests"] + ) + + def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer(): result = run_session_scenario( """ @@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state)); assert result["replacedAfterDeletion"] is True +def test_idle_response_locks_without_clearing_private_queued_work(): + result = run_session_scenario( + """ +state.responseStatus = 401; +state.responsePayload = {detail:'Authentication required', code:'session_idle'}; +await boundary.fetch('/dashboard/api/v1/live'); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["remaining"] == ["stackchain.private", "gitea.preference"] + assert result["deletedDatabases"] == [] + assert result["deletedCaches"] == [] + assert result["workerMessages"] == [] + assert result["replaced"] == ["/dashboard/login?reason=session-idle"] + + def test_worker_revocation_message_clears_window_storage_before_login(): result = run_session_scenario( """ @@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state)); assert result["replaced"] == ["/dashboard/login?reason=session-revoked"] +def test_worker_idle_message_locks_without_clearing_window_storage(): + result = run_session_scenario( + """ +await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}}); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["remaining"] == ["stackchain.private", "gitea.preference"] + assert result["replaced"] == ["/dashboard/login?reason=session-idle"] + + def test_worker_expiry_message_clears_window_storage_before_expired_login(): result = run_session_scenario( """ diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index 8f8f757..89a2f91 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }})) assert state["ignored"] == state["expired"] +def test_idle_session_reason_explains_that_saved_work_will_resume(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const status = {{ textContent: '' }}; +const controller = createLoginController({{ + form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }}, + fetchImpl: async () => new Response('{{}}', {{ status: 200 }}), + location: {{ replace: () => {{}} }}, +}}); +controller.showReason('session-idle'); +process.stdout.write(JSON.stringify({{ status: status.textContent }})); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + + assert json.loads(result.stdout)["status"] == ( + "Stackchain locked after inactivity. Your drafts and queued work are still " + "on this device. Sign in to resume." + ) + + def test_revoked_session_reason_clears_private_data_before_enabling_sign_in(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index a8a576b..cc70139 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -237,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl ] +def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients(): + result = run_worker_scenario( + """ + state.clientMessages=[]; + state.clientList=[{postMessage:message=>state.clientMessages.push(message)}]; + context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}}); + const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'}) + .then(()=>({ok:true}),error=>({status:error.status,code:error.code})); + process.stdout.write(JSON.stringify({state,outcome})); +""" + ) + + assert result["outcome"] == {"status": 401, "code": "session_idle"} + assert result["state"]["outboxPurges"] == 0 + assert result["state"]["deleted"] == [] + assert result["state"]["clientMessages"] == [ + {"type": "stackchain-session-idle"} + ] + + def test_authenticated_page_message_resumes_queued_background_delivery(): result = run_worker_scenario( """ diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 9c7e603..1a7e148 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,) +def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("phone-session", 3_000) + + now[0] = 1_899.0 + assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active" + now[0] = 1_900.0 + assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle" + + +def test_touch_extends_only_the_matching_live_session(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("phone-session", 3_000) + store.activate("laptop-session", 3_000) + + now[0] = 1_800.0 + assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True + now[0] = 2_000.0 + + assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active" + assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle" + assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False + + +def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("phone-session", 3_000) + + now[0] = 1_900.0 + + assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False + assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle" + + def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path): store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0) store.activate("phone-session-secret", 2_000, device_label="Pixel 9") @@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa assert next(device for device in devices if device.current).device_label == "Existing device" +def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path): + database = tmp_path / "sessions.sqlite3" + digest = SessionStore._digest("existing-session") + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)" + ) + connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000)) + + store = SessionStore(database, clock=lambda: 1_000.0) + + assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active" + with sqlite3.connect(database) as connection: + last_active_at = connection.execute( + "SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,) + ).fetchone()[0] + assert last_active_at == 1_000 + + def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path): database = tmp_path / "sessions.sqlite3" digest = SessionStore._digest("existing-session")