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()`.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user