Files
EventSnap/e2e/specs/07-adversarial/admin-guest-displacement.spec.ts
fabi 811c724685 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>
2026-07-13 21:19:51 +02:00

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(\/)?$/);
});
});