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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
Reference in New Issue
Block a user