Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
All checks were successful
CI / lint (push) Successful in 1m12s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
timmy 2026-08-11 00:02:39 +00:00
commit 7cadb36b41
8 changed files with 255 additions and 34 deletions

View File

@ -122,20 +122,25 @@ async function notifyIdleSession() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' })); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
} }
async function storeOfflineLease(expiresAt) { async function storeOfflineLease(expiresAt, idleExpiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return; if (
!Number.isInteger(expiresAt) || expiresAt <= 0
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
) return;
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response( await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }), JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
)); ));
} }
async function validOfflineLease(cache) { async function offlineLeaseState(cache) {
const response = await cache.match(OFFLINE_LEASE_URL); const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {}; const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at) const current = Math.floor(Date.now() / 1000);
&& payload.expires_at > Math.floor(Date.now() / 1000); if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
return 'valid';
} }
async function expiredOfflineResponse() { async function expiredOfflineResponse() {
@ -152,8 +157,18 @@ async function expiredOfflineResponse() {
); );
} }
async function idleOfflineResponse() {
await notifyIdleSession();
return new Response(
'Your Stackchain session is locked. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) { async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse(); const state = await offlineLeaseState(cache);
if (state === 'expired') return expiredOfflineResponse();
if (state === 'idle') return idleOfflineResponse();
return cache.match(BASE); return cache.match(BASE);
} }
@ -239,7 +254,7 @@ self.addEventListener('message', event => {
await flushAndNotify(); await flushAndNotify();
})()); })());
if (event.data?.type === 'stackchain-session-lease') { if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt)); event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
} }
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try { try {

View File

@ -228,6 +228,7 @@
}) { }) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false; let expirationStarted = false;
let idleLockStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY; let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null; let activityHeartbeat = null;
@ -247,8 +248,8 @@
} }
function handleSessionIdle() { function handleSessionIdle() {
if (expirationStarted) return; if (expirationStarted || idleLockStarted) return;
expirationStarted = true; idleLockStarted = true;
location.replace(base + 'login?reason=session-idle'); location.replace(base + 'login?reason=session-idle');
} }
@ -269,16 +270,32 @@
return Number.isInteger(value) && value > 0 ? value : 0; return Number.isInteger(value) && value > 0 ? value : 0;
} }
function storedOfflineIdleLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) { function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now()); const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay); setTimer(() => handleSessionExpiry(), delay);
} }
async function publishOfflineLease(expiresAt) { function scheduleOfflineIdle(idleExpiresAt) {
const delay = Math.max(0, idleExpiresAt * 1000 - now());
setTimer(() => {
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
}, delay);
}
async function publishOfflineLease(expiresAt, idleExpiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt)); localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
scheduleOfflineExpiry(expiresAt); scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
const registration = await serviceWorker?.ready; const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt }); registration?.active?.postMessage({
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
});
} }
function csrfToken() { function csrfToken() {
@ -449,7 +466,10 @@
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
} }
} }
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { if (
response.status === 401 && isSameOrigin(input)
&& !expirationStarted && !idleLockStarted
) {
expirationStarted = true; expirationStarted = true;
const payload = await response.clone().json().catch(() => ({})); const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') { if (payload.code === 'session_revoked') {
@ -470,12 +490,21 @@
const current = now(); const current = now();
if ( if (
expirationStarted expirationStarted
|| idleLockStarted
|| activityHeartbeat || activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs || current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false); ) return Promise.resolve(false);
lastActivityHeartbeatAt = current; lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' }) activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(response => response.ok) .then(async response => {
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
const expiresAt = storedOfflineLease();
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
await publishOfflineLease(expiresAt, payload.idle_expires_at);
}
return true;
})
.catch(() => false) .catch(() => false)
.finally(() => { activityHeartbeat = null; }); .finally(() => { activityHeartbeat = null; });
return activityHeartbeat; return activityHeartbeat;
@ -492,17 +521,27 @@
const response = await sessionFetch(base + 'api/v1/session'); const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false; if (!response.ok) return false;
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false; if (
await publishOfflineLease(payload.expires_at); !payload.authenticated
|| !Number.isInteger(payload.expires_at)
|| !Number.isInteger(payload.idle_expires_at)
) return false;
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
return true; return true;
} catch (_error) { } catch (_error) {
const expiresAt = storedOfflineLease(); const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) { if (expiresAt * 1000 <= now()) {
scheduleOfflineExpiry(expiresAt); if (expiresAt) await handleSessionExpiry();
return true; return false;
} }
if (expiresAt) await handleSessionExpiry(); const idleExpiresAt = storedOfflineIdleLease();
return false; if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
handleSessionIdle();
return false;
}
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
return true;
} }
} }

