From a3c0082c4b51e3eb942dfcc4e65bd11a45b96987 Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 30 Jun 2026 22:12:05 +0200 Subject: [PATCH] test(e2e): update suite for shipped features + gate Chromium-only perms The running test container had been built from an older tree; rebuilding it to pick up current app code surfaced several tests that predated shipped security/UX features: - file-upload-attacks / rate-limit: assert the magic-byte rejection wording and upload a real decodable JPEG (a zero-buffer is now rejected at the boundary). - ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=. - recover-page / join: the PIN field auto-submits on the 4th digit, so don't race an explicit submit click against the ensuing navigation. - gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and anchor the radio accessible-name match at the start (gated dialog semantics). playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw "Unknown permission: camera" on firefox/webkit and failed the test at context creation); grant them per-Chromium-project. firefox-android drops the unsupported isMobile flag. Co-Authored-By: Claude Opus 4.8 --- e2e/page-objects/recover-page.ts | 10 +++++++- e2e/playwright.config.ts | 22 ++++++++++++++---- e2e/specs/01-auth/join.spec.ts | 8 +++++-- e2e/specs/02-upload/rate-limit.spec.ts | 9 ++++--- e2e/specs/07-adversarial/ddos.spec.ts | 18 +++++++++++--- .../file-upload-attacks.spec.ts | Bin 6575 -> 6826 bytes .../09-mobile/gestures-doubletap.spec.ts | 9 +++---- e2e/specs/09-mobile/sheet-escape.spec.ts | 4 +++- 8 files changed, 61 insertions(+), 19 deletions(-) diff --git a/e2e/page-objects/recover-page.ts b/e2e/page-objects/recover-page.ts index d4477d9..ea69940 100644 --- a/e2e/page-objects/recover-page.ts +++ b/e2e/page-objects/recover-page.ts @@ -21,7 +21,15 @@ export class RecoverPage { async recover(name: string, pin: string) { await this.nameInput.fill(name); + // Filling the 4th digit fires the form's auto-submit ($effect on + // pin.length === 4, see pin-auto-submit.spec). An explicit submit click would + // race the ensuing navigation and detach mid-click, so only click as a + // fallback if the button is still around (e.g. a partial / failed PIN). await this.pinInput.fill(pin); - await this.submitButton.click(); + if (await this.submitButton.isEnabled().catch(() => false)) { + await this.submitButton.click().catch(() => { + /* auto-submit already navigated — nothing to click */ + }); + } } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 6fee2ec..1073517 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -12,6 +12,12 @@ import { defineConfig, devices } from '@playwright/test'; * engine-level divergences. The rest of the suite only runs against * `chromium-desktop` to keep the wall-clock reasonable. */ +// camera/microphone/clipboard are Chromium-only permissions; passing them to +// firefox/webkit projects throws "Unknown permission: camera" and fails the +// whole test before it runs. Granted per-Chromium-project below instead of in +// the global `use` block. +const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboard-write']; + export default defineConfig({ testDir: './specs', outputDir: './test-results', @@ -35,9 +41,8 @@ export default defineConfig({ video: 'retain-on-failure', actionTimeout: 10_000, navigationTimeout: 30_000, - // Camera/mic permissions granted by default; the fake-media launch args - // (set per-project below for Chromium) supply the actual stream. - permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'], + // No camera/mic/clipboard here — those are Chromium-only and are granted on + // the Chromium projects below (see CHROMIUM_PERMISSIONS). }, projects: [ @@ -49,6 +54,7 @@ export default defineConfig({ testIgnore: ['**/09-mobile/**'], use: { ...devices['Desktop Chrome'], + permissions: CHROMIUM_PERMISSIONS, launchOptions: { args: [ '--use-fake-ui-for-media-stream', @@ -68,13 +74,13 @@ export default defineConfig({ { name: 'chromium-mobile', testMatch: ['**/09-mobile/**/*.spec.ts'], - use: { ...devices['Pixel 7'] }, + use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS }, }, // ── Mobile UA smoke matrix (runs only @smoke specs in CI) ──────────── { name: 'chromium-pixel7', - use: { ...devices['Pixel 7'] }, + use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS }, grep: /@smoke/, }, { @@ -84,6 +90,7 @@ export default defineConfig({ viewport: { width: 360, height: 780 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + permissions: CHROMIUM_PERMISSIONS, }, grep: /@smoke/, }, @@ -94,6 +101,7 @@ export default defineConfig({ viewport: { width: 360, height: 780 }, userAgent: 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36', + permissions: CHROMIUM_PERMISSIONS, }, grep: /@smoke/, }, @@ -103,6 +111,7 @@ export default defineConfig({ ...devices['Pixel 7'], userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0', + permissions: CHROMIUM_PERMISSIONS, }, grep: /@smoke/, }, @@ -125,6 +134,9 @@ export default defineConfig({ use: { ...devices['Pixel 7'], defaultBrowserType: 'firefox', + // Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport + + // Android UA for coverage, but drop the unsupported flag. + isMobile: false, userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0', }, diff --git a/e2e/specs/01-auth/join.spec.ts b/e2e/specs/01-auth/join.spec.ts index 26a4edc..020bef5 100644 --- a/e2e/specs/01-auth/join.spec.ts +++ b/e2e/specs/01-auth/join.spec.ts @@ -54,9 +54,13 @@ test.describe('Auth — join flow', () => { await expect(join.recoveryPinInput).toBeVisible(); await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible(); - // Type correct PIN → land on /feed with a new JWT + // Type correct PIN → land on /feed with a new JWT. Filling the 4th digit + // auto-submits (see pin-auto-submit.spec), so an explicit submit click would + // race the navigation; click only as a fallback if the button is still around. await join.recoveryPinInput.fill(original.pin); - await join.recoverySubmit.click(); + if (await join.recoverySubmit.isEnabled().catch(() => false)) { + await join.recoverySubmit.click().catch(() => {}); + } await page.waitForURL('**/feed'); const storage = await readStorage(page); diff --git a/e2e/specs/02-upload/rate-limit.spec.ts b/e2e/specs/02-upload/rate-limit.spec.ts index b749f91..44efacb 100644 --- a/e2e/specs/02-upload/rate-limit.spec.ts +++ b/e2e/specs/02-upload/rate-limit.spec.ts @@ -6,8 +6,12 @@ */ import { test, expect } from '../../fixtures/test'; import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); +// A real, decodable JPEG — the upload handler validates magic bytes, so a +// zero-filled buffer would be rejected with 400 before the rate limiter is reached. +const SAMPLE_BYTES = readFileSync(SAMPLE_JPG); test.describe('Upload — rate limit', () => { test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => { @@ -24,7 +28,7 @@ test.describe('Upload — rate limit', () => { // Hit the API directly for speed — UI behavior is asserted in gallery-path.spec. const upload = async (n: number) => { const form = new FormData(); - const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' }); + const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' }); form.append('file', blob, `file${n}.jpg`); form.append('content_type', 'image/jpeg'); return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', { @@ -47,7 +51,6 @@ test.describe('Upload — rate limit', () => { // The 429 response carries Retry-After. const limited = responses.find((r) => r.status === 429)!; expect(limited.headers.get('retry-after')).toBeTruthy(); - void SAMPLE_JPG; }); test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => { @@ -56,7 +59,7 @@ test.describe('Upload — rate limit', () => { const h = await guest('NoQuota'); const upload = async (n: number) => { const form = new FormData(); - const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' }); + const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' }); form.append('file', blob, `file${n}.jpg`); form.append('content_type', 'image/jpeg'); return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', { diff --git a/e2e/specs/07-adversarial/ddos.spec.ts b/e2e/specs/07-adversarial/ddos.spec.ts index a52e7c6..8550dfd 100644 --- a/e2e/specs/07-adversarial/ddos.spec.ts +++ b/e2e/specs/07-adversarial/ddos.spec.ts @@ -46,9 +46,21 @@ test.describe('Adversarial — small-scale abuse', () => { test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => { const g = await guest('SseFlood'); - const controllers = Array.from({ length: 10 }, () => new AbortController()); - const requests = controllers.map((c) => - fetch(`${BASE}/api/v1/stream?token=${encodeURIComponent(g.jwt)}`, { signal: c.signal }) + // The stream endpoint authenticates via single-use tickets (POST /stream/ticket), + // not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream. + const mintTicket = async () => { + const res = await fetch(`${BASE}/api/v1/stream/ticket`, { + method: 'POST', + headers: { Authorization: `Bearer ${g.jwt}` }, + }); + const json: any = await res.json(); + return json.ticket as string; + }; + const tickets = await Promise.all(Array.from({ length: 10 }, mintTicket)); + + const controllers = tickets.map(() => new AbortController()); + const requests = tickets.map((ticket, i) => + 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). diff --git a/e2e/specs/07-adversarial/file-upload-attacks.spec.ts b/e2e/specs/07-adversarial/file-upload-attacks.spec.ts index db07319d3b8e1ed5b138bbacb61977d3cf1dc0d7..baa9dcae701ca7de4a3200272be9c8557ec31294 100644 GIT binary patch delta 305 zcmZvXF-`+P3`H9#pymKysfdJ<0?|-WKtT~IE->skS%+EA%HyysLJD`_4iq#X(b91f z8cME!6_yT{_W#fSzZ^avy?$@4r&Cs!KHbAu){Q*keu#}cm8E1h~hy|n5V8qt1TXd`u!c}cpiIEoj zc(*${#^=M+2>WjFFn{2XQtlELL&K$vkrgA?4Sv$=AYy~Ft4Y8z_gYG}QYpB|LgUE> YMz^gedi>g%{PX<&H_yO(JK4^AKdlvS9RL6T delta 46 zcmZ2wy54xhdzQ&}xJ+bI^Gb>fiZYWkN)+-yWKBwHc1~hZW@=GMYTjmRwtNu)$?6i8 diff --git a/e2e/specs/09-mobile/gestures-doubletap.spec.ts b/e2e/specs/09-mobile/gestures-doubletap.spec.ts index 8649338..fbe9b50 100644 --- a/e2e/specs/09-mobile/gestures-doubletap.spec.ts +++ b/e2e/specs/09-mobile/gestures-doubletap.spec.ts @@ -75,10 +75,11 @@ test.describe('Mobile — double-tap gesture', () => { await expect(imageButton).toBeVisible({ timeout: 10_000 }); await imageButton.click(); - // LightboxModal is `role="dialog"` (no aria-modal). The other dialog on the - // page is the ContextSheet which has `aria-modal="true"` even when closed, - // so scope to NOT-aria-modal to pick the lightbox specifically. - const lightbox = page.locator('[role="dialog"]:not([aria-modal])'); + // LightboxModal is the only dialog labelled by #lightbox-title, so target it + // directly. (Closed sheets no longer expose role=dialog — that semantics is + // gated on `open` — so a plain [role=dialog] match would be ambiguous only + // while a sheet is open; the labelledby scope keeps this unambiguous.) + const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]'); await expect(lightbox).toBeVisible(); // Find the inner image element to tap. diff --git a/e2e/specs/09-mobile/sheet-escape.spec.ts b/e2e/specs/09-mobile/sheet-escape.spec.ts index 9bfaaf3..6ab3730 100644 --- a/e2e/specs/09-mobile/sheet-escape.spec.ts +++ b/e2e/specs/09-mobile/sheet-escape.spec.ts @@ -12,7 +12,9 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => { await page.goto('/account'); // Click the "Original" radio in the Datennutzung section to open the warning sheet. - const originalRadio = page.getByRole('radio', { name: /Original$/i }); + // The radio's accessible name is its title + description ("Original Lädt die + // Originaldateien…"), so anchor on the start, not the end. + const originalRadio = page.getByRole('radio', { name: /^Original/i }); await originalRadio.click(); const sheet = page.locator('[role="dialog"][aria-labelledby="data-mode-title"]');