fix: recover expired dashboard sessions (#309)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-08 14:32:37 +00:00
1 parent 6b99d4f171
commit 9786724678
8 files changed
+87 -10

No files matched your search

+39 -3
View File
@@ -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: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200 }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -27,7 +27,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 || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
return new Response('{{}}', {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
@@ -47,7 +47,10 @@ const boundary = createSessionBoundary({{
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
this.port1 = first; this.port2 = second;
}} }},
location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }},
location: {{
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
confirmAction: message => {{ state.confirmations.push(message); return true; }},
}});
@@ -68,6 +71,39 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
await Promise.all([
boundary.fetch('/dashboard/api/v1/live'),
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
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"] == []
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
state.responseStatus = 401;
await boundary.fetch('https://untrusted.example/api/private');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == []
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
+27
View File
@@ -11,6 +11,32 @@ ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_expired_session_reason_explains_preserved_private_work():
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-expired');
const expired = status.textContent;
controller.showReason('https://evil.example/redirect');
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["expired"] == (
"Your session expired. Private drafts remain on this device. "
"Sign in to continue."
)
assert state["ignored"] == state["expired"]
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -58,3 +84,4 @@ async def test_login_page_loads_rate_limit_controller():
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
assert '<p id="status" role="status" aria-live="polite"></p>' in html
+2 -2
View File
@@ -92,10 +92,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_strict_browser_assets_ship_in_a_new_shell_cache():
def test_session_expiry_recovery_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v30" in source
assert "stackchain-dashboard-shell-v31" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source