View File

@ -30,6 +30,7 @@ class Session:
session_id: str session_id: str
csrf: str csrf: str
expires_at: int expires_at: int
idle_expires_at: int | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -174,7 +175,14 @@ def verify_session_with_reason(
return SessionVerification(None, "session_idle") return SessionVerification(None, "session_idle")
if status != "active": if status != "active":
return SessionVerification(None, "session_revoked") return SessionVerification(None, "session_revoked")
return SessionVerification(session) return SessionVerification(
Session(
session_id=session.session_id,
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
)
)
def verify_session(value: str | None, now: int | None = None) -> Session | None: def verify_session(value: str | None, now: int | None = None) -> Session | None:

View File

@ -1512,6 +1512,7 @@ async def session_status(request: Request):
} }
if session is not None: if session is not None:
payload["expires_at"] = session.expires_at payload["expires_at"] = session.expires_at
payload["idle_expires_at"] = session.idle_expires_at
return payload return payload
@ -1534,7 +1535,10 @@ async def record_session_activity(request: Request):
) )
return { return {
"active": True, "active": True,
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(), "idle_expires_at": min(
session.expires_at,
int(time.time()) + dashboard_auth.idle_timeout_seconds(),
),
} }

View File

@ -12,6 +12,17 @@ class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely.""" """Raised when session state cannot be read or changed safely."""
class SessionStatus(str):
"""String-compatible status carrying the server-confirmed idle deadline."""
idle_expires_at: int | None
def __new__(cls, value: str, idle_expires_at: int | None = None):
instance = super().__new__(cls, value)
instance.idle_expires_at = idle_expires_at
return instance
@dataclass(frozen=True) @dataclass(frozen=True)
class ActiveDevice: class ActiveDevice:
management_id: str management_id: str
@ -180,10 +191,11 @@ class SessionStore:
except (OSError, sqlite3.Error) as exc: except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now: if row is None or row[0] != expires_at or expires_at <= now:
return "revoked" return SessionStatus("revoked")
if row[1] + max(1, idle_timeout_seconds) <= now: idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
return "idle" if idle_expires_at <= now:
return "active" return SessionStatus("idle", idle_expires_at)
return SessionStatus("active", idle_expires_at)
def touch( def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int self, session_id: str, expires_at: int, *, idle_timeout_seconds: int

View File

@ -791,9 +791,39 @@ async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease
assert payload["authenticated"] is True assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"] assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time()) assert payload["expires_at"] > int(time.time())
assert payload["idle_expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_session_status_reports_server_idle_deadline_without_extending_it(
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"}
)
expected_last_active = int(time.time()) - 120
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
connection.execute(
"UPDATE active_sessions SET last_active_at = ?", (expected_last_active,)
)
first = await client.get("/api/v1/session")
second = await client.get("/api/v1/session")
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
stored_last_active = connection.execute(
"SELECT last_active_at FROM active_sessions"
).fetchone()[0]
assert first.status_code == 200
assert first.json()["idle_expires_at"] == expected_last_active + 900
assert second.json()["idle_expires_at"] == expected_last_active + 900
assert stored_last_active == expected_last_active
@pytest.mark.anyio @pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control): async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
@ -834,6 +864,7 @@ async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(acce
@pytest.mark.anyio @pytest.mark.anyio
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch): async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900") monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", "100")
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post( await client.post(
@ -846,10 +877,11 @@ async def test_activity_heartbeat_extends_the_server_idle_deadline(access_contro
"X-CSRF-Token": client.cookies["stackchain_csrf"], "X-CSRF-Token": client.cookies["stackchain_csrf"],
}, },
) )
status = await client.get("/api/v1/session")
assert heartbeat.status_code == 200 assert heartbeat.status_code == 200
assert heartbeat.json()["active"] is True assert heartbeat.json()["active"] is True
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899 assert heartbeat.json()["idle_expires_at"] == status.json()["expires_at"]
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict: def run_session_scenario(scenario: str) -> dict:
harness = f""" harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); 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: [], activityListeners: {{}}, now: 100000, 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: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{ const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }}, get length() {{ return this.values.size; }},
@ -59,7 +59,7 @@ const boundary = createSessionBoundary({{
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }}, onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }}, addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now, now: () => state.now,
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }}, setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }}, confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }}, promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}}); }});
@ -260,22 +260,100 @@ process.stdout.write(JSON.stringify(state));
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer(): def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario( result = run_session_scenario(
""" """
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800}; state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
const valid = await boundary.refreshOfflineLease(); const valid = await boundary.refreshOfflineLease();
state.valid = valid; state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at'); state.savedExpiry = storage.getItem('stackchain.session-expires-at');
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state)); process.stdout.write(JSON.stringify(state));
""" """
) )
assert result["valid"] is True assert result["valid"] is True
assert result["savedExpiry"] == "4102444800" assert result["savedExpiry"] == "4102444800"
assert result["savedIdleExpiry"] == "4102441200"
assert result["workerMessages"] == [ assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800} {
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441200,
}
] ]
assert result["leaseDelay"] > 0 assert result["leaseDelay"] > 0
def test_expired_idle_lease_locks_offline_without_purging_private_work():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '200');
storage.setItem('stackchain.session-idle-expires-at', '99');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == [
"stackchain.private",
"gitea.preference",
"stackchain.session-expires-at",
"stackchain.session-idle-expires-at",
]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '4102444800');
state.responsePayload = {active:true, idle_expires_at:4102441300};
const recorded = await boundary.recordActivity();
state.recorded = recorded;
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["recorded"] is True
assert result["savedIdleExpiry"] == "4102441300"
assert result["workerMessages"] == [
{
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441300,
}
]
def test_absolute_expiry_still_purges_after_an_idle_lock():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
await boundary.refreshOfflineLease();
state.now = 150000;
await state.timers[1].callback();
state.deletedAtIdle = [...state.deletedDatabases];
state.now = 200000;
await state.timers[0].callback();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
"/dashboard/login?reason=session-idle",
"/dashboard/login?reason=session-expired",
]
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline(): def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario( result = run_session_scenario(
""" """

View File

@ -17,7 +17,10 @@ const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches
const storedResponses = new Map(); const storedResponses = new Map();
storedResponses.set( storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease', 'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})), new Response(JSON.stringify({{
expires_at: Math.floor(Date.now() / 1000) + 3600,
idle_expires_at: Math.floor(Date.now() / 1000) + 900,
}})),
); );
const cache = {{ const cache = {{
addAll: async urls => {{ state.added = urls; }}, addAll: async urls => {{ state.added = urls; }},
@ -503,7 +506,7 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell(): def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario( result = run_worker_scenario(
""" """
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1}); await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1});
state.failFetch = true; state.failFetch = true;
state.cachedBody = 'private cached dashboard'; state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', { const response = await dispatch('fetch', {
@ -524,6 +527,36 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
assert "private cached dashboard" not in result["body"] assert "private cached dashboard" not in result["body"]
def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work():
result = run_worker_scenario(
"""
state.clientList = [{postMessage: message => { state.clientMessage = message; }}];
await dispatchMessage({
type:'stackchain-session-lease',
expiresAt:Math.floor(Date.now() / 1000) + 3600,
idleExpiresAt:1,
});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({
state, status:response.status, body:await response.text(),
cacheControl:response.headers.get('Cache-Control'),
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in."
assert result["cacheControl"] == "no-store"
assert result["state"]["outboxPurges"] == 0
assert result["state"]["deleted"] == []
assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"}
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline(): def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario( result = run_worker_scenario(
""" """