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>
103 lines
4.7 KiB
TypeScript
103 lines
4.7 KiB
TypeScript
/**
|
||
* 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 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 });
|
||
});
|
||
});
|