Compare commits
No commits in common. "7cadb36b4154cc164ee03523bcbbf78f00d793ce" and "100d38cc5ccc88fbb9748dcd5db1c3a7c45a48a6" have entirely different histories.
7cadb36b41
...
100d38cc5c
|
|
@ -122,25 +122,20 @@ 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, idleExpiresAt) {
|
async function storeOfflineLease(expiresAt) {
|
||||||
if (
|
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||||
!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, idle_expires_at: idleExpiresAt }),
|
JSON.stringify({ expires_at: expiresAt }),
|
||||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function offlineLeaseState(cache) {
|
async function validOfflineLease(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(() => ({})) || {};
|
||||||
const current = Math.floor(Date.now() / 1000);
|
return Number.isInteger(payload.expires_at)
|
||||||
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
|
&& payload.expires_at > Math.floor(Date.now() / 1000);
|
||||||
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
|
|
||||||
return 'valid';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expiredOfflineResponse() {
|
async function expiredOfflineResponse() {
|
||||||
|
|
@ -157,18 +152,8 @@ 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) {
|
||||||
const state = await offlineLeaseState(cache);
|
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
|
||||||
if (state === 'expired') return expiredOfflineResponse();
|
|
||||||
if (state === 'idle') return idleOfflineResponse();
|
|
||||||
return cache.match(BASE);
|
return cache.match(BASE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,7 +239,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.data.idleExpiresAt));
|
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||||
}
|
}
|
||||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,6 @@
|
||||||
}) {
|
}) {
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -248,8 +247,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSessionIdle() {
|
function handleSessionIdle() {
|
||||||
if (expirationStarted || idleLockStarted) return;
|
if (expirationStarted) return;
|
||||||
idleLockStarted = true;
|
expirationStarted = true;
|
||||||
location.replace(base + 'login?reason=session-idle');
|
location.replace(base + 'login?reason=session-idle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,32 +269,16 @@
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleOfflineIdle(idleExpiresAt) {
|
async function publishOfflineLease(expiresAt) {
|
||||||
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({
|
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
|
||||||
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function csrfToken() {
|
function csrfToken() {
|
||||||
|
|
@ -466,10 +449,7 @@
|
||||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||||
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') {
|
||||||
|
|
@ -490,21 +470,12 @@
|
||||||
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(async response => {
|
.then(response => response.ok)
|
||||||
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;
|
||||||
|
|
@ -521,28 +492,18 @@
|
||||||
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 (
|
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
|
||||||
!payload.authenticated
|
await publishOfflineLease(payload.expires_at);
|
||||||
|| !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);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (expiresAt) await handleSessionExpiry();
|
if (expiresAt) await handleSessionExpiry();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const idleExpiresAt = storedOfflineIdleLease();
|
|
||||||
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
|
|
||||||
handleSessionIdle();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
scheduleOfflineExpiry(expiresAt);
|
|
||||||
scheduleOfflineIdle(idleExpiresAt);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDashboardStorage(storage) {
|
function removeDashboardStorage(storage) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ 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)
|
||||||
|
|
@ -175,14 +174,7 @@ 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(
|
return SessionVerification(session)
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -1512,7 +1512,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1535,10 +1534,7 @@ async def record_session_activity(request: Request):
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"active": True,
|
"active": True,
|
||||||
"idle_expires_at": min(
|
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
||||||
session.expires_at,
|
|
||||||
int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,6 @@ 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
|
||||||
|
|
@ -191,11 +180,10 @@ 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 SessionStatus("revoked")
|
return "revoked"
|
||||||
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||||
if idle_expires_at <= now:
|
return "idle"
|
||||||
return SessionStatus("idle", idle_expires_at)
|
return "active"
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -791,39 +791,9 @@ 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)
|
||||||
|
|
@ -864,7 +834,6 @@ 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(
|
||||||
|
|
@ -877,11 +846,10 @@ 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"] == status.json()["expires_at"]
|
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -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: {{}}, timers: [], 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: {{}}, 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; state.timers.push({{callback, delay}}); return state.timers.length; }},
|
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||||
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,100 +260,22 @@ 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, idle_expires_at:4102441200};
|
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||||
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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,7 @@ 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({{
|
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
|
||||||
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; }},
|
||||||
|
|
@ -506,7 +503,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, idleExpiresAt:1});
|
await dispatchMessage({type:'stackchain-session-lease', expiresAt: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', {
|
||||||
|
|
@ -527,36 +524,6 @@ 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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user