Files
EventSnap/e2e/specs/04-host/role-identity-reset.spec.ts
fabi 32dfe6874a test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those
predated the audit's changes, established by building a stack from a clean HEAD worktree
and running the same specs against it rather than guessing.

Most were stale assertions rather than product defects:

- quota.spec solved for a target limit using the observed uploader count, but the divisor is
  max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it
  aimed for came out 100x small and every "within quota" upload 413'd.
- rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with
  `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a
  release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was
  never reached; it now does a real release and asserts 200 rather than "not 429".
- ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence
  it exercises: four tickets per session survive and the rest correctly 401. Now asserts
  exactly four, which a tightened cap or an inverted eviction order would catch.
- auth-tampering asserted a throttled IP is refused EVEN with the correct password. That
  contract was deliberately removed — it let any phone on the venue NAT lock the operator
  out of their own admin panel, with a circular escape hatch. Inverted, plus a new check
  that a success does not refill an attacker's bucket.
- moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters
  banned authors, so it is hidden from everyone including the host. Now pins the pair that
  matters — the ban hides it, and the host's permanent removal survives an unban — and the
  UI leg it used to own is restored as a separate test on a reachable comment.

The export specs mint with `?kind=` now that a download ticket is bound to one archive, and
four of them assert the mint's 404 rather than the download's: with the kind always known,
the pre-check refuses up front instead of after charging a daily download for an archive
that cannot be served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:48 +02:00

103 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 the "Abmelden" button takes.
await page.goto('/account');
await page.getByTestId('account-logout').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 });
});
});