fix(auth): bind the role store to the identity, not to the tab
The role store I added in the moderation work 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 no module and re-runs no onMount — the previous user's role simply stayed resident. Nothing reset it: not join, recover, admin login, "Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear. So a host who left, followed by a guest joining on the same phone, left that guest with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's photos. The backend 403s the delete, so this was a false affordance rather than a privilege escalation — but `/feed` never fetched `/me/context`, so unlike every other route it never self-corrected either. It survived until a hard reload. The mirror case was equally broken and easier to overlook: a guest who recovered into a host account got NO host affordances. `clearAuth` already had a hook registry for exactly this shape of problem, with a comment explaining it exists to avoid circular imports. Add the missing mirror, `onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is resident, and have the role store register on both sides: clear to null on logout, re-seed from the new token on login. That also gives `syncRoleFromToken` — dead code with zero callers since I introduced it — its intended purpose. Seeding from the claim fixes the reported bug, but the claim is frozen for the token's 30-day life, so a promotion or demotion still wouldn't reach the feed. `/feed` now calls the existing `refreshEventState()` on mount, which fetches `/me/context` and applies both the authoritative role and the lock/release state in one request. The feed is the one route gating a destructive action on the role, so it should not be the only route running on a stale claim. Tests: 04-host/role-identity-reset drives the real flows. The first asserts the host DOES see the action before asserting the newcomer does not — a negative assertion alone would pass against a build that shipped no moderation at all. The second covers the mirror, promoting a guest server-side while their resident token still claims `role: guest`, so a fix that only cleared the role would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal file
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal 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 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user