Merge branch 'fix/role-store-identity-reset'

This commit is contained in:
fabi
2026-07-28 20:35:40 +02:00
4 changed files with 146 additions and 1 deletions

View File

@@ -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 users 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 });
});
});

View File

@@ -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

View File

@@ -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);

View File

@@ -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<FeedUpload[]>([]);
@@ -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();