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:
@@ -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}$/);
|
||||
|
||||
Reference in New Issue
Block a user