diff --git a/e2e/specs/04-host/role-identity-reset.spec.ts b/e2e/specs/04-host/role-identity-reset.spec.ts new file mode 100644 index 0000000..ee8140f --- /dev/null +++ b/e2e/specs/04-host/role-identity-reset.spec.ts @@ -0,0 +1,102 @@ +/** + * Regression guard — the role must follow the identity, not the tab. + * + * The `role` store is a module-level singleton seeded ONCE at import. `goto()` is a + * client-side navigation, so leaving and re-joining in the same tab re-imports nothing and + * re-runs no `onMount` — the previous user's role simply stayed. A host who left and a + * guest who then joined kept `isStaff === true` and were offered "🚫 Beitrag entfernen" on + * other people's photos. The backend 403s the delete, so it was a false affordance rather + * than a privilege escalation, but `/feed` never fetched `/me/context`, so it never + * self-corrected either — it survived until a hard reload. + * + * The mirror case matters just as much and is easier to forget: a guest who recovers into a + * host account must GAIN the affordance without a reload. + */ +import { test, expect } from '../../fixtures/test'; +import { seedUpload } from '../../helpers/seed'; +import { JoinPage } from '../../page-objects'; + +const REMOVE = /beitrag entfernen/i; + +test.describe('Role — follows the identity across a same-tab switch', () => { + test('a guest joining after a host leaves does NOT inherit host actions', async ({ + page, + host, + guest, + signIn, + }) => { + // Someone else's photo — the only kind the removal action is offered on. + const author = await guest('RoleAuthor'); + await seedUpload(author.jwt); + + // 1. Host is signed in and DOES see the moderation action. Establishing this first is + // what makes the negative assertion below meaningful. + await signIn(page, host); + await page.goto('/feed'); + const card = page.locator('article').filter({ hasText: author.displayName }).first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.getByRole('button', { name: 'Mehr Aktionen' }).click(); + await expect(page.getByRole('button', { name: REMOVE })).toBeVisible(); + await page.keyboard.press('Escape'); + + // 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes. + await page.goto('/account'); + await page.getByRole('button', { name: /event verlassen/i }).click(); + const confirm = page.getByTestId('confirm-sheet-confirm'); + if (await confirm.isVisible().catch(() => false)) await confirm.click(); + await page.waitForURL('**/join', { timeout: 10_000 }); + + // 3. A brand-new guest joins in the same tab — the real flow, PIN modal and all. + const join = new JoinPage(page); + await join.joinAs(`Nachzuegler${Date.now() % 100000}`); + await join.continueToFeed(); + await expect(page).toHaveURL(/\/feed$/, { timeout: 15_000 }); + + // 4. They must NOT be offered moderation on someone else's photo. + const card2 = page.locator('article').filter({ hasText: author.displayName }).first(); + await expect(card2).toBeVisible({ timeout: 15_000 }); + await card2.getByRole('button', { name: 'Mehr Aktionen' }).click(); + await expect( + page.getByRole('button', { name: REMOVE }), + 'a fresh guest must not inherit the previous user’s role' + ).toHaveCount(0); + }); + + test('a guest who recovers into a host account GAINS host actions without a reload', async ({ + page, + api, + adminToken, + guest, + signIn, + }) => { + // The mirror. If the fix only cleared the role it would pass the test above and still + // leave a real host with no moderation until they reloaded. + const author = await guest('RoleAuthor2'); + await seedUpload(author.jwt); + + const futureHost = await guest('WillBeHost'); + await signIn(page, futureHost); + await page.goto('/feed'); + const card = page.locator('article').filter({ hasText: author.displayName }).first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.getByRole('button', { name: 'Mehr Aktionen' }).click(); + await expect(page.getByRole('button', { name: REMOVE })).toHaveCount(0); + await page.keyboard.press('Escape'); + + // Promote them server-side. Their resident JWT still claims `role: guest`. + await api.setRole(adminToken, futureHost.userId, 'host'); + const claim = JSON.parse(Buffer.from(futureHost.jwt.split('.')[1], 'base64').toString()); + expect(claim.role, 'the token must still be stale for this to prove anything').toBe('guest'); + + // A plain in-app navigation back to the feed must pick up the live role. + await page.goto('/account'); + await page.goto('/feed'); + const card2 = page.locator('article').filter({ hasText: author.displayName }).first(); + await expect(card2).toBeVisible({ timeout: 15_000 }); + await card2.getByRole('button', { name: 'Mehr Aktionen' }).click(); + await expect( + page.getByRole('button', { name: REMOVE }), + 'the live role from /me/context must reach the feed' + ).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index d02e8e1..48bfded 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -86,6 +86,7 @@ export function setAuth( localStorage.setItem(USER_ID_KEY, userId); if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName); isAuthenticated.set(true); + fireSetAuthHooks(); } /** @@ -106,6 +107,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string): sessionStorage.setItem(USER_ID_KEY, userId); if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName); isAuthenticated.set(true); + fireSetAuthHooks(); } // Hook registry: cross-cutting stores (export-status, etc.) register a callback @@ -118,6 +120,26 @@ export function onClearAuth(fn: () => void): void { clearAuthHooks.push(fn); } +// The mirror of `onClearAuth`, for stores that must be RE-SEEDED when a new identity +// arrives rather than merely cleared. Without it, anything derived from the token +// survives a logout→login in the same tab: `goto()` is a client-side navigation, so no +// module is re-imported and no `onMount` re-runs, and the previous user's value simply +// stays. Fires after the new token is resident, so hooks can read it. +const setAuthHooks: Array<() => void> = []; +export function onSetAuth(fn: () => void): void { + setAuthHooks.push(fn); +} + +function fireSetAuthHooks(): void { + for (const fn of setAuthHooks) { + try { + fn(); + } catch { + /* hook failure is non-fatal */ + } + } +} + export function clearAuth(): void { if (!browser) return; // Clear from BOTH stores — a guest token lives in localStorage, an admin token in diff --git a/frontend/src/lib/role-store.ts b/frontend/src/lib/role-store.ts index 5f665ef..dba7a01 100644 --- a/frontend/src/lib/role-store.ts +++ b/frontend/src/lib/role-store.ts @@ -1,5 +1,5 @@ import { derived, writable } from 'svelte/store'; -import { getRole } from './auth'; +import { getRole, onClearAuth, onSetAuth } from './auth'; export type Role = 'guest' | 'host' | 'admin'; @@ -32,3 +32,14 @@ export function setRole(next: Role | null): void { export function syncRoleFromToken(): void { role.set(getRole()); } + +// Bind the store to the identity lifecycle. The store is a module-level singleton seeded +// ONCE at import, and `goto()` navigations re-import nothing — so without these hooks the +// previous user's role survives a logout→login in the same tab. A host who left and a guest +// who then joined kept `isStaff === true` and were offered "Beitrag entfernen" on other +// people's photos (the backend 403s it, so it was a false affordance rather than an +// escalation — but the feed never fetches /me/context, so it never self-corrected either). +// The mirror case is just as wrong: a guest recovering into a host account got no host +// affordances at all. +onClearAuth(() => role.set(null)); +onSetAuth(syncRoleFromToken); diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index 240a16e..b663aed 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -18,6 +18,7 @@ import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { vibrate } from '$lib/haptics'; import { filterUploads } from '$lib/feed-filter'; + import { refreshEventState } from '$lib/event-state-store'; import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types'; let uploads = $state([]); @@ -233,6 +234,15 @@ } } + // Pull the authoritative role/event state. The root layout does this on a full page + // load, but arriving here from /join or /recover is a client-side navigation, so its + // onMount never re-runs — and the feed is the one route that gates a destructive + // action (host "Beitrag entfernen") on the role. Without this the feed would run on + // whatever the JWT claim said, which is frozen for the token's 30-day lifetime and so + // misses a promotion or demotion entirely. Cheap, and it refreshes the lock/release + // state in the same request. + void refreshEventState(); + await Promise.all([loadFeed(), loadHashtags()]); connectSse();