Merge test/review-followups: dedup helpers + XSS render guard + SSE hardening
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()`.
|
||||
*/
|
||||
|
||||
import { mintSseTicket } from './sse';
|
||||
|
||||
export type SseEvent = { type: string; data: any; receivedAt: number };
|
||||
|
||||
export class SseListener {
|
||||
@@ -20,14 +22,7 @@ export class SseListener {
|
||||
async start(token: string): Promise<void> {
|
||||
// Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer
|
||||
// accepts ?token=).
|
||||
const ticketRes = await fetch(`${this.baseUrl}/api/v1/stream/ticket`, {
|
||||
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 ticket = await mintSseTicket(token);
|
||||
const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`;
|
||||
// Use fetch with streaming since Node has no EventSource by default.
|
||||
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) {
|
||||
await this.nameInput.fill(name);
|
||||
// Filling the 4th digit fires the form's auto-submit ($effect on
|
||||
// pin.length === 4, see pin-auto-submit.spec). An explicit submit click would
|
||||
// race the ensuing navigation and detach mid-click, so only click as a
|
||||
// fallback if the button is still around (e.g. a partial / failed PIN).
|
||||
// Filling the 4th digit fires the form's auto-submit (the onPinInput handler
|
||||
// calls handleRecover once pin.length === 4, see pin-auto-submit.spec). An explicit
|
||||
// submit click would race the ensuing navigation and detach mid-click, so only click
|
||||
// as a fallback if the button is still around (e.g. a partial / failed PIN).
|
||||
await this.pinInput.fill(pin);
|
||||
if (await this.submitButton.isEnabled().catch(() => false)) {
|
||||
await this.submitButton.click().catch(() => {
|
||||
|
||||
@@ -7,20 +7,10 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
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';
|
||||
|
||||
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> {
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||
method: 'POST',
|
||||
@@ -29,43 +19,38 @@ async function like(jwt: string, uploadId: string): Promise<number> {
|
||||
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('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 liker = await guest('Liker');
|
||||
const uploadId = await seedUpload(author.jwt, db);
|
||||
const uploadId = await seedUpload(author.jwt);
|
||||
|
||||
// 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.liked_by_me).toBe(false);
|
||||
|
||||
// First like → counted exactly once.
|
||||
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.liked_by_me).toBe(true);
|
||||
|
||||
// Liking again is a toggle → back to zero (guards against a regression that
|
||||
// double-counts a repeated like instead of removing it).
|
||||
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.liked_by_me).toBe(false);
|
||||
|
||||
// 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(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);
|
||||
});
|
||||
|
||||
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
|
||||
// both the stream connect and the delivery may cost up to ~30s each.
|
||||
test.setTimeout(120_000);
|
||||
@@ -77,13 +62,8 @@ test.describe('Feed — like + comment', () => {
|
||||
await sse.start(b.jwt);
|
||||
|
||||
try {
|
||||
const uploadId = await seedUpload(a.jwt, db, 'pic');
|
||||
const cRes = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${a.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: 'hello from A' }),
|
||||
});
|
||||
expect(cRes.status).toBe(201);
|
||||
const uploadId = await seedUpload(a.jwt, { caption: 'pic' });
|
||||
await seedComment(a.jwt, uploadId, 'hello from A');
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -8,15 +8,7 @@
|
||||
* of the reverse proxy's SSE buffering.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
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 }) => {
|
||||
@@ -26,15 +18,21 @@ test.describe('Feed — SSE behavior', () => {
|
||||
await signIn(page, g); // lands on /feed, which calls connectSse() on mount
|
||||
// Initial connection established.
|
||||
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||
const afterInitial = streamOpens();
|
||||
|
||||
// Background: the visibility handler reads document.hidden, so override that
|
||||
// (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(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
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
|
||||
// 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.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
@@ -42,8 +40,8 @@ test.describe('Feed — SSE behavior', () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// A reconnect means a brand-new stream GET beyond the initial one.
|
||||
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterInitial);
|
||||
// The reconnect is a brand-new stream GET that appears only after foregrounding.
|
||||
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterHidden);
|
||||
|
||||
// And the app is still functional.
|
||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
||||
|
||||
@@ -5,37 +5,10 @@
|
||||
* with cross-user and banned-user scenarios that span multiple resources.
|
||||
*/
|
||||
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';
|
||||
|
||||
/** 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', () => {
|
||||
// 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
|
||||
@@ -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.
|
||||
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 b = await guest('AttackerB3');
|
||||
|
||||
const uploadId = await seedUpload(a.jwt, 'original caption');
|
||||
// Make it visible in the feed so we can read the caption back.
|
||||
await db.setUploadCompressionStatus(uploadId, 'done');
|
||||
// seedUpload marks compression done, so the upload is feed-visible for the read-back.
|
||||
const uploadId = await seedUpload(a.jwt, { caption: 'original caption' });
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -104,9 +76,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
|
||||
// No state change: the caption A set is intact.
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
|
||||
const feed: any = await feedRes.json();
|
||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||
const row = list.find((u: any) => u.id === uploadId);
|
||||
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||
expect(row?.caption).toBe('original caption');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* or rejected gracefully without crashing the backend.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket } from '../../helpers/sse';
|
||||
|
||||
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');
|
||||
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
|
||||
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
|
||||
const mintTicket = async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
const json: any = await res.json();
|
||||
return json.ticket as string;
|
||||
};
|
||||
const tickets = await Promise.all(Array.from({ length: 10 }, mintTicket));
|
||||
// (These streams must be held open concurrently, so we can't use the openStream
|
||||
// helper which opens-and-aborts a single stream.)
|
||||
const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));
|
||||
|
||||
const controllers = tickets.map(() => new AbortController());
|
||||
const requests = tickets.map((ticket, i) =>
|
||||
|
||||
@@ -7,31 +7,10 @@
|
||||
* on first use so it cannot be replayed.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||
|
||||
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('minting a ticket requires authentication', async () => {
|
||||
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 }) => {
|
||||
const g = await guest('SseReplay');
|
||||
const ticket = await mintTicket(g.jwt);
|
||||
const ticket = await mintSseTicket(g.jwt);
|
||||
|
||||
// First open consumes the ticket.
|
||||
expect(await openStream(ticket)).toBe(200);
|
||||
|
||||
@@ -12,22 +12,10 @@
|
||||
* / a navigation to a `javascript:` URL.
|
||||
*/
|
||||
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';
|
||||
|
||||
/** 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 = [
|
||||
`<script>window.__xssFired=true</script>`,
|
||||
`<img src=x onerror="window.__xssFired=true">`,
|
||||
@@ -76,9 +64,16 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
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');
|
||||
|
||||
// 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);
|
||||
expect(fired, 'window.__xssFired should never be set').toBe(false);
|
||||
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)', () => {
|
||||
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');
|
||||
// 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.
|
||||
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}$/);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
@@ -123,16 +118,11 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
`"><svg onload="window.__xssFired=true">`,
|
||||
];
|
||||
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 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).
|
||||
const cRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${author.jwt}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: `${payload} CMTMARK` }),
|
||||
});
|
||||
expect(cRes.status).toBe(201);
|
||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
|
||||
@@ -3,28 +3,21 @@
|
||||
* browser process.
|
||||
*/
|
||||
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 }) => {
|
||||
const g = await guest('Twin');
|
||||
|
||||
// Count each tab's own EventSource open (GET /api/v1/stream?ticket=…). Asserting
|
||||
// *connection establishment* is fast and reliable; asserting event *delivery* would
|
||||
// depend on the reverse proxy's ~30s SSE buffering and isn't worth the flake here.
|
||||
const streamOpens = (p: import('@playwright/test').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);
|
||||
// Count each tab's own EventSource open. Asserting *connection establishment* is
|
||||
// fast and reliable; asserting event *delivery* would depend on the reverse proxy's
|
||||
// ~30s SSE buffering and isn't worth the flake here.
|
||||
const opens1 = trackStreamOpens(page);
|
||||
await signIn(page, g); // → /feed, connectSse() on mount
|
||||
await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const tab2 = await context.newPage();
|
||||
const opens2 = streamOpens(tab2);
|
||||
const opens2 = trackStreamOpens(tab2);
|
||||
await signIn(tab2, g);
|
||||
await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user