feat: expire offline work with sessions (#399)
This commit is contained in:
1 parent
33427d2407
commit
2e2a7821ae
11 files changed
+243
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user