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>
52 lines
2.1 KiB
TypeScript
52 lines
2.1 KiB
TypeScript
/**
|
|
* Regression guard — privilege escalation via mixed auth storage.
|
|
*
|
|
* The admin JWT lives in sessionStorage; guest JWTs in localStorage; reads are
|
|
* sessionStorage-first. So a guest login MUST displace any resident admin token — otherwise
|
|
* a leftover admin session on a shared "event laptop" would shadow the guest's own token and
|
|
* silently hand the next person who joins full admin rights. This exercises the real UI flow.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { JoinPage } from '../../page-objects';
|
|
|
|
function roleOf(page: import('@playwright/test').Page) {
|
|
return page.evaluate(() => {
|
|
const t = sessionStorage.getItem('eventsnap_jwt') ?? localStorage.getItem('eventsnap_jwt');
|
|
if (!t) return null;
|
|
try {
|
|
return JSON.parse(atob(t.split('.')[1])).role;
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
test.describe('AuthZ — guest join displaces a resident admin session', () => {
|
|
test('joining as a guest with an admin token resident in sessionStorage yields GUEST rights', async ({
|
|
page,
|
|
api,
|
|
}) => {
|
|
// Simulate an admin who logged in on a shared device and walked away without closing the
|
|
// tab — their token sits in sessionStorage.
|
|
const adminJwt = await api.adminLogin();
|
|
await page.goto('/');
|
|
await page.evaluate((jwt) => sessionStorage.setItem('eventsnap_jwt', jwt), adminJwt);
|
|
|
|
// A different person now joins as a guest in that same tab.
|
|
const join = new JoinPage(page);
|
|
await join.goto();
|
|
await join.joinAs('DisplacementGuest');
|
|
await join.continueToFeed();
|
|
|
|
// The effective identity must be the GUEST — not the leftover admin.
|
|
expect(await roleOf(page)).toBe('guest');
|
|
// The admin token must have been cleared from sessionStorage by the guest login.
|
|
expect(await page.evaluate(() => sessionStorage.getItem('eventsnap_jwt'))).toBeNull();
|
|
|
|
// And the guest must NOT be able to reach the admin dashboard.
|
|
await page.goto('/admin');
|
|
await page.waitForURL(/admin\/login|join|feed/, { timeout: 5_000 });
|
|
expect(page.url()).not.toMatch(/\/admin(\/)?$/);
|
|
});
|
|
});
|