test(e2e): address self-review follow-ups (dedup, XSS render guard, SSE hardening)
Follow-ups from the code review of the test-quality batches: - Consolidate duplicated helpers into e2e/helpers/: seed.ts (seedUpload, seedComment, listComments, findFeedRow) and sse.ts (mintSseTicket, openStream, trackStreamOpens). Refactor authorization-deep, xss-injection, like-comment, sse-ticket-abuse, ddos, sse-realtime, multi-tab, and SseListener to use them — the upload/comment/ticket-flow contracts now live in one place each instead of being re-inlined across 3–7 specs. - xss-injection display-name loop: it navigated to /feed (which renders uploader names, not the viewer's) so "nothing fired" passed vacuously — the payload was never rendered. Now navigate to /account (the actual sink) and add a render guard asserting the payload reached the DOM as escaped text before checking __xssFired. - sse-realtime reconnect: snapshot the stream-open count AFTER backgrounding, so the "new connection" assertion is attributable to the foreground event and can't be satisfied by a spurious native/error reconnect before the toggle. - recover-page: correct the comment (auto-submit is the onPinInput handler, not an $effect). 44 affected specs verified green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
55
e2e/helpers/seed.ts
Normal file
55
e2e/helpers/seed.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Shared seed helpers so specs don't each hand-roll the upload/comment create
|
||||||
|
* flow. Centralising the API contract (routes, field names, expected statuses)
|
||||||
|
* means an API change is a one-file edit, not a 7-file hunt.
|
||||||
|
*/
|
||||||
|
import { uploadRaw, JPEG_MAGIC } from './upload-client';
|
||||||
|
import { db } from '../fixtures/db';
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
|
export type SeedUploadOptions = {
|
||||||
|
caption?: string;
|
||||||
|
/** Mark compression done so the card is fully rendered in the feed. Default true. */
|
||||||
|
visible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seed a real, accepted upload owned by `jwt` and return its id. */
|
||||||
|
export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise<string> {
|
||||||
|
const body = new Uint8Array(1024);
|
||||||
|
body.set(JPEG_MAGIC, 0);
|
||||||
|
const res = await uploadRaw(jwt, body, {
|
||||||
|
filename: 'a.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
caption: opts.caption,
|
||||||
|
});
|
||||||
|
if (res.status !== 201) throw new Error(`seedUpload failed: ${res.status} ${await res.text()}`);
|
||||||
|
const { id } = await res.json();
|
||||||
|
if (opts.visible !== false) await db.setUploadCompressionStatus(id, 'done');
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed a comment on `uploadId` authored by `jwt`; return its id. */
|
||||||
|
export async function seedComment(jwt: string, uploadId: string, body: string): Promise<string> {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
if (res.status !== 201) throw new Error(`seedComment failed: ${res.status} ${await res.text()}`);
|
||||||
|
return (await res.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the comments for an upload as `jwt`. */
|
||||||
|
export async function listComments(jwt: string, uploadId: string): Promise<any[]> {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a single upload row from a feed response whose envelope shape isn't pinned. */
|
||||||
|
export function findFeedRow(feed: any, id: string): any {
|
||||||
|
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||||
|
return Array.isArray(list) ? list.find((u: any) => u.id === id) : undefined;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
* does that exchange internally, so callers still just pass a JWT to `start()`.
|
* does that exchange internally, so callers still just pass a JWT to `start()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mintSseTicket } from './sse';
|
||||||
|
|
||||||
export type SseEvent = { type: string; data: any; receivedAt: number };
|
export type SseEvent = { type: string; data: any; receivedAt: number };
|
||||||
|
|
||||||
export class SseListener {
|
export class SseListener {
|
||||||
@@ -20,14 +22,7 @@ export class SseListener {
|
|||||||
async start(token: string): Promise<void> {
|
async start(token: string): Promise<void> {
|
||||||
// Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer
|
// Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer
|
||||||
// accepts ?token=).
|
// accepts ?token=).
|
||||||
const ticketRes = await fetch(`${this.baseUrl}/api/v1/stream/ticket`, {
|
const ticket = await mintSseTicket(token);
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (ticketRes.status !== 200) {
|
|
||||||
throw new Error(`SSE ticket mint failed: ${ticketRes.status} ${await ticketRes.text()}`);
|
|
||||||
}
|
|
||||||
const { ticket } = await ticketRes.json();
|
|
||||||
const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`;
|
const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`;
|
||||||
// Use fetch with streaming since Node has no EventSource by default.
|
// Use fetch with streaming since Node has no EventSource by default.
|
||||||
const res = await fetch(url, { signal: this.controller.signal });
|
const res = await fetch(url, { signal: this.controller.signal });
|
||||||
|
|||||||
43
e2e/helpers/sse.ts
Normal file
43
e2e/helpers/sse.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Shared SSE-flow helpers. The stream auth flow (mint a single-use ticket, open
|
||||||
|
* with `?ticket=`) is security-sensitive and recently changed from `?token=`, so
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
||||||
|
export async function mintSseTicket(jwt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
|
||||||
|
return (await res.json()).ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the SSE stream with a ticket, return the HTTP status, and tear the stream down. */
|
||||||
|
export async function openStream(ticket: string): Promise<number> {
|
||||||
|
const c = new AbortController();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
||||||
|
signal: c.signal,
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
} finally {
|
||||||
|
c.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count EventSource opens (GET /api/v1/stream?ticket=…) on a page — NOT the ticket
|
||||||
|
* POST. Returns a getter for the running count.
|
||||||
|
*/
|
||||||
|
export function trackStreamOpens(page: Page): () => number {
|
||||||
|
let n = 0;
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++;
|
||||||
|
});
|
||||||
|
return () => n;
|
||||||
|
}
|
||||||
@@ -21,10 +21,10 @@ export class RecoverPage {
|
|||||||
|
|
||||||
async recover(name: string, pin: string) {
|
async recover(name: string, pin: string) {
|
||||||
await this.nameInput.fill(name);
|
await this.nameInput.fill(name);
|
||||||
// Filling the 4th digit fires the form's auto-submit ($effect on
|
// Filling the 4th digit fires the form's auto-submit (the onPinInput handler
|
||||||
// pin.length === 4, see pin-auto-submit.spec). An explicit submit click would
|
// calls handleRecover once pin.length === 4, see pin-auto-submit.spec). An explicit
|
||||||
// race the ensuing navigation and detach mid-click, so only click as a
|
// submit click would race the ensuing navigation and detach mid-click, so only click
|
||||||
// fallback if the button is still around (e.g. a partial / failed PIN).
|
// as a fallback if the button is still around (e.g. a partial / failed PIN).
|
||||||
await this.pinInput.fill(pin);
|
await this.pinInput.fill(pin);
|
||||||
if (await this.submitButton.isEnabled().catch(() => false)) {
|
if (await this.submitButton.isEnabled().catch(() => false)) {
|
||||||
await this.submitButton.click().catch(() => {
|
await this.submitButton.click().catch(() => {
|
||||||
|
|||||||
@@ -7,20 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
async function seedUpload(jwt: string, db: any, caption?: string): Promise<string> {
|
|
||||||
const body = new Uint8Array(1024);
|
|
||||||
body.set(JPEG_MAGIC, 0);
|
|
||||||
const res = await uploadRaw(jwt, body, { filename: 'a.jpg', contentType: 'image/jpeg', caption });
|
|
||||||
if (res.status !== 201) throw new Error(`seed upload failed: ${res.status} ${await res.text()}`);
|
|
||||||
const { id } = await res.json();
|
|
||||||
await db.setUploadCompressionStatus(id, 'done');
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function like(jwt: string, uploadId: string): Promise<number> {
|
async function like(jwt: string, uploadId: string): Promise<number> {
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -29,43 +19,38 @@ async function like(jwt: string, uploadId: string): Promise<number> {
|
|||||||
return res.status;
|
return res.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRow(feed: any, id: string): any {
|
|
||||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
|
||||||
return list.find((u: any) => u.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Feed — like + comment', () => {
|
test.describe('Feed — like + comment', () => {
|
||||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest, db }) => {
|
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
|
||||||
const author = await guest('Author');
|
const author = await guest('Author');
|
||||||
const liker = await guest('Liker');
|
const liker = await guest('Liker');
|
||||||
const uploadId = await seedUpload(author.jwt, db);
|
const uploadId = await seedUpload(author.jwt);
|
||||||
|
|
||||||
// Baseline: nobody has liked yet.
|
// Baseline: nobody has liked yet.
|
||||||
let row = findRow(await api.getFeed(liker.jwt), uploadId);
|
let row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(0);
|
expect(row.like_count).toBe(0);
|
||||||
expect(row.liked_by_me).toBe(false);
|
expect(row.liked_by_me).toBe(false);
|
||||||
|
|
||||||
// First like → counted exactly once.
|
// First like → counted exactly once.
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204);
|
expect(await like(liker.jwt, uploadId)).toBe(204);
|
||||||
row = findRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(1);
|
expect(row.like_count).toBe(1);
|
||||||
expect(row.liked_by_me).toBe(true);
|
expect(row.liked_by_me).toBe(true);
|
||||||
|
|
||||||
// Liking again is a toggle → back to zero (guards against a regression that
|
// Liking again is a toggle → back to zero (guards against a regression that
|
||||||
// double-counts a repeated like instead of removing it).
|
// double-counts a repeated like instead of removing it).
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204);
|
expect(await like(liker.jwt, uploadId)).toBe(204);
|
||||||
row = findRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(0);
|
expect(row.like_count).toBe(0);
|
||||||
expect(row.liked_by_me).toBe(false);
|
expect(row.liked_by_me).toBe(false);
|
||||||
|
|
||||||
// A second distinct user's like is counted independently (per-user semantics).
|
// A second distinct user's like is counted independently (per-user semantics).
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1
|
expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1
|
||||||
expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2
|
expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2
|
||||||
row = findRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(2);
|
expect(row.like_count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('comment by user A → SSE new-comment delivered to user B', async ({ guest, db }) => {
|
test('comment by user A → SSE new-comment delivered to user B', async ({ guest }) => {
|
||||||
// SSE frames can take a keep-alive tick to flush through the reverse proxy, so
|
// SSE frames can take a keep-alive tick to flush through the reverse proxy, so
|
||||||
// both the stream connect and the delivery may cost up to ~30s each.
|
// both the stream connect and the delivery may cost up to ~30s each.
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
@@ -77,13 +62,8 @@ test.describe('Feed — like + comment', () => {
|
|||||||
await sse.start(b.jwt);
|
await sse.start(b.jwt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadId = await seedUpload(a.jwt, db, 'pic');
|
const uploadId = await seedUpload(a.jwt, { caption: 'pic' });
|
||||||
const cRes = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
await seedComment(a.jwt, uploadId, 'hello from A');
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${a.jwt}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ body: 'hello from A' }),
|
|
||||||
});
|
|
||||||
expect(cRes.status).toBe(201);
|
|
||||||
|
|
||||||
// B must receive the new-comment event for this upload. Generous timeout: SSE
|
// B must receive the new-comment event for this upload. Generous timeout: SSE
|
||||||
// frames flush on the keep-alive tick through the compressing reverse proxy.
|
// frames flush on the keep-alive tick through the compressing reverse proxy.
|
||||||
|
|||||||
@@ -8,15 +8,7 @@
|
|||||||
* of the reverse proxy's SSE buffering.
|
* of the reverse proxy's SSE buffering.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { trackStreamOpens } from '../../helpers/sse';
|
||||||
/** Count EventSource opens (GET /api/v1/stream?ticket=…), not the ticket POST. */
|
|
||||||
function trackStreamOpens(page: import('@playwright/test').Page): () => number {
|
|
||||||
let n = 0;
|
|
||||||
page.on('request', (req) => {
|
|
||||||
if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++;
|
|
||||||
});
|
|
||||||
return () => n;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Feed — SSE behavior', () => {
|
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 }) => {
|
||||||
@@ -26,15 +18,21 @@ test.describe('Feed — SSE behavior', () => {
|
|||||||
await signIn(page, g); // lands on /feed, which calls connectSse() on mount
|
await signIn(page, g); // lands on /feed, which calls connectSse() on mount
|
||||||
// Initial connection established.
|
// Initial connection established.
|
||||||
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||||
const afterInitial = streamOpens();
|
|
||||||
|
|
||||||
// Background: the visibility handler reads document.hidden, so override that
|
// Background: the visibility handler reads document.hidden, so override that
|
||||||
// (not just visibilityState) before dispatching, or the close never fires.
|
// (not just visibilityState) before dispatching, or the close never fires.
|
||||||
|
// disconnectSse() closes the EventSource and clears any reconnect timer, so no
|
||||||
|
// new stream opens while hidden.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
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'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
|
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
||||||
|
// any spurious native/error reconnect before now), so the assertion below can only
|
||||||
|
// be satisfied by a NEW open attributable to the foreground event itself.
|
||||||
|
const afterHidden = streamOpens();
|
||||||
|
|
||||||
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||||
@@ -42,8 +40,8 @@ test.describe('Feed — SSE behavior', () => {
|
|||||||
document.dispatchEvent(new Event('visibilitychange'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// A reconnect means a brand-new stream GET beyond the initial one.
|
// The reconnect is a brand-new stream GET that appears only after foregrounding.
|
||||||
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterInitial);
|
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterHidden);
|
||||||
|
|
||||||
// And the app is still functional.
|
// And the app is still functional.
|
||||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
||||||
|
|||||||
@@ -5,37 +5,10 @@
|
|||||||
* with cross-user and banned-user scenarios that span multiple resources.
|
* with cross-user and banned-user scenarios that span multiple resources.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
/** Seed a real upload owned by `jwt` and return its id. */
|
|
||||||
async function seedUpload(jwt: string, caption?: string): Promise<string> {
|
|
||||||
const body = new Uint8Array(1024);
|
|
||||||
body.set(JPEG_MAGIC, 0);
|
|
||||||
const res = await uploadRaw(jwt, body, { filename: 'a.jpg', contentType: 'image/jpeg', caption });
|
|
||||||
if (res.status !== 201) throw new Error(`seed upload failed: ${res.status} ${await res.text()}`);
|
|
||||||
return (await res.json()).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed a real comment on `uploadId` authored by `jwt`; return its id. */
|
|
||||||
async function seedComment(jwt: string, uploadId: string, body = 'mine'): Promise<string> {
|
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ body }),
|
|
||||||
});
|
|
||||||
if (res.status !== 201) throw new Error(`seed comment failed: ${res.status} ${await res.text()}`);
|
|
||||||
return (await res.json()).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listComments(jwt: string, uploadId: string): Promise<any[]> {
|
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Adversarial — deep authorization', () => {
|
test.describe('Adversarial — deep authorization', () => {
|
||||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
// 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
|
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
||||||
@@ -87,13 +60,12 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
// 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, db }) => {
|
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
|
||||||
const a = await guest('UploadOwnerA2');
|
const a = await guest('UploadOwnerA2');
|
||||||
const b = await guest('AttackerB3');
|
const b = await guest('AttackerB3');
|
||||||
|
|
||||||
const uploadId = await seedUpload(a.jwt, 'original caption');
|
// seedUpload marks compression done, so the upload is feed-visible for the read-back.
|
||||||
// Make it visible in the feed so we can read the caption back.
|
const uploadId = await seedUpload(a.jwt, { caption: 'original caption' });
|
||||||
await db.setUploadCompressionStatus(uploadId, 'done');
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -104,9 +76,7 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
|
|
||||||
// No state change: the caption A set is intact.
|
// 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 feed: any = await feedRes.json();
|
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
|
||||||
const row = list.find((u: any) => u.id === uploadId);
|
|
||||||
expect(row?.caption).toBe('original caption');
|
expect(row?.caption).toBe('original caption');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* or rejected gracefully without crashing the backend.
|
* or rejected gracefully without crashing the backend.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { mintSseTicket } from '../../helpers/sse';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
@@ -48,15 +49,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
const g = await guest('SseFlood');
|
const g = await guest('SseFlood');
|
||||||
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
|
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
|
||||||
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
|
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
|
||||||
const mintTicket = async () => {
|
// (These streams must be held open concurrently, so we can't use the openStream
|
||||||
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
|
// helper which opens-and-aborts a single stream.)
|
||||||
method: 'POST',
|
const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
|
||||||
});
|
|
||||||
const json: any = await res.json();
|
|
||||||
return json.ticket as string;
|
|
||||||
};
|
|
||||||
const tickets = await Promise.all(Array.from({ length: 10 }, mintTicket));
|
|
||||||
|
|
||||||
const controllers = tickets.map(() => new AbortController());
|
const controllers = tickets.map(() => new AbortController());
|
||||||
const requests = tickets.map((ticket, i) =>
|
const requests = tickets.map((ticket, i) =>
|
||||||
|
|||||||
@@ -7,31 +7,10 @@
|
|||||||
* on first use so it cannot be replayed.
|
* on first use so it cannot be replayed.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
async function mintTicket(jwt: string): Promise<string> {
|
|
||||||
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
|
||||||
});
|
|
||||||
if (res.status !== 200) throw new Error(`mint failed: ${res.status} ${await res.text()}`);
|
|
||||||
return (await res.json()).ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the SSE stream with a ticket, return the HTTP status, and tear the stream down. */
|
|
||||||
async function openStream(ticket: string): Promise<number> {
|
|
||||||
const c = new AbortController();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
|
||||||
signal: c.signal,
|
|
||||||
});
|
|
||||||
return res.status;
|
|
||||||
} finally {
|
|
||||||
c.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||||
test('minting a ticket requires authentication', async () => {
|
test('minting a ticket requires authentication', async () => {
|
||||||
const res = await fetch(`${BASE}/api/v1/stream/ticket`, { method: 'POST' });
|
const res = await fetch(`${BASE}/api/v1/stream/ticket`, { method: 'POST' });
|
||||||
@@ -40,7 +19,7 @@ test.describe('Adversarial — SSE ticket abuse', () => {
|
|||||||
|
|
||||||
test('a ticket is single-use: replay after first open is rejected', async ({ guest }) => {
|
test('a ticket is single-use: replay after first open is rejected', async ({ guest }) => {
|
||||||
const g = await guest('SseReplay');
|
const g = await guest('SseReplay');
|
||||||
const ticket = await mintTicket(g.jwt);
|
const ticket = await mintSseTicket(g.jwt);
|
||||||
|
|
||||||
// First open consumes the ticket.
|
// First open consumes the ticket.
|
||||||
expect(await openStream(ticket)).toBe(200);
|
expect(await openStream(ticket)).toBe(200);
|
||||||
|
|||||||
@@ -12,22 +12,10 @@
|
|||||||
* / a navigation to a `javascript:` URL.
|
* / a navigation to a `javascript:` URL.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
/** Seed a real, feed-visible upload owned by `jwt`; returns its id. */
|
|
||||||
async function seedVisibleUpload(jwt: string, caption: string, db: any): Promise<string> {
|
|
||||||
const body = new Uint8Array(1024);
|
|
||||||
body.set(JPEG_MAGIC, 0);
|
|
||||||
const res = await uploadRaw(jwt, body, { filename: 'x.jpg', contentType: 'image/jpeg', caption });
|
|
||||||
if (res.status !== 201) throw new Error(`seed upload failed: ${res.status} ${await res.text()}`);
|
|
||||||
const { id } = await res.json();
|
|
||||||
// Mark compression done so the card is fully rendered in the feed.
|
|
||||||
await db.setUploadCompressionStatus(id, 'done');
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const XSS_PAYLOADS = [
|
const XSS_PAYLOADS = [
|
||||||
`<script>window.__xssFired=true</script>`,
|
`<script>window.__xssFired=true</script>`,
|
||||||
`<img src=x onerror="window.__xssFired=true">`,
|
`<img src=x onerror="window.__xssFired=true">`,
|
||||||
@@ -76,9 +64,16 @@ test.describe('Adversarial — input injection (display name)', () => {
|
|||||||
d.dismiss().catch(() => {});
|
d.dismiss().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/feed');
|
// /account is where the viewer's own display name is rendered (the sink). /feed
|
||||||
|
// shows uploader names, not the viewer's — navigating there rendered nothing, so
|
||||||
|
// the non-firing check passed vacuously. Go where the payload actually lands.
|
||||||
|
await page.goto('/account');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
|
||||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||||
expect(fired, 'window.__xssFired should never be set').toBe(false);
|
expect(fired, 'window.__xssFired should never be set').toBe(false);
|
||||||
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
|
expect(dialogs, 'no dialogs should appear').toHaveLength(0);
|
||||||
@@ -92,11 +87,11 @@ test.describe('Adversarial — input injection (display name)', () => {
|
|||||||
|
|
||||||
test.describe('Adversarial — stored XSS (caption)', () => {
|
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||||
for (const payload of XSS_PAYLOADS) {
|
for (const payload of XSS_PAYLOADS) {
|
||||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, db, page, signIn }) => {
|
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||||
const g = await guest('CapXss');
|
const g = await guest('CapXss');
|
||||||
// A trailing marker lets us wait until the caption has actually rendered before
|
// 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.
|
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
||||||
const id = await seedVisibleUpload(g.jwt, `${payload} CAPMARK`, db);
|
const id = await seedUpload(g.jwt, { caption: `${payload} CAPMARK` });
|
||||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
const dialogs: string[] = [];
|
||||||
@@ -123,16 +118,11 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
|||||||
`"><svg onload="window.__xssFired=true">`,
|
`"><svg onload="window.__xssFired=true">`,
|
||||||
];
|
];
|
||||||
for (const payload of COMMENT_PAYLOADS) {
|
for (const payload of COMMENT_PAYLOADS) {
|
||||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, db, 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 author = await guest('CmtXss');
|
||||||
const id = await seedVisibleUpload(author.jwt, 'pic CAPMARK', db);
|
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||||
// Post the XSS comment via the API (verbatim storage).
|
// Post the XSS comment via the API (verbatim storage).
|
||||||
const cRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${author.jwt}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ body: `${payload} CMTMARK` }),
|
|
||||||
});
|
|
||||||
expect(cRes.status).toBe(201);
|
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
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(() => {}); });
|
||||||
|
|||||||
@@ -3,28 +3,21 @@
|
|||||||
* browser process.
|
* browser process.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { trackStreamOpens } from '../../helpers/sse';
|
||||||
|
|
||||||
test.describe('Browser chaos — multi-tab', () => {
|
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');
|
const g = await guest('Twin');
|
||||||
|
|
||||||
// Count each tab's own EventSource open (GET /api/v1/stream?ticket=…). Asserting
|
// Count each tab's own EventSource open. Asserting *connection establishment* is
|
||||||
// *connection establishment* is fast and reliable; asserting event *delivery* would
|
// fast and reliable; asserting event *delivery* would depend on the reverse proxy's
|
||||||
// depend on the reverse proxy's ~30s SSE buffering and isn't worth the flake here.
|
// ~30s SSE buffering and isn't worth the flake here.
|
||||||
const streamOpens = (p: import('@playwright/test').Page) => {
|
const opens1 = trackStreamOpens(page);
|
||||||
let n = 0;
|
|
||||||
p.on('request', (req) => {
|
|
||||||
if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++;
|
|
||||||
});
|
|
||||||
return () => n;
|
|
||||||
};
|
|
||||||
|
|
||||||
const opens1 = streamOpens(page);
|
|
||||||
await signIn(page, g); // → /feed, connectSse() on mount
|
await signIn(page, g); // → /feed, connectSse() on mount
|
||||||
await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
const tab2 = await context.newPage();
|
const tab2 = await context.newPage();
|
||||||
const opens2 = streamOpens(tab2);
|
const opens2 = trackStreamOpens(tab2);
|
||||||
await signIn(tab2, g);
|
await signIn(tab2, g);
|
||||||
await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user