chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE

The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).

Rules keep the ones that catch real TEST bugs and drop the noise:
  - no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
    passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
  - no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
    `api` arg, an unused JPEG_MAGIC import), all removed.
  - no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
    a page.evaluate() return.
  - no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.

Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.

Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 20:45:59 +02:00
parent f8cba95e49
commit bbdfae09a0
67 changed files with 2441 additions and 396 deletions

View File

@@ -8,23 +8,28 @@
*/
import { test, expect } from '../../fixtures/test';
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
/** RFC-4648 base64url with no padding. */
function b64u(s: string) {
return Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
return Buffer.from(s)
.toString('base64')
.replace(/=+$/, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
test.describe('Adversarial — JWT', () => {
test('alg:none token claiming admin role is rejected', async () => {
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
const payload = b64u(JSON.stringify({
sub: '00000000-0000-0000-0000-000000000000',
role: 'admin',
event_id: '00000000-0000-0000-0000-000000000000',
exp: Math.floor(Date.now() / 1000) + 3600,
}));
const payload = b64u(
JSON.stringify({
sub: '00000000-0000-0000-0000-000000000000',
role: 'admin',
event_id: '00000000-0000-0000-0000-000000000000',
exp: Math.floor(Date.now() / 1000) + 3600,
})
);
const token = `${header}.${payload}.`;
const res = await fetch(`${BASE}/api/v1/admin/config`, {
headers: { Authorization: `Bearer ${token}` },
@@ -44,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
expect(res.status).toBe(401);
});
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({ guest }) => {
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
guest,
}) => {
const g = await guest('RolePromote');
const parts = g.jwt.split('.');
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
@@ -111,7 +118,9 @@ test.describe('Adversarial — PIN brute-force', () => {
expect(correct.status).toBe(429);
});
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({ guest }) => {
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
guest,
}) => {
const g = await guest('BruteParallel');
const wrong = g.pin === '0000' ? '1111' : '0000';
@@ -125,7 +134,10 @@ test.describe('Adversarial — PIN brute-force', () => {
)
);
const statuses = attempts.map((r) => r.status);
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(0);
expect(
statuses.filter((s) => s === 200),
'a wrong PIN must never authenticate'
).toHaveLength(0);
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
@@ -140,7 +152,10 @@ test.describe('Adversarial — PIN brute-force', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
});
expect(correct.status, 'after 10 wrong PINs the account must be locked, even for the right PIN').toBe(429);
expect(
correct.status,
'after 10 wrong PINs the account must be locked, even for the right PIN'
).toBe(429);
});
});
@@ -188,7 +203,10 @@ test.describe('Adversarial — admin password brute-force', () => {
}
// The password path actually ran (budget existed) before the limiter engaged...
expect(statuses[0], 'first attempt should be a normal wrong-password 401, not a spurious 429').toBe(401);
expect(
statuses[0],
'first attempt should be a normal wrong-password 401, not a spurious 429'
).toBe(401);
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
expect(
statuses.some((s) => s === 429),
@@ -218,7 +236,10 @@ test.describe('Adversarial — admin password brute-force', () => {
// The right password, while throttled, must STILL be refused — the limiter is checked before
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').toBe(429);
expect(
(await tryLogin(ADMIN_PASSWORD)).status,
'a throttled IP is refused even with the correct password'
).toBe(429);
});
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
@@ -235,6 +256,9 @@ test.describe('Adversarial — admin password brute-force', () => {
const statuses: number[] = [];
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
expect(statuses.every((s) => s === 401), 'with admin_login_rate_enabled=false no attempt should be 429').toBe(true);
expect(
statuses.every((s) => s === 401),
'with admin_login_rate_enabled=false no attempt should be 429'
).toBe(true);
});
});

View File

@@ -6,15 +6,16 @@
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
test.describe('Adversarial — deep authorization', () => {
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
// tested authorization at all.
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({
guest,
}) => {
const a = await guest('CommentOwnerA');
const b = await guest('AttackerB');
@@ -42,7 +43,7 @@ test.describe('Adversarial — deep authorization', () => {
});
// IDOR: user B must not be able to delete user A's REAL upload.
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => {
const a = await guest('UploadOwnerA');
const b = await guest('AttackerB2');
@@ -60,7 +61,7 @@ test.describe('Adversarial — deep authorization', () => {
});
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => {
const a = await guest('UploadOwnerA2');
const b = await guest('AttackerB3');
@@ -75,7 +76,9 @@ test.describe('Adversarial — deep authorization', () => {
expect(res.status).toBe(403);
// No state change: the caption A set is intact.
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
const feedRes = await fetch(`${BASE}/api/v1/feed`, {
headers: { Authorization: `Bearer ${a.jwt}` },
});
const row = findFeedRow(await feedRes.json(), uploadId);
expect(row?.caption).toBe('original caption');
});
@@ -115,7 +118,11 @@ test.describe('Adversarial — deep authorization', () => {
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
});
test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest }) => {
test('banned user can still read the feed (read-only access preserved)', async ({
api,
host,
guest,
}) => {
const target = await guest('BannedRead');
await api.banUser(host.jwt, target.userId);
@@ -126,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
expect(res.status).toBe(200);
});
test('host cannot delete another host\'s session via /api/v1/session', async ({ api, host, guest }) => {
test("host cannot delete another host's session via /api/v1/session", async ({
api,
host,
guest,
}) => {
const otherHost = await guest('OtherHost');
// Promote so they have a host JWT to play with.
await api.setRole(host.jwt, otherHost.userId, 'host');

View File

@@ -6,8 +6,7 @@
import { test, expect } from '../../fixtures/test';
import { mintSseTicket } from '../../helpers/sse';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
test.describe('Adversarial — small-scale abuse', () => {
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
@@ -64,7 +63,9 @@ test.describe('Adversarial — small-scale abuse', () => {
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
});
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({ guest }) => {
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
guest,
}) => {
const g = await guest('BigComment');
const uploadId = await seedUpload(g.jwt);
const huge = 'A'.repeat(10 * 1024 * 1024);
@@ -87,7 +88,9 @@ test.describe('Adversarial — small-scale abuse', () => {
const controllers = tickets.map(() => new AbortController());
const requests = tickets.map((ticket, i) =>
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
signal: controllers[i].signal,
})
);
const responses = await Promise.all(requests);
// All accepted (or some rate-limited — both fine).

View File

@@ -6,8 +6,7 @@
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
test.describe('Media gating — moderation revokes preview access (F2)', () => {
test('preview served via gated alias, blocked directly, and 404 after delete', async ({

View File

@@ -7,8 +7,7 @@
* still work.
*/
import { test, expect } from '../../fixtures/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
function patchRole(token: string, userId: string, role: string) {
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {

View File

@@ -8,8 +8,7 @@
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket, openStream } from '../../helpers/sse';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
test.describe('Adversarial — SSE ticket abuse', () => {
test('minting a ticket requires authentication', async () => {

View File

@@ -14,12 +14,15 @@ test.describe('Adversarial — UI render escape', () => {
const r = await api.join(payload);
await page.goto('/');
await page.evaluate(({ j, u, n, p }) => {
localStorage.setItem('eventsnap_jwt', j);
localStorage.setItem('eventsnap_user_id', u);
localStorage.setItem('eventsnap_display_name', n);
localStorage.setItem('eventsnap_pin', p);
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
await page.evaluate(
({ j, u, n, p }) => {
localStorage.setItem('eventsnap_jwt', j);
localStorage.setItem('eventsnap_user_id', u);
localStorage.setItem('eventsnap_display_name', n);
localStorage.setItem('eventsnap_pin', p);
},
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
);
page.on('dialog', (d) => {
throw new Error(`Dialog fired: ${d.message()}`);
@@ -31,7 +34,9 @@ test.describe('Adversarial — UI render escape', () => {
// the *absence* of a <b> at that point passes on a page that never rendered the name
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
// (as escaped text) before concluding anything about how it was rendered.
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
const fired = await page.evaluate(() => (window as any).__xssFired === true);
expect(fired).toBe(false);
@@ -42,17 +47,23 @@ test.describe('Adversarial — UI render escape', () => {
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
});
test('rendering of a known SQL-injection-shaped name does not break the page', async ({ page, api }) => {
test('rendering of a known SQL-injection-shaped name does not break the page', async ({
page,
api,
}) => {
const payload = `'); DROP TABLE users; --`;
const r = await api.join(payload);
await page.goto('/');
await page.evaluate(({ j, u, n, p }) => {
localStorage.setItem('eventsnap_jwt', j);
localStorage.setItem('eventsnap_user_id', u);
localStorage.setItem('eventsnap_display_name', n);
localStorage.setItem('eventsnap_pin', p);
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
await page.evaluate(
({ j, u, n, p }) => {
localStorage.setItem('eventsnap_jwt', j);
localStorage.setItem('eventsnap_user_id', u);
localStorage.setItem('eventsnap_display_name', n);
localStorage.setItem('eventsnap_pin', p);
},
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
);
await page.goto('/account');
// Page renders.

View File

@@ -13,8 +13,7 @@
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
import { BASE } from '../../helpers/env';
/**
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
@@ -36,7 +35,10 @@ const XSS_PAYLOADS = [
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
const NAME_MAX = 50;
for (const p of XSS_PAYLOADS) {
if (p.length > NAME_MAX) throw new Error(`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`);
if (p.length > NAME_MAX)
throw new Error(
`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`
);
}
const SQLI_PAYLOADS = [
@@ -48,7 +50,10 @@ const SQLI_PAYLOADS = [
test.describe('Adversarial — input injection (display name)', () => {
for (const payload of XSS_PAYLOADS) {
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
api,
page,
}) => {
// No try/catch escape hatch: every payload is short enough to be accepted, so a
// rejection here is a real failure (the payload would never be rendered, and the
// "nothing executed" assertions below would be vacuous).
@@ -77,7 +82,9 @@ test.describe('Adversarial — input injection (display name)', () => {
// Render guard: confirm the payload actually reached the DOM as escaped text,
// so a "nothing fired" pass can't be because the name was never rendered.
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
const fired = await page.evaluate(() => (window as any).__x === 1);
expect(fired, 'window.__x should never be set').toBe(false);
@@ -92,7 +99,11 @@ test.describe('Adversarial — input injection (display name)', () => {
test.describe('Adversarial — stored XSS (caption)', () => {
for (const payload of XSS_PAYLOADS) {
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
guest,
page,
signIn,
}) => {
const g = await guest('CapXss');
// A trailing marker lets us wait until the caption has actually rendered before
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
@@ -100,17 +111,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
expect(id).toMatch(/^[0-9a-f-]{36}$/);
const dialogs: string[] = [];
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
page.on('dialog', (d) => {
dialogs.push(d.message());
d.dismiss().catch(() => {});
});
await signIn(page, g);
// Wait for the caption text to land in the DOM (escaped, as literal text).
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
expect(await page.evaluate(() => (window as any).__x === 1), 'caption XSS must not fire').toBe(false);
expect(
await page.evaluate(() => (window as any).__x === 1),
'caption XSS must not fire'
).toBe(false);
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
// The payload must be inert text, not a live element / script.
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
expect(await page.locator('script:has-text("window.__x")').count(), 'no executable script from caption').toBe(0);
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(
0
);
expect(
await page.locator('script:has-text("window.__x")').count(),
'no executable script from caption'
).toBe(0);
});
}
});
@@ -118,23 +142,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
test.describe('Adversarial — stored XSS (comment)', () => {
// The two payloads that actually execute on render (script injection via innerHTML
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
const COMMENT_PAYLOADS = [
`<img src=x onerror="window.__x=1">`,
`"><svg onload="window.__x=1">`,
];
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
for (const payload of COMMENT_PAYLOADS) {
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
guest,
page,
signIn,
}) => {
const author = await guest('CmtXss');
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
// Post the XSS comment via the API (verbatim storage).
await seedComment(author.jwt, id, `${payload} CMTMARK`);
const dialogs: string[] = [];
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
page.on('dialog', (d) => {
dialogs.push(d.message());
d.dismiss().catch(() => {});
});
await signIn(page, author);
// Open the lightbox (which loads + renders comments).
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
const imageButton = page
.locator('article')
.filter({ hasText: 'CAPMARK' })
.first()
.getByRole('button', { name: 'Bild vergrößern' });
await expect(imageButton).toBeVisible({ timeout: 10_000 });
await imageButton.click();
@@ -142,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
await expect(lightbox).toBeVisible();
// Wait until the comment (marker) has rendered.
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({
timeout: 10_000,
});
expect(await page.evaluate(() => (window as any).__x === 1), 'comment XSS must not fire').toBe(false);
expect(
await page.evaluate(() => (window as any).__x === 1),
'comment XSS must not fire'
).toBe(false);
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(
0
);
});
}
});
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
for (const payload of SQLI_PAYLOADS) {
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({
api,
adminToken,
}) => {
const res = await api.join(payload);
expect(res.jwt).toBeTruthy();
@@ -196,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
expect([400, 201, 409]).toContain(res.status);
});
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
test('Unicode RTL override character in name does not corrupt rendering', async ({
api,
page,
}) => {
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
const r = await api.join(rtlName);
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);