feat(e2e): Playwright suite — 134 tests across 9 spec areas + UA matrix
Adds an end-to-end Playwright test suite under e2e/ that spins up an
isolated docker-compose stack (Postgres :55432, Caddy :3101, backend with
EVENTSNAP_TEST_MODE=1, SvelteKit adapter-node frontend) and exercises the
SvelteKit app against the real Rust backend.
Phase 1 — happy paths covering every documented USER_JOURNEYS.md flow:
01-auth/ join, recover, admin login, leave event, PIN lockout
02-upload/ gallery picker (API path), rate-limit + admin toggle
03-feed/ like/comment SSE, filters, SSE reconnect on visibility
04-host/ event lock API, ban/unban, promote
05-admin/ config validation, foundational authz guards, stats
06-export/ /export status + download stub
__smoke/ cross-UA happy-path (runs on every UA project)
Phase 2 — adversarial + browser chaos:
07-adversarial/ XSS payloads (6 × display name path), SQLi shapes,
length / encoding / RTL override / NUL byte;
file-upload boundaries (ELF body claimed as JPEG,
oversize vs max_image_size_mb, zero-byte, NUL
filename, path-traversal, SVG-with-script);
JWT alg:none, signature/payload tamper, expired
session, PIN brute-force (serial + parallel),
admin password brute-force; deep authz (cross-user
delete, banned user across like/comment/feed-read,
host→admin escalation); small-scale DDoS (20× /join,
10MB comment body, 10 concurrent SSE).
08-browser-chaos/ localStorage / sessionStorage / cookie purge,
IndexedDB drop mid-session, offline → reconnect,
slow-3G, 503 flakes, 429 with no retry storm,
multi-tab same/different user, no-JS, hostile CSS,
clock skew ±1h / -2d, localStorage quota exhausted.
Phase 3 — mobile gestures (runs only on chromium-mobile / Pixel 7):
09-mobile/ touch-target ≥44px audit, env(safe-area-inset-bottom)
structural check, long-press (FeedListCard → ContextSheet,
quick-tap negation, click-suppression), double-tap
(feed card like + lightbox heart-burst, via synthetic
pointer events to bypass the first-tap-fires-click trap),
viewport reflow (portrait/landscape/narrow/phablet),
plus fixme stubs documenting planned gestures (swipe
lightbox L/R, swipe-down dismiss, pull-to-refresh,
long-press-comment).
Cross-UA matrix (chromium-engine projects run @smoke only):
chromium-pixel7, chromium-galaxy-s22, samsung-internet (Samsung UA
emulation on Galaxy viewport), edge-android, plus webkit-iphone,
chrome-ios, firefox-android, firefox-desktop — the latter four need
libavif16 on the host (Playwright dep) but the configs are in place.
Infrastructure:
- fixtures/test.ts central test.extend (api, db, adminToken, guest,
host, signIn). Per-test DB truncate via the dev-only POST
/admin/__truncate route, gated by EVENTSNAP_TEST_MODE=1.
- helpers/sse-listener.ts, helpers/upload-client.ts (Node-side
multipart for adversarial file-upload tests + JPEG/PNG/ELF magic
constants), helpers/touch.ts (longPress / doubleTap / swipe /
inlineStyle / computedStyle).
- 10 page objects covering every route + UploadSheet/Lightbox.
- global-setup waits for /health, logs in admin, disables every
rate-limit and quota toggle.
- .github/workflows/e2e.yml: PR check runs chromium-desktop + the
smoke matrix in parallel, uploads playwright-report/ and traces on
failure.
Findings the suite surfaces as live `[finding]` warnings (not silenced):
1. /admin/login has no rate-limit or lockout (bcrypt cost only).
2. PIN-attempt counter races under parallel /recover requests.
3. Zero-byte uploads pass /api/v1/upload.
4. SVG-with-script can pass the magic-byte check (consider CSP +
X-Content-Type-Options on /media/*).
Stack-internal docs live in e2e/README.md (UA tier table, Samsung
Internet escalation tiers A/B/C, debugging tips, roadmap).
Final tally: 134 passed / 0 failed / 9 skipped (test.fixme stubs for
not-yet-shipped gestures and one UI-upload-flow investigation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
e2e/helpers/fake-media.ts
Normal file
15
e2e/helpers/fake-media.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Camera/microphone faking. Chromium has the `--use-fake-device-for-media-stream`
|
||||
* launch arg (set in playwright.config.ts) which makes `getUserMedia` succeed
|
||||
* with a generated green-rectangle stream. WebKit and Firefox do not — those
|
||||
* UA projects skip camera-path tests entirely.
|
||||
*/
|
||||
import type { BrowserContext } from '@playwright/test';
|
||||
|
||||
export async function grantCameraPermissions(context: BrowserContext, origin: string) {
|
||||
await context.grantPermissions(['camera', 'microphone'], { origin });
|
||||
}
|
||||
|
||||
export function isFakeMediaSupported(browserName: string): boolean {
|
||||
return browserName === 'chromium';
|
||||
}
|
||||
88
e2e/helpers/sse-listener.ts
Normal file
88
e2e/helpers/sse-listener.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Subscribe to /api/v1/stream and collect events. Tests can then assert "a
|
||||
* `like-update` arrived for upload X within 5 seconds" without driving a
|
||||
* second browser tab.
|
||||
*
|
||||
* The backend authenticates the SSE endpoint via `?token=` query param
|
||||
* (the EventSource API can't set headers).
|
||||
*/
|
||||
|
||||
export type SseEvent = { type: string; data: any; receivedAt: number };
|
||||
|
||||
export class SseListener {
|
||||
private controller = new AbortController();
|
||||
private events: SseEvent[] = [];
|
||||
private closed = false;
|
||||
|
||||
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
|
||||
|
||||
async start(token: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/v1/stream?token=${encodeURIComponent(token)}`;
|
||||
// Use fetch with streaming since Node has no EventSource by default.
|
||||
const res = await fetch(url, { signal: this.controller.signal });
|
||||
if (!res.body) throw new Error('SSE response has no body');
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
(async () => {
|
||||
try {
|
||||
while (!this.closed) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// SSE messages are separated by \n\n
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const part of parts) {
|
||||
const evt = parseSseMessage(part);
|
||||
if (evt) this.events.push({ ...evt, receivedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* aborted */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async waitForEvent(
|
||||
type: string,
|
||||
predicate: (e: SseEvent) => boolean = () => true,
|
||||
timeoutMs = 10_000
|
||||
): Promise<SseEvent> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = this.events.find((e) => e.type === type && predicate(e));
|
||||
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(', ')}`);
|
||||
}
|
||||
|
||||
allEvents(): SseEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.closed = true;
|
||||
this.controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseMessage(raw: string): { type: string; data: any } | null {
|
||||
const lines = raw.split('\n');
|
||||
let type = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) type = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
const dataStr = dataLines.join('\n');
|
||||
let data: any = dataStr;
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch {
|
||||
/* leave as string */
|
||||
}
|
||||
return { type, data };
|
||||
}
|
||||
46
e2e/helpers/storage-helpers.ts
Normal file
46
e2e/helpers/storage-helpers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Helpers for inspecting and manipulating browser storage from Playwright.
|
||||
* Centralised here so the storage-key strings match auth.ts in one place.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
jwt: 'eventsnap_jwt',
|
||||
pin: 'eventsnap_pin',
|
||||
userId: 'eventsnap_user_id',
|
||||
displayName: 'eventsnap_display_name',
|
||||
guideSeen: 'eventsnap_guide_seen',
|
||||
} as const;
|
||||
|
||||
export async function readStorage(page: Page) {
|
||||
return page.evaluate((keys) => {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const [name, key] of Object.entries(keys)) {
|
||||
out[name] = localStorage.getItem(key as string);
|
||||
}
|
||||
return out;
|
||||
}, STORAGE_KEYS);
|
||||
}
|
||||
|
||||
export async function clearLocalStorage(page: Page) {
|
||||
if (page.url() === 'about:blank') await page.goto('/');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
}
|
||||
|
||||
export async function clearSessionStorage(page: Page) {
|
||||
if (page.url() === 'about:blank') await page.goto('/');
|
||||
await page.evaluate(() => sessionStorage.clear());
|
||||
}
|
||||
|
||||
export async function clearAllStorage(page: Page) {
|
||||
// localStorage is only accessible on a real origin — about:blank throws SecurityError.
|
||||
// Navigate to the app origin first if we're on the blank starting page.
|
||||
if (page.url() === 'about:blank') {
|
||||
await page.goto('/');
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
103
e2e/helpers/touch.ts
Normal file
103
e2e/helpers/touch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Touch-gesture primitives for Phase 3 mobile specs.
|
||||
*
|
||||
* The app uses two custom Svelte actions:
|
||||
*
|
||||
* 1. `longpress` — listens for `pointerdown`, starts a 500 ms timer,
|
||||
* cancels on pointerup / pointermove > 10 px / pointercancel.
|
||||
* [frontend/src/lib/actions/longpress.ts]
|
||||
*
|
||||
* 2. `doubletap` — listens for `pointerup` and times consecutive
|
||||
* releases within 300 ms on roughly the same coordinate.
|
||||
* [frontend/src/lib/actions/doubletap.ts]
|
||||
*
|
||||
* Both listen to pointer events, which fire from mouse, touch, and pen
|
||||
* input. We drive them with `page.mouse` because it works identically
|
||||
* across all Playwright engines and respects the project's mobile
|
||||
* viewport — the action doesn't care whether the underlying device was
|
||||
* touch or mouse.
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error(`Cannot get bounding box for locator (not visible?)`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Hold the pointer down on `locator` for `durationMs`. Default 600 ms beats the 500 ms long-press threshold. */
|
||||
export async function longPress(page: Page, locator: Locator, durationMs = 600) {
|
||||
const { x, y } = await centerOf(locator);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(durationMs);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two rapid pointer-event pairs within the doubletap action's 300 ms window.
|
||||
*
|
||||
* Dispatches synthetic `pointerdown`/`pointerup` directly on the element via
|
||||
* `page.evaluate` (not via `page.mouse`). Why: with real mouse events, the
|
||||
* first tap fires the element's `onclick` synchronously — which on a
|
||||
* FeedListCard image button means the lightbox opens *before* the second tap
|
||||
* lands. That makes mouse-driven double-tap impossible to test in headless
|
||||
* Chromium. Dispatching pointer events bypasses the synthetic click pipeline
|
||||
* entirely, so we can exercise the `doubletap` action's contract directly.
|
||||
*/
|
||||
export async function doubleTap(page: Page, locator: Locator) {
|
||||
await locator.evaluate((el: HTMLElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const mk = (type: string) =>
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
});
|
||||
el.dispatchEvent(mk('pointerdown'));
|
||||
el.dispatchEvent(mk('pointerup'));
|
||||
// Second tap < 300 ms later.
|
||||
return new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
el.dispatchEvent(mk('pointerdown'));
|
||||
el.dispatchEvent(mk('pointerup'));
|
||||
resolve();
|
||||
}, 50)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Touch swipe from start to end coordinates via the touchscreen API (mobile viewports). */
|
||||
export async function swipe(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
steps = 10
|
||||
) {
|
||||
// page.touchscreen.tap exists but doesn't expose move; fall back to dispatching
|
||||
// raw touch events via page.evaluate when needed. For now, mouse-based pointer
|
||||
// events are equivalent because the app's planned swipe handlers (when they
|
||||
// land) will use pointer events too.
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
await page.mouse.move(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Read computed style on a locator. Useful for asserting `padding-bottom` includes env(safe-area-inset-bottom). */
|
||||
export async function computedStyle(locator: Locator, prop: string): Promise<string> {
|
||||
return locator.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop);
|
||||
}
|
||||
|
||||
/** Read the raw inline `style` attribute (env() vars expand only inside computed style, not here). */
|
||||
export async function inlineStyle(locator: Locator): Promise<string> {
|
||||
return locator.evaluate((el) => (el as HTMLElement).getAttribute('style') ?? '');
|
||||
}
|
||||
48
e2e/helpers/upload-client.ts
Normal file
48
e2e/helpers/upload-client.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
||||
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
||||
* driving the UI. Crucial for MIME-spoofing, oversize, polyglot, and
|
||||
* filename-injection tests.
|
||||
*
|
||||
* Field shape matches [backend/src/handlers/upload.rs]:
|
||||
* - file (binary; carries filename + content_type in the part headers)
|
||||
* - caption (text, optional)
|
||||
* - hashtags (CSV text, optional)
|
||||
*/
|
||||
// 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;
|
||||
caption?: string;
|
||||
hashtags?: string;
|
||||
};
|
||||
|
||||
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');
|
||||
if (opts.caption !== undefined) form.append('caption', opts.caption);
|
||||
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
|
||||
return fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form as any,
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: read a fixture from disk and upload it. */
|
||||
export async function uploadFile(token: string, path: string, opts: UploadOptions = {}) {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const body = await readFile(path);
|
||||
return uploadRaw(token, body, opts);
|
||||
}
|
||||
|
||||
/** 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]);
|
||||
/** 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`. */
|
||||
export const ELF_MAGIC = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
|
||||
Reference in New Issue
Block a user