Purge private data after remote session revocation #338
12
README.md
12
README.md
|
|
@ -107,10 +107,14 @@ exposes only independent management IDs and bounded labels—never cookie values
|
|||
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
||||
migrated in place and their live sessions remain valid.
|
||||
|
||||
If an active session expires or is revoked, the first authenticated API rejection
|
||||
replaces the dashboard with sign-in and explains that private drafts remain on the
|
||||
device; signing in again resumes account-bound queued delivery. This recovery does
|
||||
not clear offline state. **Sign out & clear this device** revokes only the current
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
dashboard with sign-in and explains that private drafts remain on the device; signing
|
||||
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
||||
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||
reason: on its next server contact, Stackchain clears its owned local/session storage,
|
||||
private outbox database, and dashboard caches before enabling sign-in. Revocation
|
||||
blocks server access immediately, but no web application can erase a device that
|
||||
remains offline forever. **Sign out & clear this device** revokes only the current
|
||||
session before clearing browser state, so a copied cookie cannot be replayed
|
||||
afterward; other signed-in devices remain active. **Sign out all devices** is a
|
||||
separately confirmed lost-device safety action that atomically revokes every existing
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
const button = options.button;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
|
|
@ -29,9 +30,22 @@
|
|||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
function showReason(reason) {
|
||||
if (reason !== 'session-expired') return;
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
try {
|
||||
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
|
||||
await clearPrivateDeviceData();
|
||||
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
|
||||
button.disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
|
||||
}
|
||||
}
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
|
|
@ -92,6 +106,7 @@ if (typeof document !== 'undefined') {
|
|||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
|
||||
});
|
||||
controller.showReason(loginParams.get('reason'));
|
||||
form.addEventListener('submit', event => {
|
||||
|
|
|
|||
61
frontend/private-device-data.js
Normal file
61
frontend/private-device-data.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
@ -66,6 +66,16 @@ async function withSessionCsrf(work) {
|
|||
}
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
|
|
@ -89,9 +99,13 @@ async function fetchJson(url, options = {}) {
|
|||
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const code = payload.code || payload.detail?.code;
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = payload.detail?.code;
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@
|
|||
});
|
||||
boundary.resumeQueuedWork();
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
|
|
@ -92,6 +93,19 @@
|
|||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
.map(value => value.trim())
|
||||
|
|
@ -116,8 +130,14 @@
|
|||
const response = await fetchImpl(input, requestOptions);
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
expirationStarted = true;
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else {
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
|
@ -230,6 +250,7 @@
|
|||
listActiveDevices,
|
||||
revokeActiveDevice,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
resumeQueuedWork,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ class Session:
|
|||
expires_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionVerification:
|
||||
session: Session | None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def access_token() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
|
||||
|
|
@ -112,13 +118,15 @@ def issue_session(
|
|||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
def verify_session_with_reason(
|
||||
value: str | None, now: int | None = None
|
||||
) -> SessionVerification:
|
||||
if not value or "." not in value or not enabled():
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
encoded, supplied_signature = value.rsplit(".", 1)
|
||||
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(supplied_signature, expected):
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
try:
|
||||
payload = json.loads(_decode(encoded))
|
||||
session = Session(
|
||||
|
|
@ -127,7 +135,7 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
|||
expires_at=int(payload["exp"]),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
current = int(time.time() if now is None else now)
|
||||
if (
|
||||
session.expires_at <= current
|
||||
|
|
@ -136,8 +144,14 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
|||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return None
|
||||
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
|
||||
return SessionVerification(None)
|
||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
return verify_session_with_reason(value, now).session
|
||||
|
||||
|
||||
def revoke_session(session: Session) -> None:
|
||||
|
|
@ -160,6 +174,12 @@ async def request_session(request: Request) -> Session | None:
|
|||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
async def request_session_verification(request: Request) -> SessionVerification:
|
||||
return await asyncio.to_thread(
|
||||
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
|
||||
)
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
return root_path or "/"
|
||||
|
|
|
|||
17
src/main.py
17
src/main.py
|
|
@ -524,9 +524,12 @@ async def require_operator_session(request: Request, call_next):
|
|||
or (path == "/api/v1/session" and request.method == "POST")
|
||||
)
|
||||
session = None
|
||||
session_reason = None
|
||||
if not public:
|
||||
try:
|
||||
session = await dashboard_auth.request_session(request)
|
||||
verification = await dashboard_auth.request_session_verification(request)
|
||||
session = verification.session
|
||||
session_reason = verification.reason
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
|
|
@ -535,13 +538,21 @@ async def require_operator_session(request: Request, call_next):
|
|||
)
|
||||
if not public and session is None:
|
||||
if path.startswith("/api/"):
|
||||
payload = {"detail": "Authentication required"}
|
||||
if session_reason == "session_revoked":
|
||||
payload["code"] = session_reason
|
||||
return JSONResponse(
|
||||
{"detail": "Authentication required"},
|
||||
payload,
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
login_redirect = (
|
||||
"login?reason=session-revoked"
|
||||
if session_reason == "session_revoked"
|
||||
else _share_target_login_redirect(request)
|
||||
)
|
||||
return RedirectResponse(
|
||||
_share_target_login_redirect(request),
|
||||
login_redirect,
|
||||
status_code=303,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ LOGIN_HTML = """<!doctype html>
|
|||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/login.js"></script></body></html>"""
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
|||
},
|
||||
)
|
||||
phone_status = await phone.get("/api/v1/session")
|
||||
phone_page = await phone.get("/", follow_redirects=False)
|
||||
laptop_status = await laptop.get("/api/v1/session")
|
||||
|
||||
assert listed.status_code == 200
|
||||
|
|
@ -303,6 +304,12 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
|||
assert missing_csrf.status_code == 403
|
||||
assert revoked.json() == {"revoked": True, "current_session": False}
|
||||
assert phone_status.status_code == 401
|
||||
assert phone_status.json() == {
|
||||
"detail": "Authentication required",
|
||||
"code": "session_revoked",
|
||||
}
|
||||
assert phone_page.status_code == 303
|
||||
assert phone_page.headers["location"] == "login?reason=session-revoked"
|
||||
assert laptop_status.status_code == 200
|
||||
assert "session_hash" not in listed.text
|
||||
assert "csrf" not in listed.text
|
||||
|
|
|
|||
|
|
@ -92,6 +92,39 @@ process.stdout.write(JSON.stringify(state));
|
|||
assert result["deletedCaches"] == []
|
||||
|
||||
|
||||
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
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["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -37,6 +37,75 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
|
|||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{cleared:false, snapshots:[]}};
|
||||
const status = {{textContent:''}};
|
||||
const button = {{disabled:false}};
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status, button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
|
||||
location:{{replace:()=>{{}}}},
|
||||
clearPrivateDeviceData:async()=>{{
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
state.cleared=true;
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const pending=controller.showReason('session-revoked');
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await pending;
|
||||
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
|
||||
assert state["final"] == {
|
||||
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
|
||||
"disabled": False,
|
||||
"cleared": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_loads_private_data_purger_before_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert html.index('static/private-device-data.js') < html.index('static/login.js')
|
||||
|
||||
|
||||
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status={{textContent:''}};
|
||||
const button={{disabled:false}};
|
||||
const controller=createLoginController({{
|
||||
form:{{reset:()=>{{}}}},status,button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.showReason('session-revoked');
|
||||
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.",
|
||||
"disabled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_rate_limited_login_disables_submit_and_counts_down():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
|
|
|
|||
49
tests/test_private_device_data.py
Normal file
49
tests/test_private_device_data.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js"
|
||||
|
||||
|
||||
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
|
||||
harness = f"""
|
||||
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
|
||||
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
|
||||
const storage = {{
|
||||
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
|
||||
get length(){{return this.values.size;}},
|
||||
key(index){{return Array.from(this.values.keys())[index] || null;}},
|
||||
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
|
||||
}};
|
||||
const clear = createPrivateDeviceDataPurger({{
|
||||
localStorage:storage, sessionStorage:storage,
|
||||
indexedDB:{{deleteDatabase:name=>{{
|
||||
state.databases.push(name);
|
||||
const request={{}};
|
||||
setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10);
|
||||
return request;
|
||||
}}}},
|
||||
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
|
||||
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
|
||||
MessageChannel:class{{constructor(){{
|
||||
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
|
||||
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
|
||||
this.port1=first;this.port2=second;
|
||||
}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await clear();
|
||||
process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["remaining"] == ["other.preference"]
|
||||
assert state["databases"] == ["stackchain-background-outbox-v1"]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert state["complete"] is True
|
||||
|
|
@ -146,6 +146,26 @@ def test_background_delivery_preserves_structured_uncertain_error():
|
|||
}
|
||||
|
||||
|
||||
def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_clients():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
||||
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_revoked'}),{status:401,headers:{'Content-Type':'application/json'}});
|
||||
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
||||
process.stdout.write(JSON.stringify({state,outcome}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
{"type": "stackchain-session-revoked"}
|
||||
]
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user