diff --git a/e2e/.prettierignore b/e2e/.prettierignore new file mode 100644 index 0000000..25a1856 --- /dev/null +++ b/e2e/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +package-lock.json diff --git a/e2e/.prettierrc b/e2e/.prettierrc new file mode 100644 index 0000000..1e6146b --- /dev/null +++ b/e2e/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/e2e/README.md b/e2e/README.md index 6f4a5c7..0b56409 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -6,6 +6,7 @@ backend) and `:55432` (Postgres), and exercises the SvelteKit frontend against a real Rust backend with rate limits and quotas disabled. **Phases 1, 2, and 3-mobile-gestures are landed**: + - **Phase 1** — happy-path coverage of every documented user journey, plus a smoke matrix across nine browser/UA profiles to catch engine-level divergences. @@ -47,25 +48,25 @@ The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR. Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md) or a security/chaos scenario. One folder per area: -| Folder | Phase | Journeys / Topic | Tests | Notes | -|---|---|---|---|---| -| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. | -| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. | -| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. | -| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. | -| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. | -| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. | -| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. | -| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. | -| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. | -| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. | +| Folder | Phase | Journeys / Topic | Tests | Notes | +| ------------------------- | ----- | ----------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------- | +| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. | +| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. | +| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. | +| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. | +| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. | +| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. | +| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. | +| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. | +| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. | +| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. | ### Phase 2 — adversarial (`specs/07-adversarial/`) - **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path - + four SQLi patterns + length/encoding edge cases (NUL byte, RTL override, - caption overflow). Asserts `window.__xssFired` never gets set and no - `dialog` event fires. + - four SQLi patterns + length/encoding edge cases (NUL byte, RTL override, + caption overflow). Asserts `window.__xssFired` never gets set and no + `dialog` event fires. - **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script- payload sits in localStorage as the user's display name, rendering through `/account` keeps it as text. @@ -104,17 +105,17 @@ are marked `test.fixme` and will activate when that helper lands. ## Browser & UA matrix -| Project | Engine | UA / Device | Why | -|---|---|---|---| -| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. | -| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. | -| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. | -| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** | -| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). | -| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). | -| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. | -| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. | -| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. | +| Project | Engine | UA / Device | Why | +| --------------------- | -------- | ----------------------------------- | --------------------------------------------- | +| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. | +| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. | +| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. | +| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** | +| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). | +| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). | +| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. | +| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. | +| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. | Only the `@smoke` happy-path runs across all projects (controlled by `grep` in `playwright.config.ts`). The full Phase 1 suite is @@ -127,14 +128,14 @@ It's **Blink-based**, so Tier-A catches ~90% of regressions. Real Samsung divergences (Smart Switch save-data mode, dark-mode injection, custom autoplay, in-browser ad blocking) are only reproducible at Tier B+: -- **Tier A** *(this repo, free, in CI)*: Playwright Chromium with the +- **Tier A** _(this repo, free, in CI)_: Playwright Chromium with the Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet` project in `playwright.config.ts`. -- **Tier B** *(free, manual, future)*: Android Studio emulator on Linux → +- **Tier B** _(free, manual, future)_: Android Studio emulator on Linux → install Samsung Internet APK → enable `--remote-debugging-port=9222` → `chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in `docs/samsung-emulator.md` (to be written). -- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices. +- **Tier C** _(paid, optional)_: BrowserStack or LambdaTest cloud devices. Real Galaxy S22/S23 hardware via Playwright's cloud integration. ## Test isolation @@ -223,7 +224,7 @@ Known findings surfaced (documented in tests, not silent failures): clears it). 3. SVG uploads currently pass the magic-byte check (depends on `infer`'s detection coverage) — consider adding `X-Content-Type-Options: nosniff` - + CSP on `/media/*` if SVGs are ever expected as user content. + - CSP on `/media/*` if SVGs are ever expected as user content. ### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed @@ -273,6 +274,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config values. ### Phase 3 — Real-device compat & visual / a11y (not landed) + - Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like. - Safe-area inset visual diff on iPhone notch. - Touch-target ≥ 44 px audit. @@ -282,6 +284,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config - Visual regression with screenshot diffs. ### Out of scope (handed to other tools) + - Load testing → k6 / Vegeta. - API contract testing → backend `cargo test` integration tests. - Static asset auditing → Lighthouse CI. diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml index b7b43bf..adf77d5 100644 --- a/e2e/docker-compose.test.yml +++ b/e2e/docker-compose.test.yml @@ -17,12 +17,12 @@ services: POSTGRES_PASSWORD: eventsnap_test POSTGRES_DB: eventsnap_test healthcheck: - test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"] + test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test'] interval: 3s timeout: 3s retries: 30 ports: - - "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup + - '55432:5432' # exposed so the e2e harness can connect via pg for fixture setup app: build: @@ -40,15 +40,15 @@ services: ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32 EVENT_SLUG: e2e-test-event EVENT_NAME: E2E Test Event - APP_PORT: "3000" + APP_PORT: '3000' MEDIA_PATH: /media - SESSION_EXPIRY_DAYS: "30" - EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod + SESSION_EXPIRY_DAYS: '30' + EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod RUST_LOG: eventsnap_backend=info,tower_http=warn volumes: - media_data:/media expose: - - "3000" + - '3000' frontend: build: @@ -57,11 +57,11 @@ services: depends_on: - app environment: - PORT: "3001" - HOST: "0.0.0.0" - ORIGIN: "http://localhost:3101" + PORT: '3001' + HOST: '0.0.0.0' + ORIGIN: 'http://localhost:3101' expose: - - "3001" + - '3001' caddy: image: caddy:2-alpine @@ -71,7 +71,7 @@ services: volumes: - ./Caddyfile.test:/etc/caddy/Caddyfile:ro ports: - - "3101:3101" + - '3101:3101' volumes: media_data: diff --git a/e2e/eslint.config.js b/e2e/eslint.config.js new file mode 100644 index 0000000..6e81c02 --- /dev/null +++ b/e2e/eslint.config.js @@ -0,0 +1,47 @@ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +/** + * Flat-config ESLint for the Playwright TypeScript suite. Prettier owns formatting (its config is + * last so it disables every stylistic rule). The rules kept here catch real test bugs — unused + * setup, floating promises that make a test race, `any` that hides a wrong assertion shape. + */ +export default ts.config( + js.configs.recommended, + ...ts.configs.recommended, + prettier, + { + languageOptions: { + globals: { ...globals.node }, + }, + }, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + // A floating promise in a test is a real hazard: an un-awaited request or assertion can let + // the test end before it runs, passing vacuously. Keep it on. + '@typescript-eslint/no-floating-promises': 'error', + // Off for the suite: this is all test code, where `any` is the honest type for an untyped + // `res.json()` body or a `page.evaluate()` return. Threading DTO types through every + // assertion is churn that buys nothing — the assertion values are what's checked, not the + // static shape. (no-floating-promises and no-unused-vars, which catch real test bugs, stay on.) + '@typescript-eslint/no-explicit-any': 'off', + // Off: Playwright fixtures with no dependencies are declared `async ({}, use) => {}` — the + // empty destructure is required by the fixtures API, not an accident. + 'no-empty-pattern': 'off', + }, + }, + { + languageOptions: { + parserOptions: { projectService: true }, + }, + }, + { + ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'], + } +); diff --git a/e2e/fixtures/api-client.ts b/e2e/fixtures/api-client.ts index c0b35b0..845c94c 100644 --- a/e2e/fixtures/api-client.ts +++ b/e2e/fixtures/api-client.ts @@ -47,7 +47,9 @@ export class ApiClient { } // ── Auth ─────────────────────────────────────────────────────────────── - async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> { + async join( + displayName: string + ): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> { const { body } = await this.request('POST', '/join', { body: { display_name: displayName }, expectedStatus: [201], @@ -55,7 +57,11 @@ export class ApiClient { return body; } - async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) { + async recover( + displayName: string, + pin: string, + opts: { expectedStatus?: number | number[] } = {} + ) { return this.request('POST', '/recover', { body: { display_name: displayName, pin }, expectedStatus: opts.expectedStatus ?? [200], @@ -91,7 +97,9 @@ export class ApiClient { } async getConfig(adminToken: string): Promise> { - const { body } = await this.request>('GET', '/admin/config', { token: adminToken }); + const { body } = await this.request>('GET', '/admin/config', { + token: adminToken, + }); return body; } @@ -118,7 +126,11 @@ export class ApiClient { }); } - async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) { + async unbanUser( + token: string, + userId: string, + opts: { expectedStatus?: number | number[] } = {} + ) { return this.request('POST', `/host/users/${userId}/unban`, { token, expectedStatus: opts.expectedStatus ?? [200, 204], diff --git a/e2e/fixtures/db.ts b/e2e/fixtures/db.ts index 6a001fc..1dafdb0 100644 --- a/e2e/fixtures/db.ts +++ b/e2e/fixtures/db.ts @@ -39,11 +39,16 @@ export const db = { async expireSession(userId: string) { await withClient((c) => - c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId]) + c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [ + userId, + ]) ); }, - async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') { + async setUploadCompressionStatus( + uploadId: string, + status: 'pending' | 'processing' | 'done' | 'failed' + ) { await withClient((c) => c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status]) ); @@ -113,7 +118,11 @@ export const db = { * Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the * event's CURRENT epoch so it counts as the live generation. */ - async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') { + async fakeExportJob( + eventSlug: string, + type: 'zip' | 'html', + status: 'pending' | 'running' | 'done' + ) { await withClient(async (c) => { const ev = await c.query<{ id: string; export_epoch: string }>( `SELECT id, export_epoch FROM event WHERE slug = $1`, diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts index 79b8ee7..0c4b93d 100644 --- a/e2e/fixtures/test.ts +++ b/e2e/fixtures/test.ts @@ -84,16 +84,13 @@ export const test = base.extend({ const fn = async (page: Page, handle: GuestHandle) => { // Visit any in-app URL first so localStorage is scoped to the right origin. await page.goto('/'); - await page.evaluate( - ({ jwt, pin, userId, displayName }) => { - localStorage.setItem('eventsnap_jwt', jwt); - localStorage.setItem('eventsnap_pin', pin); - localStorage.setItem('eventsnap_user_id', userId); - localStorage.setItem('eventsnap_display_name', displayName); - localStorage.setItem('eventsnap_guide_seen', 'true'); - }, - handle - ); + await page.evaluate(({ jwt, pin, userId, displayName }) => { + localStorage.setItem('eventsnap_jwt', jwt); + localStorage.setItem('eventsnap_pin', pin); + localStorage.setItem('eventsnap_user_id', userId); + localStorage.setItem('eventsnap_display_name', displayName); + localStorage.setItem('eventsnap_guide_seen', 'true'); + }, handle); await page.goto('/feed'); }; await use(fn); diff --git a/e2e/helpers/env.ts b/e2e/helpers/env.ts new file mode 100644 index 0000000..6ef7bc9 --- /dev/null +++ b/e2e/helpers/env.ts @@ -0,0 +1,5 @@ +/** + * Shared test environment constants. The frontend base URL was redeclared verbatim in ~23 specs; + * one source of truth means a port/scheme change is a single edit, not a sweep. + */ +export const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; diff --git a/e2e/helpers/seed.ts b/e2e/helpers/seed.ts index a243e2e..f233613 100644 --- a/e2e/helpers/seed.ts +++ b/e2e/helpers/seed.ts @@ -7,8 +7,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { uploadRaw } from './upload-client'; import { db } from '../fixtures/db'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from './env'; // A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the // backend when compression fails (a failed transcode refunds quota + deletes the diff --git a/e2e/helpers/sse-listener.ts b/e2e/helpers/sse-listener.ts index 0c6b0a6..5ad4f02 100644 --- a/e2e/helpers/sse-listener.ts +++ b/e2e/helpers/sse-listener.ts @@ -30,7 +30,8 @@ export class SseListener { const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - (async () => { + // Fire-and-forget reader loop; the caller drives lifecycle via stop()/the AbortController. + void (async () => { try { while (!this.closed) { const { done, value } = await reader.read(); @@ -61,7 +62,9 @@ export class SseListener { if (hit) return hit; await new Promise((r) => setTimeout(r, 50)); } - throw new Error(`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`); + throw new Error( + `SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}` + ); } allEvents(): SseEvent[] { diff --git a/e2e/helpers/sse.ts b/e2e/helpers/sse.ts index 4282f68..5bbc7c0 100644 --- a/e2e/helpers/sse.ts +++ b/e2e/helpers/sse.ts @@ -4,8 +4,7 @@ * it lives in one place instead of being re-inlined per spec. */ import type { Page } from '@playwright/test'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from './env'; /** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */ export async function mintSseTicket(jwt: string): Promise { @@ -13,7 +12,8 @@ export async function mintSseTicket(jwt: string): Promise { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, }); - if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`); + if (res.status !== 200) + throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`); return (await res.json()).ticket; } diff --git a/e2e/helpers/upload-client.ts b/e2e/helpers/upload-client.ts index 2c9228d..6fb535c 100644 --- a/e2e/helpers/upload-client.ts +++ b/e2e/helpers/upload-client.ts @@ -1,3 +1,4 @@ +import { BASE } from './env'; /** * Node-side multipart upload helper. Lets adversarial specs post arbitrary * bytes with arbitrary `Content-Type` claims to /api/v1/upload without @@ -11,8 +12,6 @@ */ // Node 22+ ships FormData and Blob as globals — no import needed. -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; - export type UploadOptions = { filename?: string; contentType?: string; @@ -20,7 +19,11 @@ export type UploadOptions = { hashtags?: string; }; -export async function uploadRaw(token: string, body: Uint8Array | Buffer, opts: UploadOptions = {}) { +export async function uploadRaw( + token: string, + body: Uint8Array | Buffer, + opts: UploadOptions = {} +) { const form = new FormData(); const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' }); form.append('file', blob as any, opts.filename ?? 'upload.bin'); @@ -41,7 +44,9 @@ export async function uploadFile(token: string, path: string, opts: UploadOption } /** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */ -export const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); +export const JPEG_MAGIC = new Uint8Array([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, +]); /** Tiny valid PNG magic bytes. */ export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */ diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 293cb6c..cf98947 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -8,11 +8,240 @@ "name": "eventsnap-e2e", "version": "0.1.0", "devDependencies": { + "@eslint/js": "^9.39.5", "@playwright/test": "^1.49.0", "@types/node": "^22.10.0", "@types/pg": "^8.11.10", + "eslint": "^9.39.5", + "eslint-config-prettier": "^9.1.2", + "globals": "^15.15.0", "pg": "^8.13.1", - "typescript": "^5.7.0" + "prettier": "^3.9.5", + "typescript": "^5.7.0", + "typescript-eslint": "^8.64.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@playwright/test": { @@ -31,6 +260,20 @@ "node": ">=18" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -53,6 +296,733 @@ "pg-types": "^2.2.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -68,13 +1038,316 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -166,6 +1439,19 @@ "split2": "^4.1.0" } }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -241,6 +1527,88 @@ "node": ">=0.10.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -251,6 +1619,75 @@ "node": ">= 10.x" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -265,6 +1702,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -272,6 +1733,42 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -281,6 +1778,19 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/e2e/package.json b/e2e/package.json index 501f217..7c2b791 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,13 +14,23 @@ "stack:up": "docker compose -f docker-compose.test.yml up -d --build", "stack:down": "docker compose -f docker-compose.test.yml down -v", "stack:logs": "docker compose -f docker-compose.test.yml logs -f", - "install:browsers": "playwright install --with-deps chromium firefox webkit" + "install:browsers": "playwright install --with-deps chromium firefox webkit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { + "@eslint/js": "^9.39.5", "@playwright/test": "^1.49.0", "@types/node": "^22.10.0", - "pg": "^8.13.1", "@types/pg": "^8.11.10", - "typescript": "^5.7.0" + "eslint": "^9.39.5", + "eslint-config-prettier": "^9.1.2", + "globals": "^15.15.0", + "pg": "^8.13.1", + "prettier": "^3.9.5", + "typescript": "^5.7.0", + "typescript-eslint": "^8.64.0" } } diff --git a/e2e/page-objects/host-dashboard.ts b/e2e/page-objects/host-dashboard.ts index 991e1e1..12e3eeb 100644 --- a/e2e/page-objects/host-dashboard.ts +++ b/e2e/page-objects/host-dashboard.ts @@ -11,8 +11,12 @@ export class HostDashboard { constructor(page: Page) { this.page = page; this.statsSection = page.locator('[data-testid="host-stats"]'); - this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first(); - this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first(); + this.lockEventButton = page + .getByRole('button', { name: /uploads sperren|event sperren|sperren/i }) + .first(); + this.unlockEventButton = page + .getByRole('button', { name: /uploads freigeben|entsperren/i }) + .first(); this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i }); this.userSearchInput = page.getByPlaceholder(/suche|search/i).first(); } @@ -22,14 +26,23 @@ export class HostDashboard { } userRow(displayName: string): Locator { - return this.page.locator('tr,li,div', { hasText: displayName }).filter({ has: this.page.getByRole('button') }).first(); + return this.page + .locator('tr,li,div', { hasText: displayName }) + .filter({ has: this.page.getByRole('button') }) + .first(); } async banUser(displayName: string) { // A ban always hides — the confirm dialog is a plain confirm, no "hide uploads" checkbox // (removed from the UI; the endpoint takes no body). const row = this.userRow(displayName); - await row.getByRole('button', { name: /sperren/i }).first().click(); - await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().click(); + await row + .getByRole('button', { name: /sperren/i }) + .first() + .click(); + await this.page + .getByRole('button', { name: /bestätigen|sperren/i }) + .last() + .click(); } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 382d32a..a39125b 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -21,7 +21,7 @@ const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboa export default defineConfig({ testDir: './specs', outputDir: './test-results', - fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel. + fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel. forbidOnly: !!process.env.CI, // NO RETRIES — not even in CI. This is deliberate and it is the opposite of the usual advice. // @@ -42,11 +42,8 @@ export default defineConfig({ // // A flake here is a bug report. Treat it as one. retries: 0, - workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+). - reporter: [ - ['list'], - ['html', { open: 'never', outputFolder: './playwright-report' }], - ], + workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+). + reporter: [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]], globalSetup: './global-setup.ts', globalTeardown: './global-teardown.ts', timeout: 60_000, @@ -104,7 +101,7 @@ export default defineConfig({ { name: 'chromium-galaxy-s22', use: { - ...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor. + ...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor. 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', @@ -160,8 +157,7 @@ export default defineConfig({ // 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', + userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0', }, grep: /@smoke/, }, diff --git a/e2e/specs/01-auth/admin-login.spec.ts b/e2e/specs/01-auth/admin-login.spec.ts index dc62e19..adb6d90 100644 --- a/e2e/specs/01-auth/admin-login.spec.ts +++ b/e2e/specs/01-auth/admin-login.spec.ts @@ -26,7 +26,7 @@ test.describe('Auth — admin login', () => { }; return { sessionRole: decodeRole(sessionStorage.getItem('eventsnap_jwt')), - localToken: localStorage.getItem('eventsnap_jwt') + localToken: localStorage.getItem('eventsnap_jwt'), }; }); expect(stores.sessionRole).toBe('admin'); diff --git a/e2e/specs/01-auth/back-chevron.spec.ts b/e2e/specs/01-auth/back-chevron.spec.ts index 20d6d75..e685582 100644 --- a/e2e/specs/01-auth/back-chevron.spec.ts +++ b/e2e/specs/01-auth/back-chevron.spec.ts @@ -6,7 +6,9 @@ import { test, expect } from '../../fixtures/test'; test.describe('Navigation — back chevrons', () => { - test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => { + test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ + page, + }) => { await page.goto('/recover'); const back = page.getByTestId('recover-back'); await expect(back).toBeVisible(); @@ -15,7 +17,11 @@ test.describe('Navigation — back chevrons', () => { await page.waitForURL(/\/(join|feed)$/); }); - test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => { + test('/export back chevron returns the authenticated guest to /feed', async ({ + page, + guest, + signIn, + }) => { const g = await guest('ExportBack'); await signIn(page, g); await page.goto('/export'); diff --git a/e2e/specs/01-auth/join.spec.ts b/e2e/specs/01-auth/join.spec.ts index 020bef5..58a2940 100644 --- a/e2e/specs/01-auth/join.spec.ts +++ b/e2e/specs/01-auth/join.spec.ts @@ -41,7 +41,10 @@ test.describe('Auth — join flow', () => { await page.waitForURL('**/feed', { timeout: 5_000 }); }); - test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => { + test('returning guest, new device: same name shows the inline recovery form', async ({ + page, + guest, + }) => { const original = await guest('Charlie'); // Brand-new browser context (cleared storage) — landing on /join with same name diff --git a/e2e/specs/01-auth/leave-event.spec.ts b/e2e/specs/01-auth/leave-event.spec.ts index 625ade4..0e78233 100644 --- a/e2e/specs/01-auth/leave-event.spec.ts +++ b/e2e/specs/01-auth/leave-event.spec.ts @@ -7,7 +7,11 @@ import { AccountPage } from '../../page-objects'; import { readStorage } from '../../helpers/storage-helpers'; test.describe('Auth — leave event', () => { - test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => { + test('leave event clears localStorage and redirects to /join', async ({ + page, + guest, + signIn, + }) => { const h = await guest('Jens'); await signIn(page, h); diff --git a/e2e/specs/01-auth/logout-everywhere.spec.ts b/e2e/specs/01-auth/logout-everywhere.spec.ts index 9451c00..996d128 100644 --- a/e2e/specs/01-auth/logout-everywhere.spec.ts +++ b/e2e/specs/01-auth/logout-everywhere.spec.ts @@ -5,14 +5,13 @@ * revoked token must stop authenticating. */ import { test, expect } from '../../fixtures/test'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; const ctx = (jwt: string) => fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } }); test.describe('Auth — sign out everywhere', () => { - test('DELETE /sessions revokes ALL of the caller\'s sessions, not just the current one', async ({ + test("DELETE /sessions revokes ALL of the caller's sessions, not just the current one", async ({ api, guest, db, @@ -41,7 +40,7 @@ test.describe('Auth — sign out everywhere', () => { expect(await db.countSessionsForUser(g.userId)).toBe(0); }); - test('one user signing out everywhere does not touch another user\'s sessions', async ({ + test("one user signing out everywhere does not touch another user's sessions", async ({ guest, }) => { const a = await guest('SignsOut'); diff --git a/e2e/specs/01-auth/pin-auto-submit.spec.ts b/e2e/specs/01-auth/pin-auto-submit.spec.ts index b8390d6..ed168d4 100644 --- a/e2e/specs/01-auth/pin-auto-submit.spec.ts +++ b/e2e/specs/01-auth/pin-auto-submit.spec.ts @@ -13,7 +13,10 @@ import { JoinPage, RecoverPage } from '../../page-objects'; import { clearAllStorage } from '../../helpers/storage-helpers'; test.describe('Auth — PIN auto-submit', () => { - test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({ page, guest }) => { + test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({ + page, + guest, + }) => { const original = await guest('AutoInline'); await clearAllStorage(page); @@ -30,7 +33,10 @@ test.describe('Auth — PIN auto-submit', () => { await page.waitForURL('**/feed', { timeout: 5_000 }); }); - test('/recover: 4th digit auto-submits when the name is already filled in', async ({ page, guest }) => { + test('/recover: 4th digit auto-submits when the name is already filled in', async ({ + page, + guest, + }) => { const original = await guest('AutoRecover'); await clearAllStorage(page); diff --git a/e2e/specs/01-auth/pin-reset-lifecycle.spec.ts b/e2e/specs/01-auth/pin-reset-lifecycle.spec.ts index 7579a06..1a26d6e 100644 --- a/e2e/specs/01-auth/pin-reset-lifecycle.spec.ts +++ b/e2e/specs/01-auth/pin-reset-lifecycle.spec.ts @@ -9,8 +9,7 @@ * - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out) */ import { test, expect } from '../../fixtures/test'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; const requestReset = (displayName: string) => fetch(`${BASE}/api/v1/recover/request`, { @@ -59,9 +58,8 @@ test.describe('PIN reset — in-app request lifecycle', () => { api, host, db, - adminToken, }) => { - // The admin user exists (adminToken logged them in). Their display name is "Admin" by + // The admin user exists (the host fixture logs an admin in to create it). Its display name is "Admin" by // convention; request a reset for it and confirm the queue stays empty — the INSERT filters // `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak. const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin'); diff --git a/e2e/specs/02-upload/gallery-path.spec.ts b/e2e/specs/02-upload/gallery-path.spec.ts index f402a02..3eaa5d8 100644 --- a/e2e/specs/02-upload/gallery-path.spec.ts +++ b/e2e/specs/02-upload/gallery-path.spec.ts @@ -31,14 +31,25 @@ test.describe('Upload — gallery path', () => { const h = await guest('Uploader2'); const { uploadRaw } = await import('../../helpers/upload-client'); const { readFileSync } = await import('node:fs'); - const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { filename: 'a.jpg', contentType: 'image/jpeg' }); - const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { filename: 'b.jpg', contentType: 'image/jpeg' }); + const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { + filename: 'a.jpg', + contentType: 'image/jpeg', + }); + const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { + filename: 'b.jpg', + contentType: 'image/jpeg', + }); expect(r1.status).toBe(201); expect(r2.status).toBe(201); await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2); }); - test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => { + test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ + page, + guest, + signIn, + db, + }) => { // Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a // navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2 // `upgrade` callback opened a *new* transaction, which throws during a @@ -74,13 +85,20 @@ test.describe('Upload — gallery path', () => { const { id } = await res.json(); // Wait up to 10s for the compression worker's SSE. - const evt = await sse.waitForEvent('upload-processed', (e) => { - const data = typeof e.data === 'string' ? safeJson(e.data) : e.data; - return data?.upload_id === id || data?.id === id; - }, 10_000).catch(() => null); + const evt = await sse + .waitForEvent( + 'upload-processed', + (e) => { + const data = typeof e.data === 'string' ? safeJson(e.data) : e.data; + return data?.upload_id === id || data?.id === id; + }, + 10_000 + ) + .catch(() => null); // If the SSE didn't arrive in 10s (slow CI, debug mode), at least we know the upload was accepted. - if (!evt) console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id); + if (!evt) + console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id); } finally { sse.stop(); } @@ -88,5 +106,9 @@ test.describe('Upload — gallery path', () => { }); function safeJson(s: string) { - try { return JSON.parse(s); } catch { return s; } + try { + return JSON.parse(s); + } catch { + return s; + } } diff --git a/e2e/specs/02-upload/quota.spec.ts b/e2e/specs/02-upload/quota.spec.ts index 0c0d12a..f054d7b 100644 --- a/e2e/specs/02-upload/quota.spec.ts +++ b/e2e/specs/02-upload/quota.spec.ts @@ -26,8 +26,8 @@ import { test, expect } from '../../fixtures/test'; import { join } from 'node:path'; import { readFileSync } from 'node:fs'; import { uploadPausedMidStream } from '../../helpers/upload-client'; +import { BASE } from '../../helpers/env'; -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')); const SIZE = SAMPLE_BYTES.byteLength; @@ -64,7 +64,10 @@ async function setLimitTo( targetBytes: number ): Promise { const q = await quotaOf(jwt); - expect(q.free_disk_bytes, 'the disk must be readable, else quota fails OPEN and proves nothing').toBeTruthy(); + expect( + q.free_disk_bytes, + 'the disk must be readable, else quota fails OPEN and proves nothing' + ).toBeTruthy(); const active = Math.max(q.active_uploaders, 1); const tolerance = (targetBytes * active) / (q.free_disk_bytes as number); await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) }); @@ -98,7 +101,9 @@ test.describe('Upload — storage quota enforcement', () => { // Nothing was accounted, and nothing reached the feed. expect((await quotaOf(g.jwt)).used_bytes).toBe(0); - const feed = await (await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })).json(); + const feed = await ( + await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } }) + ).json(); expect(feed.uploads).toHaveLength(0); }); diff --git a/e2e/specs/02-upload/rate-limit.spec.ts b/e2e/specs/02-upload/rate-limit.spec.ts index 44efacb..2a5b5c2 100644 --- a/e2e/specs/02-upload/rate-limit.spec.ts +++ b/e2e/specs/02-upload/rate-limit.spec.ts @@ -14,7 +14,11 @@ const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); 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 }) => { + test('4th upload in one hour returns 429 with Retry-After', async ({ + api, + adminToken, + guest, + }) => { // Enable inside the test body, AFTER all auto-fixtures (truncate) have run, // so the config can't be reset out from under us by the ordering of hooks // vs fixtures. @@ -53,7 +57,11 @@ test.describe('Upload — rate limit', () => { expect(limited.headers.get('retry-after')).toBeTruthy(); }); - test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => { + test('flipping upload_rate_enabled off bypasses the limit', async ({ + api, + adminToken, + guest, + }) => { await api.patchConfig(adminToken, { upload_rate_enabled: 'false' }); const h = await guest('NoQuota'); diff --git a/e2e/specs/03-feed/comment-ui.spec.ts b/e2e/specs/03-feed/comment-ui.spec.ts index f0d118e..2834488 100644 --- a/e2e/specs/03-feed/comment-ui.spec.ts +++ b/e2e/specs/03-feed/comment-ui.spec.ts @@ -7,8 +7,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('Comments — UI round-trip (CR1)', () => { test('a comment typed in the lightbox persists to the backend', async ({ diff --git a/e2e/specs/03-feed/like-comment.spec.ts b/e2e/specs/03-feed/like-comment.spec.ts index ddaba95..85ba8d8 100644 --- a/e2e/specs/03-feed/like-comment.spec.ts +++ b/e2e/specs/03-feed/like-comment.spec.ts @@ -8,8 +8,7 @@ import { test, expect } from '../../fixtures/test'; import { SseListener } from '../../helpers/sse-listener'; import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; async function like(jwt: string, uploadId: string): Promise { const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, { @@ -20,7 +19,10 @@ async function like(jwt: string, uploadId: string): Promise { } test.describe('Feed — like + comment', () => { - test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => { + test('a like counts once per user and toggles off on repeat (no double-count)', async ({ + api, + guest, + }) => { const author = await guest('Author'); const liker = await guest('Liker'); const uploadId = await seedUpload(author.jwt); diff --git a/e2e/specs/03-feed/sse-realtime.spec.ts b/e2e/specs/03-feed/sse-realtime.spec.ts index 96ee910..41dea70 100644 --- a/e2e/specs/03-feed/sse-realtime.spec.ts +++ b/e2e/specs/03-feed/sse-realtime.spec.ts @@ -11,7 +11,11 @@ import { test, expect } from '../../fixtures/test'; import { trackStreamOpens } from '../../helpers/sse'; test.describe('Feed — SSE behavior', () => { - test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => { + test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ + page, + guest, + signIn, + }) => { const g = await guest('SseReconnect'); const streamOpens = trackStreamOpens(page); @@ -25,7 +29,10 @@ test.describe('Feed — SSE behavior', () => { // new stream opens while hidden. await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); - Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); document.dispatchEvent(new Event('visibilitychange')); }); // Snapshot the count AFTER backgrounding — this baselines out the initial open (and @@ -36,7 +43,10 @@ test.describe('Feed — SSE behavior', () => { // Foreground again → connectSse() mints a new ticket and opens a new EventSource. await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); - Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); document.dispatchEvent(new Event('visibilitychange')); }); diff --git a/e2e/specs/03-feed/toast-on-failure.spec.ts b/e2e/specs/03-feed/toast-on-failure.spec.ts index 2228616..2ed0c0a 100644 --- a/e2e/specs/03-feed/toast-on-failure.spec.ts +++ b/e2e/specs/03-feed/toast-on-failure.spec.ts @@ -33,7 +33,10 @@ test.describe('Feed — error toast on user action failures', () => { route.fulfill({ status: 429, contentType: 'application/json', - body: JSON.stringify({ error: 'rate_limited', message: 'Zu viele Anfragen — bitte kurz warten.' }), + body: JSON.stringify({ + error: 'rate_limited', + message: 'Zu viele Anfragen — bitte kurz warten.', + }), }) ); diff --git a/e2e/specs/04-host/event-lock.spec.ts b/e2e/specs/04-host/event-lock.spec.ts index d0f2781..eb6310d 100644 --- a/e2e/specs/04-host/event-lock.spec.ts +++ b/e2e/specs/04-host/event-lock.spec.ts @@ -5,13 +5,16 @@ * for a guest who's already viewing the feed. */ import { test, expect } from '../../fixtures/test'; +import { BASE } from '../../helpers/env'; test.describe('Host — event lock', () => { - test('closing the event via API sets uploads_locked_at; opening clears it', async ({ host, api }) => { + test('closing the event via API sets uploads_locked_at; opening clears it', async ({ + host, + api, + }) => { // The frontend doesn't (yet) render a per-guest "uploads locked" banner on // the feed — that's the journey §9 banner, currently a UX gap. We assert // the API + DB contract here and leave the banner check for once it ships. - const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; await api.closeEvent(host.jwt); const evRes = await fetch(`${BASE}/api/v1/host/event`, { @@ -38,8 +41,11 @@ test.describe('Host — event lock', () => { // event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are // rejected. (An earlier revision froze social interaction too; that contradicted // the documented behavior and was reverted.) - test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => { - const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; + test('a closed event still allows likes and comments, but blocks new uploads', async ({ + api, + host, + guest, + }) => { const g = await guest('SocialLocked'); // Upload while still open so there's a target to interact with. diff --git a/e2e/specs/04-host/moderation.spec.ts b/e2e/specs/04-host/moderation.spec.ts index 6373c67..d018b6f 100644 --- a/e2e/specs/04-host/moderation.spec.ts +++ b/e2e/specs/04-host/moderation.spec.ts @@ -7,7 +7,11 @@ import { test, expect } from '../../fixtures/test'; import { seedUpload, seedComment } from '../../helpers/seed'; test.describe('Host — moderation API', () => { - test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({ api, host, guest }) => { + test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({ + api, + host, + guest, + }) => { // Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint // takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone). const target = await guest('Banned2'); @@ -23,11 +27,14 @@ test.describe('Host — moderation API', () => { await api.banUser(host.jwt, target.userId); // Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing. - const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', { - method: 'POST', - headers: { Authorization: `Bearer ${target.jwt}` }, - body: new FormData(), - }); + const res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', + { + method: 'POST', + headers: { Authorization: `Bearer ${target.jwt}` }, + body: new FormData(), + } + ); expect(res.status).toBe(403); }); @@ -87,15 +94,20 @@ test.describe('Host — live role/ban revocation (H1)', () => { test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => { await api.banUser(adminToken, host.userId); - await expect( - api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] }) - ).rejects.toThrow(/→ 403/); + await expect(api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })).rejects.toThrow( + /→ 403/ + ); }); // H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is // rejected with 403. The ban is enforced on write handlers + Require{Host,Admin}, // NOT in the base extractor (which would wrongly block reads too). - test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => { + test('a banned user keeps read access but is blocked from writes', async ({ + api, + host, + guest, + db, + }) => { const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const target = await guest('BannedRW'); const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); diff --git a/e2e/specs/04-host/sse-eviction.spec.ts b/e2e/specs/04-host/sse-eviction.spec.ts index c5eccfd..ae23619 100644 --- a/e2e/specs/04-host/sse-eviction.spec.ts +++ b/e2e/specs/04-host/sse-eviction.spec.ts @@ -7,8 +7,7 @@ import { test, expect } from '../../fixtures/test'; import { seedUpload } from '../../helpers/seed'; import { SseListener } from '../../helpers/sse-listener'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; test.describe('Host — live SSE eviction (H3)', () => { test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => { @@ -24,17 +23,10 @@ test.describe('Host — live SSE eviction (H3)', () => { }); expect(res.status).toBe(204); - await sse.waitForEvent( - 'upload-deleted', - (e) => e.data.upload_id === uploadId - ); + await sse.waitForEvent('upload-deleted', (e) => e.data.upload_id === uploadId); }); - test('banning a user broadcasts user-hidden', async ({ - api, - host, - guest, - }) => { + test('banning a user broadcasts user-hidden', async ({ api, host, guest }) => { const target = await guest('HideTarget'); await seedUpload(target.jwt, { caption: 'should vanish' }); @@ -43,10 +35,7 @@ test.describe('Host — live SSE eviction (H3)', () => { await api.banUser(host.jwt, target.userId); - await sse.waitForEvent( - 'user-hidden', - (e) => e.data.user_id === target.userId - ); + await sse.waitForEvent('user-hidden', (e) => e.data.user_id === target.userId); }); // Frontend regression: the broadcasts above are inert if the client never diff --git a/e2e/specs/05-admin/authorization.spec.ts b/e2e/specs/05-admin/authorization.spec.ts index df17da3..135c3b6 100644 --- a/e2e/specs/05-admin/authorization.spec.ts +++ b/e2e/specs/05-admin/authorization.spec.ts @@ -4,8 +4,7 @@ * guest can't hit host/admin; host can't hit admin. */ import { test, expect } from '../../fixtures/test'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; test.describe('Authorization escalation guards', () => { test('guest → POST /host/event/close returns 403', async ({ guest }) => { diff --git a/e2e/specs/05-admin/authz-sweep.spec.ts b/e2e/specs/05-admin/authz-sweep.spec.ts index ac6bcef..987bacf 100644 --- a/e2e/specs/05-admin/authz-sweep.spec.ts +++ b/e2e/specs/05-admin/authz-sweep.spec.ts @@ -19,8 +19,7 @@ * still download the keepsake) is BY DESIGN, and is asserted in 07-adversarial/authorization-deep. */ import { test, expect } from '../../fixtures/test'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE'; interface Route { @@ -37,23 +36,66 @@ const ROUTES: Route[] = [ { method: 'GET', path: () => '/api/v1/host/event', name: 'GET /host/event' }, { method: 'POST', path: () => '/api/v1/host/event/close', name: 'POST /host/event/close' }, { method: 'POST', path: () => '/api/v1/host/event/open', name: 'POST /host/event/open' }, - { method: 'POST', path: () => '/api/v1/host/gallery/release', name: 'POST /host/gallery/release' }, + { + method: 'POST', + path: () => '/api/v1/host/gallery/release', + name: 'POST /host/gallery/release', + }, { method: 'POST', path: () => '/api/v1/host/export/rebuild', name: 'POST /host/export/rebuild' }, { method: 'GET', path: () => '/api/v1/host/users', name: 'GET /host/users' }, - { method: 'POST', path: (v) => `/api/v1/host/users/${v}/ban`, name: 'POST /host/users/{id}/ban', body: {} }, - { method: 'POST', path: (v) => `/api/v1/host/users/${v}/unban`, name: 'POST /host/users/{id}/unban', body: {} }, - { method: 'PATCH', path: (v) => `/api/v1/host/users/${v}/role`, name: 'PATCH /host/users/{id}/role', body: { role: 'host' } }, - { method: 'POST', path: (v) => `/api/v1/host/users/${v}/pin-reset`, name: 'POST /host/users/{id}/pin-reset', body: {} }, - { method: 'GET', path: () => '/api/v1/host/pin-reset-requests', name: 'GET /host/pin-reset-requests' }, - { method: 'DELETE', path: (v) => `/api/v1/host/pin-reset-requests/${v}`, name: 'DELETE /host/pin-reset-requests/{id}' }, + { + method: 'POST', + path: (v) => `/api/v1/host/users/${v}/ban`, + name: 'POST /host/users/{id}/ban', + body: {}, + }, + { + method: 'POST', + path: (v) => `/api/v1/host/users/${v}/unban`, + name: 'POST /host/users/{id}/unban', + body: {}, + }, + { + method: 'PATCH', + path: (v) => `/api/v1/host/users/${v}/role`, + name: 'PATCH /host/users/{id}/role', + body: { role: 'host' }, + }, + { + method: 'POST', + path: (v) => `/api/v1/host/users/${v}/pin-reset`, + name: 'POST /host/users/{id}/pin-reset', + body: {}, + }, + { + method: 'GET', + path: () => '/api/v1/host/pin-reset-requests', + name: 'GET /host/pin-reset-requests', + }, + { + method: 'DELETE', + path: (v) => `/api/v1/host/pin-reset-requests/${v}`, + name: 'DELETE /host/pin-reset-requests/{id}', + }, { method: 'DELETE', path: (v) => `/api/v1/host/upload/${v}`, name: 'DELETE /host/upload/{id}' }, { method: 'DELETE', path: (v) => `/api/v1/host/comment/${v}`, name: 'DELETE /host/comment/{id}' }, // ── Admin surface ─────────────────────────────────────────────────────── { method: 'GET', path: () => '/api/v1/admin/stats', name: 'GET /admin/stats', adminOnly: true }, { method: 'GET', path: () => '/api/v1/admin/config', name: 'GET /admin/config', adminOnly: true }, - { method: 'PATCH', path: () => '/api/v1/admin/config', name: 'PATCH /admin/config', adminOnly: true, body: { quota_enabled: 'false' } }, - { method: 'GET', path: () => '/api/v1/admin/export/jobs', name: 'GET /admin/export/jobs', adminOnly: true }, + { + method: 'PATCH', + path: () => '/api/v1/admin/config', + name: 'PATCH /admin/config', + adminOnly: true, + body: { quota_enabled: 'false' }, + }, + { + method: 'GET', + path: () => '/api/v1/admin/export/jobs', + name: 'GET /admin/export/jobs', + adminOnly: true, + }, ]; async function call(route: Route, jwt: string, victimId: string): Promise { @@ -89,7 +131,10 @@ test.describe('Authorization sweep — every privileged route', () => { const status = await call(route, host.jwt, victim.userId); - expect(status, `${route.name} is admin-only and must reject a host with 403, got ${status}`).toBe(403); + expect( + status, + `${route.name} is admin-only and must reject a host with 403, got ${status}` + ).toBe(403); }); } @@ -121,9 +166,10 @@ test.describe('Authorization sweep — every privileged route', () => { headers: route.body !== undefined ? { 'Content-Type': 'application/json' } : {}, ...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}), }); - expect([401, 403], `${route.name} must reject an anonymous caller, got ${res.status}`).toContain( - res.status - ); + expect( + [401, 403], + `${route.name} must reject an anonymous caller, got ${res.status}` + ).toContain(res.status); } }); }); diff --git a/e2e/specs/05-admin/config.spec.ts b/e2e/specs/05-admin/config.spec.ts index 92c7f71..4119c7e 100644 --- a/e2e/specs/05-admin/config.spec.ts +++ b/e2e/specs/05-admin/config.spec.ts @@ -13,21 +13,27 @@ test.describe('Admin — config API', () => { await api.patchConfig(adminToken, { max_image_size_mb: '20' }); }); - test('non-numeric value for a numeric key is rejected', async ({ api, adminToken }) => { - const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', { - method: 'PATCH', - headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ max_image_size_mb: 'not-a-number' }), - }); + test('non-numeric value for a numeric key is rejected', async ({ adminToken }) => { + const res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', + { + method: 'PATCH', + headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ max_image_size_mb: 'not-a-number' }), + } + ); expect(res.status).toBe(400); }); test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => { - const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', { - method: 'PATCH', - headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ totally_fake_key: '1' }), - }); + const res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', + { + method: 'PATCH', + headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ totally_fake_key: '1' }), + } + ); expect(res.status).toBe(400); }); @@ -40,16 +46,23 @@ test.describe('Admin — config API', () => { cfg = await api.getConfig(adminToken); expect(cfg.upload_rate_enabled).toBe('false'); - const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', { - method: 'PATCH', - headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ upload_rate_enabled: 'maybe' }), - }); + const res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', + { + method: 'PATCH', + headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ upload_rate_enabled: 'maybe' }), + } + ); expect(res.status).toBe(400); }); - test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ api, adminToken }) => { - const note = ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host'; + test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ + api, + adminToken, + }) => { + const note = + ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host'; await api.patchConfig(adminToken, { privacy_note: note }); const cfg = await api.getConfig(adminToken); expect(cfg.privacy_note).toBe(note); @@ -58,7 +71,11 @@ test.describe('Admin — config API', () => { }); test.describe('Admin — stats', () => { - test('GET /admin/stats returns matching counts after seeding users', async ({ api, adminToken, guest }) => { + test('GET /admin/stats returns matching counts after seeding users', async ({ + api, + adminToken, + guest, + }) => { await guest('Stat1'); await guest('Stat2'); await guest('Stat3'); diff --git a/e2e/specs/06-export/export-leak.spec.ts b/e2e/specs/06-export/export-leak.spec.ts index 4515d2a..fadb8e3 100644 --- a/e2e/specs/06-export/export-leak.spec.ts +++ b/e2e/specs/06-export/export-leak.spec.ts @@ -12,8 +12,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('Export — no public leak (CR2)', () => { test('a real export is downloadable only via the gated endpoint, never via /media', async ({ @@ -26,7 +25,10 @@ test.describe('Export — no public leak (CR2)', () => { await seedUpload(host.jwt, { caption: 'in the export' }); // Host releases the gallery → spawns the real zip/html export jobs. - const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }); + const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { + method: 'POST', + headers: bearer, + }); expect(rel.status).toBe(204); // Wait for the real zip job to finish writing the archive to disk. @@ -49,7 +51,10 @@ test.describe('Export — no public leak (CR2)', () => { // …but IS retrievable via the gated single-use ticket endpoint. This proves the // 404 above means "not public", not merely "no file was produced". - const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer }); + const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { + method: 'POST', + headers: bearer, + }); const { ticket } = await ticketRes.json(); const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`); expect(dl.status).toBe(200); diff --git a/e2e/specs/06-export/export-video.spec.ts b/e2e/specs/06-export/export-video.spec.ts index 615cb93..ca406b1 100644 --- a/e2e/specs/06-export/export-video.spec.ts +++ b/e2e/specs/06-export/export-video.spec.ts @@ -14,8 +14,7 @@ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; - -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +import { BASE } from '../../helpers/env'; test.describe('Export — video streaming (P4)', () => { test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => { @@ -29,7 +28,10 @@ test.describe('Export — video streaming (P4)', () => { const { id } = await up.json(); // Release the gallery → spawns the real zip + html export jobs. - const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }); + const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { + method: 'POST', + headers: bearer, + }); expect(rel.status).toBe(204); // Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed. @@ -44,7 +46,10 @@ test.describe('Export — video streaming (P4)', () => { .toBe('done'); // Download Memories.zip via the gated single-use ticket. - const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer }); + const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { + method: 'POST', + headers: bearer, + }); const { ticket } = await ticketRes.json(); const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`); expect(dl.status).toBe(200); diff --git a/e2e/specs/06-export/export.spec.ts b/e2e/specs/06-export/export.spec.ts index 44b71b7..8c42c65 100644 --- a/e2e/specs/06-export/export.spec.ts +++ b/e2e/specs/06-export/export.spec.ts @@ -11,7 +11,11 @@ import { ExportPage } from '../../page-objects'; const SLUG = 'e2e-test-event'; test.describe('Export — release and download', () => { - test('/export shows the "not yet available" state before release', async ({ page, guest, signIn }) => { + test('/export shows the "not yet available" state before release', async ({ + page, + guest, + signIn, + }) => { const g = await guest('PreRelease'); await signIn(page, g); const exportPage = new ExportPage(page); @@ -55,9 +59,12 @@ test.describe('Export — release and download', () => { test('export status API reflects released flag', async ({ guest, db }) => { const g = await guest('ReleaseQuery'); - let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', { - headers: { Authorization: `Bearer ${g.jwt}` }, - }); + let res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', + { + headers: { Authorization: `Bearer ${g.jwt}` }, + } + ); let body: any = await res.json(); expect(body.released).toBe(false); @@ -65,9 +72,12 @@ test.describe('Export — release and download', () => { await db.fakeExportJob(SLUG, 'zip', 'done'); await db.fakeExportJob(SLUG, 'html', 'done'); - res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', { - headers: { Authorization: `Bearer ${g.jwt}` }, - }); + res = await fetch( + (process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', + { + headers: { Authorization: `Bearer ${g.jwt}` }, + } + ); body = await res.json(); expect(body.released).toBe(true); expect(body.zip.status).toBe('done'); diff --git a/e2e/specs/07-adversarial/auth-tampering.spec.ts b/e2e/specs/07-adversarial/auth-tampering.spec.ts index eab23ae..69e7f05 100644 --- a/e2e/specs/07-adversarial/auth-tampering.spec.ts +++ b/e2e/specs/07-adversarial/auth-tampering.spec.ts @@ -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); }); }); diff --git a/e2e/specs/07-adversarial/authorization-deep.spec.ts b/e2e/specs/07-adversarial/authorization-deep.spec.ts index d17f579..760020b 100644 --- a/e2e/specs/07-adversarial/authorization-deep.spec.ts +++ b/e2e/specs/07-adversarial/authorization-deep.spec.ts @@ -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'); diff --git a/e2e/specs/07-adversarial/ddos.spec.ts b/e2e/specs/07-adversarial/ddos.spec.ts index 1b0c57b..18fc752 100644 --- a/e2e/specs/07-adversarial/ddos.spec.ts +++ b/e2e/specs/07-adversarial/ddos.spec.ts @@ -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). diff --git a/e2e/specs/07-adversarial/file-upload-attacks.spec.ts b/e2e/specs/07-adversarial/file-upload-attacks.spec.ts index 99b5607..027d4aa 100644 Binary files a/e2e/specs/07-adversarial/file-upload-attacks.spec.ts and b/e2e/specs/07-adversarial/file-upload-attacks.spec.ts differ diff --git a/e2e/specs/07-adversarial/media-gating.spec.ts b/e2e/specs/07-adversarial/media-gating.spec.ts index 47b2f04..29f1f16 100644 --- a/e2e/specs/07-adversarial/media-gating.spec.ts +++ b/e2e/specs/07-adversarial/media-gating.spec.ts @@ -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 ({ diff --git a/e2e/specs/07-adversarial/set-role-host-demote.spec.ts b/e2e/specs/07-adversarial/set-role-host-demote.spec.ts index b6f9422..af1e430 100644 --- a/e2e/specs/07-adversarial/set-role-host-demote.spec.ts +++ b/e2e/specs/07-adversarial/set-role-host-demote.spec.ts @@ -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`, { diff --git a/e2e/specs/07-adversarial/sse-ticket-abuse.spec.ts b/e2e/specs/07-adversarial/sse-ticket-abuse.spec.ts index d9ad5ef..0e13225 100644 --- a/e2e/specs/07-adversarial/sse-ticket-abuse.spec.ts +++ b/e2e/specs/07-adversarial/sse-ticket-abuse.spec.ts @@ -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 () => { diff --git a/e2e/specs/07-adversarial/ui-rendering.spec.ts b/e2e/specs/07-adversarial/ui-rendering.spec.ts index 9674374..199f271 100644 --- a/e2e/specs/07-adversarial/ui-rendering.spec.ts +++ b/e2e/specs/07-adversarial/ui-rendering.spec.ts @@ -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 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. diff --git a/e2e/specs/07-adversarial/xss-injection.spec.ts b/e2e/specs/07-adversarial/xss-injection.spec.ts index cf0ba49..7ad24f5 100644 --- a/e2e/specs/07-adversarial/xss-injection.spec.ts +++ b/e2e/specs/07-adversarial/xss-injection.spec.ts @@ -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 = [ - ``, - `">`, - ]; + const COMMENT_PAYLOADS = [``, `">`]; 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 = `Alice‮eciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE const r = await api.join(rtlName); expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/); diff --git a/e2e/specs/08-browser-chaos/environment.spec.ts b/e2e/specs/08-browser-chaos/environment.spec.ts index 7a1b3d7..c6993af 100644 --- a/e2e/specs/08-browser-chaos/environment.spec.ts +++ b/e2e/specs/08-browser-chaos/environment.spec.ts @@ -16,7 +16,11 @@ test.describe('Browser chaos — environment', () => { await ctx.close(); }); - test('localStorage quota exhausted — writing JWT does not crash the app', async ({ page, guest, signIn }) => { + test('localStorage quota exhausted — writing JWT does not crash the app', async ({ + page, + guest, + signIn, + }) => { const g = await guest('QuotaFull'); // Pre-fill localStorage with junk to push us near the quota. @@ -46,7 +50,11 @@ test.describe('Browser chaos — environment', () => { expect(errors.filter((e) => !/storage|quota/i.test(e.message))).toHaveLength(0); }); - test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({ page, guest, signIn }) => { + test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({ + page, + guest, + signIn, + }) => { const g = await guest('HostileCss'); await signIn(page, g); await page.goto('/feed'); @@ -59,7 +67,11 @@ test.describe('Browser chaos — environment', () => { await expect(page).toHaveURL(/\/account$/); }); - test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({ page, guest, signIn }) => { + test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('ClockSkew'); // Override Date.now() to be 1h in the future BEFORE the JWT check. @@ -74,7 +86,11 @@ test.describe('Browser chaos — environment', () => { await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible(); }); - test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({ page, guest, signIn }) => { + test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({ + page, + guest, + signIn, + }) => { const g = await guest('ClockBack'); await page.addInitScript(() => { diff --git a/e2e/specs/08-browser-chaos/indexeddb.spec.ts b/e2e/specs/08-browser-chaos/indexeddb.spec.ts index 83bc9cb..dbdcdf6 100644 --- a/e2e/specs/08-browser-chaos/indexeddb.spec.ts +++ b/e2e/specs/08-browser-chaos/indexeddb.spec.ts @@ -7,7 +7,11 @@ import { test, expect } from '../../fixtures/test'; test.describe('Browser chaos — IndexedDB', () => { - test('IndexedDB cleared mid-session does not break navigation', async ({ page, guest, signIn }) => { + test('IndexedDB cleared mid-session does not break navigation', async ({ + page, + guest, + signIn, + }) => { const g = await guest('IdbPurge'); await signIn(page, g); await page.goto('/feed'); @@ -16,11 +20,12 @@ test.describe('Browser chaos — IndexedDB', () => { // Drop every IndexedDB database the app might use. const dbs = (await (indexedDB as any).databases?.()) ?? []; await Promise.all( - dbs.map(({ name }: { name: string }) => - new Promise((resolve) => { - const req = indexedDB.deleteDatabase(name); - req.onsuccess = req.onerror = req.onblocked = () => resolve(); - }) + dbs.map( + ({ name }: { name: string }) => + new Promise((resolve) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = req.onerror = req.onblocked = () => resolve(); + }) ) ); }); diff --git a/e2e/specs/08-browser-chaos/multi-tab.spec.ts b/e2e/specs/08-browser-chaos/multi-tab.spec.ts index 7b4702b..bf23e57 100644 --- a/e2e/specs/08-browser-chaos/multi-tab.spec.ts +++ b/e2e/specs/08-browser-chaos/multi-tab.spec.ts @@ -6,7 +6,12 @@ import { test, expect } from '../../fixtures/test'; import { trackStreamOpens } from '../../helpers/sse'; test.describe('Browser chaos — multi-tab', () => { - test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => { + test('same user in two tabs — each tab establishes its own SSE stream', async ({ + page, + context, + guest, + signIn, + }) => { const g = await guest('Twin'); // Count each tab's own EventSource open. Asserting *connection establishment* is @@ -27,7 +32,10 @@ test.describe('Browser chaos — multi-tab', () => { await tab2.close(); }); - test('two different users in separate browser contexts have isolated localStorage', async ({ browser, guest }) => { + test('two different users in separate browser contexts have isolated localStorage', async ({ + browser, + guest, + }) => { const a = await guest('IsoA'); const b = await guest('IsoB'); @@ -37,20 +45,26 @@ test.describe('Browser chaos — multi-tab', () => { const pageB = await ctxB.newPage(); await pageA.goto('http://localhost:3101/'); - await pageA.evaluate(({ jwt, pin, userId, name }) => { - localStorage.setItem('eventsnap_jwt', jwt); - localStorage.setItem('eventsnap_pin', pin); - localStorage.setItem('eventsnap_user_id', userId); - localStorage.setItem('eventsnap_display_name', name); - }, { jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName }); + await pageA.evaluate( + ({ jwt, pin, userId, name }) => { + localStorage.setItem('eventsnap_jwt', jwt); + localStorage.setItem('eventsnap_pin', pin); + localStorage.setItem('eventsnap_user_id', userId); + localStorage.setItem('eventsnap_display_name', name); + }, + { jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName } + ); await pageB.goto('http://localhost:3101/'); - await pageB.evaluate(({ jwt, pin, userId, name }) => { - localStorage.setItem('eventsnap_jwt', jwt); - localStorage.setItem('eventsnap_pin', pin); - localStorage.setItem('eventsnap_user_id', userId); - localStorage.setItem('eventsnap_display_name', name); - }, { jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName }); + await pageB.evaluate( + ({ jwt, pin, userId, name }) => { + localStorage.setItem('eventsnap_jwt', jwt); + localStorage.setItem('eventsnap_pin', pin); + localStorage.setItem('eventsnap_user_id', userId); + localStorage.setItem('eventsnap_display_name', name); + }, + { jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName } + ); // Each context sees only its own user. const aUid = await pageA.evaluate(() => localStorage.getItem('eventsnap_user_id')); @@ -63,7 +77,11 @@ test.describe('Browser chaos — multi-tab', () => { await ctxB.close(); }); - test('localStorage is shared across tabs of the same context (real browser behavior)', async ({ context, guest, signIn }) => { + test('localStorage is shared across tabs of the same context (real browser behavior)', async ({ + context, + guest, + signIn, + }) => { // Real browsers share localStorage across tabs of the same origin. Tab A's // removeItem is instantly visible in tab B's localStorage. The UX gap to // document is that tab B's React/Svelte state isn't *re-rendered* until the diff --git a/e2e/specs/08-browser-chaos/offline-network.spec.ts b/e2e/specs/08-browser-chaos/offline-network.spec.ts index 2af1a70..f05af19 100644 --- a/e2e/specs/08-browser-chaos/offline-network.spec.ts +++ b/e2e/specs/08-browser-chaos/offline-network.spec.ts @@ -6,7 +6,11 @@ import { test, expect } from '../../fixtures/test'; test.describe('Browser chaos — network', () => { - test('offline → reconnect — page does not crash and bottom nav is responsive', async ({ page, guest, signIn }) => { + test('offline → reconnect — page does not crash and bottom nav is responsive', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Offline1'); await signIn(page, g); await page.goto('/feed'); @@ -16,7 +20,10 @@ test.describe('Browser chaos — network', () => { await page.context().setOffline(true); // Tap the bottom nav — should not raise unhandled errors. - await page.getByRole('link', { name: 'Konto' }).click().catch(() => {}); + await page + .getByRole('link', { name: 'Konto' }) + .click() + .catch(() => {}); await page.waitForTimeout(500); await page.context().setOffline(false); @@ -27,7 +34,11 @@ test.describe('Browser chaos — network', () => { expect(errors.filter((e) => !e.message.toLowerCase().includes('fetch'))).toHaveLength(0); }); - test('slow 3G simulation — initial nav completes within reasonable bound', async ({ page, guest, signIn }) => { + test('slow 3G simulation — initial nav completes within reasonable bound', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Slow3g'); // 50ms latency on every request, applied to /api/* only so navigation isn't catastrophic. @@ -41,7 +52,11 @@ test.describe('Browser chaos — network', () => { await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 15_000 }); }); - test('intermittent API failures during navigation — UI surfaces an error state', async ({ page, guest, signIn }) => { + test('intermittent API failures during navigation — UI surfaces an error state', async ({ + page, + guest, + signIn, + }) => { const g = await guest('FlakyApi'); let count = 0; diff --git a/e2e/specs/08-browser-chaos/storage-purge.spec.ts b/e2e/specs/08-browser-chaos/storage-purge.spec.ts index 01f3cee..cb52152 100644 --- a/e2e/specs/08-browser-chaos/storage-purge.spec.ts +++ b/e2e/specs/08-browser-chaos/storage-purge.spec.ts @@ -9,7 +9,11 @@ import { test, expect } from '../../fixtures/test'; import { readStorage, clearLocalStorage, clearAllStorage } from '../../helpers/storage-helpers'; test.describe('Browser chaos — storage purge', () => { - test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({ page, guest, signIn }) => { + test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Purge1'); await signIn(page, g); await page.goto('/feed'); @@ -31,7 +35,11 @@ test.describe('Browser chaos — storage purge', () => { expect(['/join', '/feed', '/recover', '/']).toContain(url.pathname); }); - test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({ page, guest, signIn }) => { + test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Purge2'); await signIn(page, g); await page.goto('/feed'); @@ -48,7 +56,11 @@ test.describe('Browser chaos — storage purge', () => { expect(stillAuthed).toBe(200); }); - test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({ page, guest, signIn }) => { + test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Purge3'); await signIn(page, g); await page.goto('/feed'); @@ -74,7 +86,11 @@ test.describe('Browser chaos — storage purge', () => { await page.waitForURL(/admin\/login|join/, { timeout: 5_000 }); }); - test('PIN survives clearAuth (intentional per auth.ts comment)', async ({ page, guest, signIn }) => { + test('PIN survives clearAuth (intentional per auth.ts comment)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('PurgePin'); await signIn(page, g); await page.goto('/account'); diff --git a/e2e/specs/09-mobile/focus-trap.spec.ts b/e2e/specs/09-mobile/focus-trap.spec.ts index 9956600..3483769 100644 --- a/e2e/specs/09-mobile/focus-trap.spec.ts +++ b/e2e/specs/09-mobile/focus-trap.spec.ts @@ -34,18 +34,25 @@ test.describe('Mobile a11y — focus trap on LightboxModal', () => { await trigger.click(); // Lightbox is role="dialog" aria-modal="true". - const lightbox = page.locator('[role="dialog"][aria-modal="true"]').filter({ has: page.locator('img, video') }); + const lightbox = page + .locator('[role="dialog"][aria-modal="true"]') + .filter({ has: page.locator('img, video') }); await expect(lightbox).toBeVisible(); // After opening, focus should be inside the lightbox (trap autoFocus moves // focus to the first focusable). Verify by checking activeElement is // contained. - await expect.poll(async () => { - return await page.evaluate(() => { - const dlg = document.querySelector('[role="dialog"][aria-modal="true"]'); - return !!dlg && dlg.contains(document.activeElement); - }); - }, { timeout: 2_000 }).toBe(true); + await expect + .poll( + async () => { + return await page.evaluate(() => { + const dlg = document.querySelector('[role="dialog"][aria-modal="true"]'); + return !!dlg && dlg.contains(document.activeElement); + }); + }, + { timeout: 2_000 } + ) + .toBe(true); // Escape dismisses the lightbox. await page.keyboard.press('Escape'); diff --git a/e2e/specs/09-mobile/gestures-doubletap.spec.ts b/e2e/specs/09-mobile/gestures-doubletap.spec.ts index fbe9b50..9edaff7 100644 --- a/e2e/specs/09-mobile/gestures-doubletap.spec.ts +++ b/e2e/specs/09-mobile/gestures-doubletap.spec.ts @@ -26,12 +26,18 @@ async function seedUpload(token: string, caption = 'Doubletap fixture'): Promise contentType: 'image/jpeg', caption, }); - if (res.status !== 201) throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`); + if (res.status !== 201) + throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`); return (await res.json()) as { id: string }; } test.describe('Mobile — double-tap gesture', () => { - test('double-tap on a feed card image button registers a like', async ({ api, page, guest, signIn }) => { + test('double-tap on a feed card image button registers a like', async ({ + api, + page, + guest, + signIn, + }) => { const author = await guest('DtAuthor'); const liker = await guest('DtLiker'); const { id: uploadId } = await seedUpload(author.jwt, 'Double-tap me'); @@ -40,7 +46,8 @@ test.describe('Mobile — double-tap gesture', () => { await page.goto('/feed'); // Locate the image button inside the card. The aria-label is "Bild vergrößern". - const imageButton = page.locator('article') + const imageButton = page + .locator('article') .filter({ hasText: author.displayName }) .first() .getByRole('button', { name: 'Bild vergrößern' }); @@ -50,16 +57,26 @@ test.describe('Mobile — double-tap gesture', () => { // Wait for the optimistic increment OR the SSE-confirmed count. We assert via the // API to avoid coupling to specific DOM markup for the like badge. - await expect.poll(async () => { - const feed = await api.getFeed(liker.jwt); - // Backend returns { uploads: [...], next_cursor }. - const list: any[] = feed.uploads ?? feed.items ?? feed; - const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined; - return row?.like_count ?? row?.likes ?? 0; - }, { timeout: 5_000 }).toBeGreaterThanOrEqual(1); + await expect + .poll( + async () => { + const feed = await api.getFeed(liker.jwt); + // Backend returns { uploads: [...], next_cursor }. + const list: any[] = feed.uploads ?? feed.items ?? feed; + const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined; + return row?.like_count ?? row?.likes ?? 0; + }, + { timeout: 5_000 } + ) + .toBeGreaterThanOrEqual(1); }); - test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({ api, page, guest, signIn }) => { + test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({ + api, + page, + guest, + signIn, + }) => { const author = await guest('LbAuthor'); const liker = await guest('LbLiker'); const { id: uploadId } = await seedUpload(author.jwt, 'Lightbox heart'); @@ -68,7 +85,8 @@ test.describe('Mobile — double-tap gesture', () => { await page.goto('/feed'); // Open the lightbox by clicking the image button. - const imageButton = page.locator('article') + const imageButton = page + .locator('article') .filter({ hasText: author.displayName }) .first() .getByRole('button', { name: 'Bild vergrößern' }); @@ -88,12 +106,17 @@ test.describe('Mobile — double-tap gesture', () => { await doubleTap(page, media); - await expect.poll(async () => { - const feed = await api.getFeed(liker.jwt); - // Backend returns { uploads: [...], next_cursor }. - const list: any[] = feed.uploads ?? feed.items ?? feed; - const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined; - return row?.like_count ?? row?.likes ?? 0; - }, { timeout: 5_000 }).toBeGreaterThanOrEqual(1); + await expect + .poll( + async () => { + const feed = await api.getFeed(liker.jwt); + // Backend returns { uploads: [...], next_cursor }. + const list: any[] = feed.uploads ?? feed.items ?? feed; + const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined; + return row?.like_count ?? row?.likes ?? 0; + }, + { timeout: 5_000 } + ) + .toBeGreaterThanOrEqual(1); }); }); diff --git a/e2e/specs/09-mobile/gestures-longpress.spec.ts b/e2e/specs/09-mobile/gestures-longpress.spec.ts index 67bdba7..a1c0925 100644 --- a/e2e/specs/09-mobile/gestures-longpress.spec.ts +++ b/e2e/specs/09-mobile/gestures-longpress.spec.ts @@ -11,7 +11,7 @@ * helper. */ import { test, expect } from '../../fixtures/test'; -import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client'; +import { uploadRaw } from '../../helpers/upload-client'; import { longPress } from '../../helpers/touch'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -54,7 +54,11 @@ test.describe('Mobile — long-press gesture', () => { await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible(); }); - test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn }) => { + test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Lp2'); await seedUpload(g.jwt, 'Quick tap'); await signIn(page, g); @@ -68,10 +72,16 @@ test.describe('Mobile — long-press gesture', () => { // Within 1 s, the ContextSheet must not be open (aria-modal is set only when // open). A quick tap opens the lightbox instead, which is a different element. - await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 }); + await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { + timeout: 1_000, + }); }); - test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => { + test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Lp3'); await seedUpload(g.jwt, 'Suppress click'); await signIn(page, g); @@ -85,6 +95,8 @@ test.describe('Mobile — long-press gesture', () => { // The longpress action sets `suppressNextClick = true` — so the lightbox // (separate role=dialog) should NOT appear in addition to the context sheet. // Exactly one aria-modal=true dialog should be open: the context sheet. - await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000 }); + await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { + timeout: 2_000, + }); }); }); diff --git a/e2e/specs/09-mobile/planned-gestures.spec.ts b/e2e/specs/09-mobile/planned-gestures.spec.ts index c8b1685..eeaa64c 100644 --- a/e2e/specs/09-mobile/planned-gestures.spec.ts +++ b/e2e/specs/09-mobile/planned-gestures.spec.ts @@ -19,7 +19,11 @@ import { join } from 'node:path'; const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg'); test.describe('Mobile — planned gestures (fixme until shipped)', () => { - test.fixme('swipe left in lightbox navigates to next filtered item', async ({ page, guest, signIn }) => { + test.fixme('swipe left in lightbox navigates to next filtered item', async ({ + page, + guest, + signIn, + }) => { const author = await guest('SwipeAuthor'); // Seed two uploads so there's a "next" to navigate to. for (const cap of ['First', 'Second']) { @@ -35,7 +39,10 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => { await page.goto('/feed'); // Open the lightbox on the first card. - const firstImage = page.locator('article').filter({ hasText: 'First' }).getByRole('button', { name: 'Bild vergrößern' }); + const firstImage = page + .locator('article') + .filter({ hasText: 'First' }) + .getByRole('button', { name: 'Bild vergrößern' }); await firstImage.click(); const lightbox = page.getByRole('dialog'); await expect(lightbox).toBeVisible(); @@ -96,11 +103,7 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => { const box = await page.locator('body').boundingBox(); if (!box) throw new Error('body not visible'); // Pull down from the top of the viewport. - await swipe( - page, - { x: box.x + box.width / 2, y: 20 }, - { x: box.x + box.width / 2, y: 200 } - ); + await swipe(page, { x: box.x + box.width / 2, y: 20 }, { x: box.x + box.width / 2, y: 200 }); await page.waitForTimeout(1_000); expect(deltaCalled).toBe(true); diff --git a/e2e/specs/09-mobile/safe-area.spec.ts b/e2e/specs/09-mobile/safe-area.spec.ts index a512460..5a7816e 100644 --- a/e2e/specs/09-mobile/safe-area.spec.ts +++ b/e2e/specs/09-mobile/safe-area.spec.ts @@ -17,24 +17,38 @@ import { test, expect } from '../../fixtures/test'; import { inlineStyle } from '../../helpers/touch'; test.describe('Mobile — safe-area insets', () => { - test('bottom nav declares safe-area-inset-bottom in its inline style', async ({ page, guest, signIn }) => { + test('bottom nav declares safe-area-inset-bottom in its inline style', async ({ + page, + guest, + signIn, + }) => { const g = await guest('SafeAreaNav'); await signIn(page, g); await page.goto('/feed'); - const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first(); + const nav = page + .locator('nav') + .filter({ has: page.getByRole('link', { name: 'Galerie' }) }) + .first(); await expect(nav).toBeVisible(); const style = await inlineStyle(nav); expect(style).toContain('env(safe-area-inset-bottom)'); }); - test('bottom nav stays flush with viewport bottom (no large gap)', async ({ page, guest, signIn }) => { + test('bottom nav stays flush with viewport bottom (no large gap)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('SafeAreaFlush'); await signIn(page, g); await page.goto('/feed'); - const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first(); + const nav = page + .locator('nav') + .filter({ has: page.getByRole('link', { name: 'Galerie' }) }) + .first(); const viewport = page.viewportSize(); if (!viewport) throw new Error('No viewport size set on this project'); const box = await nav.boundingBox(); @@ -48,7 +62,11 @@ test.describe('Mobile — safe-area insets', () => { // (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true — // was removed; the real sheet-level env() check is the structural test below.) - test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => { + test('upload sheet and context sheet both honor env() (structural check)', async ({ + page, + guest, + signIn, + }) => { const g = await guest('SafeAreaSheets'); await signIn(page, g); await page.goto('/feed'); @@ -57,9 +75,9 @@ test.describe('Mobile — safe-area insets', () => { await page.getByRole('button', { name: 'Hochladen' }).click(); // Even if the sheet is offscreen / hidden, the style attribute is present in the DOM. const hits: number = await page.evaluate(() => { - return Array.from(document.querySelectorAll('[style]')) - .filter((el) => (el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)')) - .length; + return Array.from(document.querySelectorAll('[style]')).filter((el) => + (el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)') + ).length; }); expect(hits).toBeGreaterThanOrEqual(1); }); diff --git a/e2e/specs/09-mobile/sheet-escape.spec.ts b/e2e/specs/09-mobile/sheet-escape.spec.ts index 6ab3730..c3c1e59 100644 --- a/e2e/specs/09-mobile/sheet-escape.spec.ts +++ b/e2e/specs/09-mobile/sheet-escape.spec.ts @@ -24,7 +24,11 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => { await expect(sheet).not.toBeVisible({ timeout: 2_000 }); }); - test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({ page, guest, signIn }) => { + test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({ + page, + guest, + signIn, + }) => { const g = await guest('LeaveEsc'); await signIn(page, g); await page.goto('/account'); diff --git a/e2e/specs/09-mobile/touch-targets.spec.ts b/e2e/specs/09-mobile/touch-targets.spec.ts index 47207d5..6773a38 100644 --- a/e2e/specs/09-mobile/touch-targets.spec.ts +++ b/e2e/specs/09-mobile/touch-targets.spec.ts @@ -16,8 +16,12 @@ const MIN_TOUCH = 44; async function assertTouchTarget(box: { width: number; height: number } | null, name: string) { if (!box) throw new Error(`${name} not visible — no bounding box`); - expect.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`).toBeGreaterThanOrEqual(MIN_TOUCH); - expect.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`).toBeGreaterThanOrEqual(MIN_TOUCH); + expect + .soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`) + .toBeGreaterThanOrEqual(MIN_TOUCH); + expect + .soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`) + .toBeGreaterThanOrEqual(MIN_TOUCH); } test.describe('Mobile — touch target audit', () => { @@ -55,6 +59,9 @@ test.describe('Mobile — touch target audit', () => { await expect(page.getByTestId('pin-modal')).toBeVisible(); await assertTouchTarget(await page.getByTestId('pin-copy').boundingBox(), 'PIN copy button'); - await assertTouchTarget(await page.getByTestId('continue-to-feed').boundingBox(), 'Continue-to-feed button'); + await assertTouchTarget( + await page.getByTestId('continue-to-feed').boundingBox(), + 'Continue-to-feed button' + ); }); }); diff --git a/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts b/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts index e4865b8..be18fff 100644 --- a/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts +++ b/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts @@ -6,7 +6,11 @@ import { test, expect } from '../../fixtures/test'; test.describe('Mobile — upload composer cancel confirmation', () => { - test('typing a caption then tapping X opens the discard ConfirmSheet', async ({ page, guest, signIn }) => { + test('typing a caption then tapping X opens the discard ConfirmSheet', async ({ + page, + guest, + signIn, + }) => { const g = await guest('CancelConf'); await signIn(page, g); @@ -31,7 +35,11 @@ test.describe('Mobile — upload composer cancel confirmation', () => { await expect(caption).toHaveValue(/a meaningful caption/); }); - test('with no content, tapping X navigates directly to /feed', async ({ page, guest, signIn }) => { + test('with no content, tapping X navigates directly to /feed', async ({ + page, + guest, + signIn, + }) => { const g = await guest('CancelEmpty'); await signIn(page, g); await page.goto('/upload'); diff --git a/e2e/specs/09-mobile/viewport-reflow.spec.ts b/e2e/specs/09-mobile/viewport-reflow.spec.ts index bd06890..7d7cf94 100644 --- a/e2e/specs/09-mobile/viewport-reflow.spec.ts +++ b/e2e/specs/09-mobile/viewport-reflow.spec.ts @@ -16,13 +16,20 @@ const VIEWPORTS = [ test.describe('Mobile — viewport reflow', () => { for (const vp of VIEWPORTS) { - test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({ page, guest, signIn }) => { + test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({ + page, + guest, + signIn, + }) => { const g = await guest(`Reflow_${vp.width}x${vp.height}`); await signIn(page, g); await page.setViewportSize({ width: vp.width, height: vp.height }); await page.goto('/feed'); - const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first(); + const nav = page + .locator('nav') + .filter({ has: page.getByRole('link', { name: 'Galerie' }) }) + .first(); const fab = page.getByRole('button', { name: 'Hochladen' }); await expect(nav).toBeVisible(); @@ -40,11 +47,15 @@ test.describe('Mobile — viewport reflow', () => { if (!fabBox) throw new Error('FAB has no bounding box'); const fabMidX = fabBox.x + fabBox.width / 2; const expectedMid = vp.width / 2; - expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.30); + expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.3); }); } - test('rotation portrait → landscape preserves auth + bottom nav', async ({ page, guest, signIn }) => { + test('rotation portrait → landscape preserves auth + bottom nav', async ({ + page, + guest, + signIn, + }) => { const g = await guest('Rotate'); await signIn(page, g); await page.goto('/feed'); diff --git a/e2e/specs/10-flow-review/ban-replay.spec.ts b/e2e/specs/10-flow-review/ban-replay.spec.ts index b8553a5..7fef50d 100644 --- a/e2e/specs/10-flow-review/ban-replay.spec.ts +++ b/e2e/specs/10-flow-review/ban-replay.spec.ts @@ -10,8 +10,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'; async function feedDelta(jwt: string, since: string): Promise { const res = await fetch(`${BASE}/api/v1/feed/delta?since=${encodeURIComponent(since)}`, { @@ -40,9 +39,10 @@ test.describe('Realtime — ban replays in the reconnect delta (H1 audit fix)', // A delta from the pre-ban cursor MUST surface the ban so the client can evict. const afterBan = await feedDelta(host.jwt, cursorBefore); - expect(afterBan.hidden_user_ids, 'ban replayed to a client that missed the live event').toContain( - g.userId - ); + expect( + afterBan.hidden_user_ids, + 'ban replayed to a client that missed the live event' + ).toContain(g.userId); // And a delta from a cursor AFTER the ban must NOT re-surface it (the `>= since` filter // means a client already past the ban isn't told again forever). diff --git a/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts b/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts index 7257124..db9793e 100644 --- a/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts +++ b/e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts @@ -44,8 +44,8 @@ import { join } from 'node:path'; import { seedUpload } from '../../helpers/seed'; import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client'; import { SseListener } from '../../helpers/sse-listener'; +import { BASE } from '../../helpers/env'; -const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const SLUG = 'e2e-test-event'; const N_UPLOADS = 6; /** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */ @@ -74,7 +74,9 @@ function post(path: string, jwt: string) { } async function exportStatus(jwt: string): Promise { - const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } }); + const res = await fetch(BASE + '/api/v1/export/status', { + headers: { Authorization: `Bearer ${jwt}` }, + }); return res.json(); } @@ -85,10 +87,13 @@ async function mintTicket(jwt: string): Promise { async function waitExportDone(jwt: string) { await expect - .poll(async () => { - const s = await exportStatus(jwt); - return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done'; - }, { timeout: 60_000, intervals: [500] }) + .poll( + async () => { + const s = await exportStatus(jwt); + return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done'; + }, + { timeout: 60_000, intervals: [500] } + ) .toBe(true); } @@ -151,10 +156,14 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204); // Post-release uploads must be rejected (belt-and-suspenders on top of the lock). - const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), { - filename: 'late.jpg', - contentType: 'image/jpeg', - }); + const rejected = await uploadRaw( + host.jwt, + readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), + { + filename: 'late.jpg', + contentType: 'image/jpeg', + } + ); expect(rejected.status).toBe(403); // Churn: reopen (retires the generation) then re-release, several times in quick @@ -418,7 +427,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k listing.some((n) => n.includes(takedown)), 'the taken-down photo must be gone from the regenerated keepsake' ).toBe(false); - expect(listing.some((n) => n.includes(keep)), 'the kept photo survives').toBe(true); + expect( + listing.some((n) => n.includes(keep)), + 'the kept photo survives' + ).toBe(true); }); test('BANNING a guest after release removes their photos from the keepsake', async ({ @@ -451,7 +463,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k listing.some((n) => n.includes(badPhoto)), "a banned guest's photo must not survive in the keepsake everyone downloads" ).toBe(false); - expect(listing.some((n) => n.includes(goodPhoto)), 'everyone else keeps their photos').toBe(true); + expect( + listing.some((n) => n.includes(goodPhoto)), + 'everyone else keeps their photos' + ).toBe(true); expect(listing).toHaveLength(1); }); @@ -543,7 +558,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k expect(res.status).toBe(200); // ... and it bumped the epoch (the viewer must rebuild) ... - await expect.poll(async () => await eventEpoch(), { timeout: 10_000 }).toBeGreaterThan(eventEpochBefore); + await expect + .poll(async () => await eventEpoch(), { timeout: 10_000 }) + .toBeGreaterThan(eventEpochBefore); // ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media // didn't change, so a full ZIP rebuild would be wasted work). @@ -663,7 +680,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k // Stuck: the keepsake is not downloadable and no amount of re-releasing helps. const ticket = await mintTicket(host.jwt); - expect((await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status).toBe(404); + expect( + (await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status + ).toBe(404); // ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.) expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400); diff --git a/e2e/specs/10-flow-review/offline-resume.spec.ts b/e2e/specs/10-flow-review/offline-resume.spec.ts index aa219b8..67629b9 100644 --- a/e2e/specs/10-flow-review/offline-resume.spec.ts +++ b/e2e/specs/10-flow-review/offline-resume.spec.ts @@ -75,9 +75,7 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => { // While offline: nothing may have reached the server, and — the crux of the // fix — the item must be parked as `pending`, NOT burned to `error`/`blocked` // (which on the old code would require a manual retry tap). - await expect - .poll(() => queueStatuses(page), { timeout: 8_000 }) - .toEqual(['pending']); + await expect.poll(() => queueStatuses(page), { timeout: 8_000 }).toEqual(['pending']); expect(await db.countUploadsForUser(g.userId)).toBe(0); // Reconnect. The `online` listener must resume the queue automatically — we do @@ -85,16 +83,17 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => { await page.context().setOffline(false); // The queued upload is now created server-side without any manual interaction. - await expect - .poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 }) - .toBe(1); + await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 }).toBe(1); // And the queue item ends terminal-good (done) or is drained — never stuck in error. await expect - .poll(async () => { - const s = await queueStatuses(page); - return s.every((x) => x === 'done') || s.length === 0; - }, { timeout: 10_000 }) + .poll( + async () => { + const s = await queueStatuses(page); + return s.every((x) => x === 'done') || s.length === 0; + }, + { timeout: 10_000 } + ) .toBe(true); }); }); diff --git a/e2e/specs/10-flow-review/upload-lock-code.spec.ts b/e2e/specs/10-flow-review/upload-lock-code.spec.ts index 0110565..a32fcdf 100644 --- a/e2e/specs/10-flow-review/upload-lock-code.spec.ts +++ b/e2e/specs/10-flow-review/upload-lock-code.spec.ts @@ -9,6 +9,7 @@ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { BASE } from '../../helpers/env'; const SAMPLE = () => readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')); @@ -36,7 +37,6 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi }); test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => { - const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: { Authorization: `Bearer ${host.jwt}` },