feat: expire offline work with sessions (#399)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-09 12:03:55 +00:00
1 parent 33427d2407
commit 2e2a7821ae
11 files changed
+243 -32

No files matched your search

+22 -6
View File
@@ -254,12 +254,16 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
payload = response.json()
assert payload["authenticated"] is True
assert isinstance(payload["expires_at"], int)
assert payload["expires_at"] > int(time.time())
assert response.headers["cache-control"] == "no-store"
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@@ -267,14 +271,26 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
)
response = await client.get("/api/v1/session")
payload = response.json()
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/session")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert "expires_at" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
+69 -5
View File
@@ -14,11 +14,13 @@ 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, 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: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
getItem(key) {{ return this.values.get(key) || null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
@@ -27,6 +29,7 @@ const boundary = createSessionBoundary({{
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
if (state.failFetch) throw new Error('offline');
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
@@ -53,6 +56,8 @@ const boundary = createSessionBoundary({{
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
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'; }},
}});
@@ -227,6 +232,46 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
]
assert result["leaseDelay"] > 0
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '1');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
@@ -260,7 +305,7 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
result = run_session_scenario(
"""
state.responseStatus = 401;
@@ -269,6 +314,7 @@ await Promise.all([
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
@@ -276,9 +322,12 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
assert result["replacedAfterDeletion"] is True
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
@@ -314,6 +363,21 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v61" in worker
assert "stackchain-dashboard-shell-v62" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v61" in worker
assert "stackchain-dashboard-shell-v62" in worker
+1 -1
View File
@@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/plan-today.js'" in source
+46 -9
View File
@@ -14,10 +14,23 @@ const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
return state.cachedBody === null ? null : new Response(state.cachedBody);
}},
put: async (request, response) => {{
const key = String(request.url || request);
state.puts.push(key);
storedResponses.set(key, response.clone());
}},
}};
const context = {{
URL, Request, Response, Headers, AbortController,
@@ -108,7 +121,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -117,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -125,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -141,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -408,6 +421,30 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-session-lease', expiresAt: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({
status: response.status,
body: await response.text(),
state,
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v61" in source
assert "stackchain-dashboard-shell-v62" in source
assert "BASE + 'static/today-sync.js'" in source