fix: recover expired dashboard sessions (#309)
This commit is contained in:
1 parent
6b99d4f171
commit
9786724678
8 files changed
+87
-10
No files matched your search
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user