fix(audit-followup): close regressions/gaps from the persona-audit re-review
Adversarial re-review of the persona-audit commit surfaced two HIGH regressions I'd introduced plus MED/LOW gaps. All fixed: HIGH - Auth privilege escalation: reads are sessionStorage-first, but guest `setAuth` wrote only localStorage — a resident admin token shadowed a new guest login (guest ran as admin on a shared device). setAuth/setAdminAuth now DISPLACE the other store so exactly one identity is resident. Adds unit + e2e regression guards. - Host dashboard: `exportGenerating` used `||`, so a one-half export failure stuck on "wird erstellt…" forever and never showed the re-release hint. Changed to `&&`. MEDIUM - Locked-upload auto-resume was unreliable (`event-opened` has no reconnect replay and SSE is down on non-feed pages) — now also resumes on `feed-delta`, which fires on every SSE reconnect. - Queue-cap eviction could drop a recoverable locked item (parked as `error` with a live blob) — now evicts only `done`/`blocked`. - Host page async-`onMount` leaked SSE handlers if unmounted mid-load — added a `destroyed` guard before registration. LOW - An unparseable 403 body now keeps the blob (reversible) instead of purging it. - `refreshEventState` is sequence-guarded so a late close-refresh can't clobber a reopen. - `/export/status` moved out of the all-or-nothing `reload()` so its failure can't blank the whole host dashboard. Verified: svelte-check 0 errors, 36 frontend unit tests (incl. new displacement guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
// jsdom localStorage is actually used (the shared mock stubs it to false).
|
||||
vi.mock('$app/environment', () => ({ browser: true, dev: false, building: false, version: 'test' }));
|
||||
|
||||
import { setAuth, getToken, getPin, getExpiry, getRole, clearAuth, clearPin } from './auth';
|
||||
import { setAuth, setAdminAuth, getToken, getPin, getExpiry, getRole, getUserId, clearAuth, clearPin } from './auth';
|
||||
|
||||
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
|
||||
function makeJwt(claims: object): string {
|
||||
@@ -13,7 +13,10 @@ function makeJwt(claims: object): string {
|
||||
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('auth — token storage', () => {
|
||||
it('setAuth then getToken/getPin round-trips', () => {
|
||||
@@ -42,6 +45,37 @@ describe('auth — token storage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth — admin (sessionStorage) vs guest (localStorage) identity displacement', () => {
|
||||
// Regression guard for a privilege-escalation: reads are sessionStorage-first, so a guest
|
||||
// login MUST displace any resident admin token (else the guest silently runs as admin on a
|
||||
// shared device), and symmetrically an admin login must displace a resident guest token.
|
||||
it('a guest login displaces a resident admin session (no privilege escalation)', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
expect(getRole()).toBe('guest'); // NOT admin
|
||||
expect(getUserId()).toBe('guest-uid');
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared
|
||||
});
|
||||
|
||||
it('an admin login displaces a resident guest session but keeps the guest PIN', () => {
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
expect(getUserId()).toBe('admin-uid');
|
||||
expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared
|
||||
expect(getPin()).toBe('1234'); // PIN preserved for later recovery
|
||||
});
|
||||
|
||||
it('clearAuth wipes both stores', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
clearAuth();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth — JWT claim decode', () => {
|
||||
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
|
||||
const exp = 2_000_000_000; // far-future unix seconds
|
||||
|
||||
Reference in New Issue
Block a user