tests/stress: add Node-based adversarial stress harness

Two suites under tests/stress/, plus a tmux-friendly run_loop.sh
runner. Both boot a fresh uvicorn on an isolated DB per cycle and
log JSON line summaries to runs/.

api_stress.mjs covers WS-level scenarios that the existing pytest
suite does not exercise: 20-student happy path, late joiners with
correct remaining_ms, mid-question disconnect, browser-sleep + wake
to a different question_idx, cookie tampering and cross-session
cookie reuse, duplicate student_id, bad submit (out-of-order, wrong
idx, resubmit no-op), close-boundary race with auto-close, malformed
JSON fuzz, and flaky reconnect.

ui_stress.mjs drives the same flows in a real Chromium context via
playwright: happy UI, sleep/wake by closing+reopening a context with
the persisted cookie, document.cookie tampering attempt, and two
browser contexts joining with the same student_id.

Findings will be summarised in runs/summary.jsonl over time. One known
issue surfaces from the fuzz scenario: app/room.py student_ws's
receive_json call propagates JSONDecodeError out of the only
try/except (which catches WebSocketDisconnect), killing that client's
WS handler. Other clients are unaffected.
This commit is contained in:
ameer
2026-05-02 15:26:18 +08:00
parent 0f8824bd43
commit 95a4dd2475
8 changed files with 1095 additions and 0 deletions

181
tests/stress/ui_stress.mjs Normal file
View File

@@ -0,0 +1,181 @@
// UI-side stress: real Chromium browser contexts driving the SPA.
// Tests scenarios that only matter at the JS layer:
// - happy path through the student SPA UI
// - sleep/wake (browser context closed mid-quiz, instructor advances, browser reopens)
// - cookie tampering via document.cookie
// - simultaneous browsers with same student_id
// Boots its own server. Slower than api_stress but exercises real DOM rendering.
import { bootServer, setupSession, STRESS_POOL, sleep, logLine, jsonReq, CookieJar, Admin } from "./lib.mjs";
import { chromium } from "playwright";
const SEED = parseInt(process.argv[2] || Date.now(), 10);
const PORT = parseInt(process.argv[3] || (8300 + (SEED % 100)), 10);
const HEADLESS = process.env.HEADLESS !== "0";
let pass = 0, fail = 0, warn = 0;
const failures = [];
function expect(cond, scenario, msg, extra = {}) {
if (cond) { pass++; logLine(scenario, "pass", msg, extra); }
else { fail++; failures.push({ scenario, msg, extra }); logLine(scenario, "fail", msg, extra); }
}
function note(scenario, msg, extra = {}) { warn++; logLine(scenario, "note", msg, extra); }
async function joinAsStudent(page, baseUrl, sid, sid_id, name) {
await page.goto(`${baseUrl}/?sid=${sid}`);
await page.waitForSelector('input[name="student_id"], input[id*=student]', { timeout: 5000 });
// Codex SPA uses input[name=student_id]
const idInput = await page.$('input[name="student_id"]');
const nameInput = await page.$('input[name="name"]');
if (!idInput || !nameInput) throw new Error("join form fields not found");
await idInput.fill(sid_id);
await nameInput.fill(name);
await page.click('button:has-text("Join")');
await page.waitForSelector('text=Waiting for instructor', { timeout: 5000 });
}
async function adminOpenQuestion(server, jar, sid, qIdx, timeLimit = 10) {
// Open via admin instructor WS
const admin = new Admin(server.url, sid, jar);
await admin.connect();
const w = admin.waitFor("question_open", { timeoutMs: 5000 });
admin.open(qIdx, timeLimit);
await w;
return admin;
}
// Scenario 1: happy path through the SPA
async function uiHappy(server, browser) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await joinAsStudent(page, server.url, sid, "U1", "UIStudent");
const admin = await adminOpenQuestion(server, jar, sid, 0, 10);
await page.waitForSelector('text=2+2?', { timeout: 5000 });
expect(true, "ui_happy", "Q1 text rendered in browser");
await page.click('button:has-text("B")');
await page.waitForSelector('text=Submitted in', { timeout: 5000 });
expect(true, "ui_happy", "submitted view shown");
const closedW = admin.waitFor("question_closed", { timeoutMs: 5000 });
admin.close();
await closedW;
await page.waitForSelector('text=Reveal', { timeout: 5000 });
expect(true, "ui_happy", "reveal view shown");
admin.disconnect();
await ctx.close();
}
// Scenario 2: sleep/wake via real browser context close-and-reopen, with persisted cookie.
async function uiSleepWake(server, browser) {
const { sid, jar } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
await joinAsStudent(page1, server.url, sid, "U2", "Sleeper");
// Capture the cookie so we can restore it in a fresh context (simulating phone-wake on same device)
const cookies = await ctx1.cookies();
const adminA = await adminOpenQuestion(server, jar, sid, 0, 10);
await page1.waitForSelector('text=2+2?', { timeout: 5000 });
await page1.click('button:has-text("B")');
await page1.waitForSelector('text=Submitted in', { timeout: 5000 });
// "Phone goes to sleep" — close the context entirely
await ctx1.close();
// Instructor closes, advances, opens Q2 (skip 1)
let cw = adminA.waitFor("question_closed", { timeoutMs: 5000 });
adminA.close();
await cw;
adminA.next();
await sleep(150);
let ow = adminA.waitFor("question_open", { timeoutMs: 5000 });
adminA.open(2, 10);
await ow;
// "Phone wakes" — fresh context with same persisted cookie
const ctx2 = await browser.newContext();
await ctx2.addCookies(cookies);
const page2 = await ctx2.newPage();
await page2.goto(`${server.url}/?sid=${sid}`);
// Should see Q3 (idx 2) text "Fastest sort?"
try {
await page2.waitForSelector('text=Fastest sort?', { timeout: 5000 });
expect(true, "ui_sleep_wake", "browser shows the LATEST question after wake");
} catch (e) {
expect(false, "ui_sleep_wake", "browser did NOT show latest question after wake", { err: e.message });
}
// Try to submit on the new question
try {
await page2.click('button:has-text("B")');
await page2.waitForSelector('text=Submitted in', { timeout: 5000 });
expect(true, "ui_sleep_wake", "post-wake submit acked in UI");
} catch (e) {
expect(false, "ui_sleep_wake", "post-wake submit failed in UI", { err: e.message });
}
adminA.disconnect();
await ctx2.close();
}
// Scenario 3: cookie tampering via document.cookie (browser cookie is HttpOnly so this should be a no-op)
async function uiCookieTamper(server, browser) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctx = await browser.newContext();
const page = await ctx.newPage();
await joinAsStudent(page, server.url, sid, "U3", "Tamper");
// Confirm document.cookie does NOT see qz_student (HttpOnly)
const visible = await page.evaluate(() => document.cookie);
expect(!visible.includes("qz_student"), "ui_cookie_tamper", "qz_student is not visible to JS (HttpOnly verified)", { document_cookie: visible });
// Try to overwrite (browsers will silently ignore HttpOnly overwrite from JS)
await page.evaluate(() => { document.cookie = "qz_student=GARBAGE; path=/"; });
await page.reload();
// Should still be in lobby (cookie wasn't actually changed)
try {
await page.waitForSelector('text=Waiting for instructor', { timeout: 4000 });
expect(true, "ui_cookie_tamper", "tamper attempt did not log student out");
} catch (e) {
note("ui_cookie_tamper", `tamper may have succeeded (lobby not re-rendered): ${e.message}`);
}
await ctx.close();
}
// Scenario 4: two browser contexts with same student_id race (different cookies → 2 participants)
async function uiDupId(server, browser) {
const { sid } = await setupSession(server.url, server.adminPw, STRESS_POOL);
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pA = await ctxA.newPage();
const pB = await ctxB.newPage();
await joinAsStudent(pA, server.url, sid, "DUPUI", "FirstBrowser");
await joinAsStudent(pB, server.url, sid, "DUPUI", "SecondBrowser");
expect(true, "ui_dup_id", "two browsers with same student_id both reach lobby");
await ctxA.close(); await ctxB.close();
}
const SCENARIOS = [
["ui_happy", uiHappy],
["ui_sleep_wake", uiSleepWake],
["ui_cookie_tamper", uiCookieTamper],
["ui_dup_id", uiDupId],
];
logLine("runner", "info", "starting ui stress", { seed: SEED, port: PORT, headless: HEADLESS });
const browser = await chromium.launch({ headless: HEADLESS });
let server = null;
try {
server = await bootServer({ port: PORT });
for (const [name, fn] of SCENARIOS) {
logLine(name, "start", `seed=${SEED}`);
try {
await fn(server, browser);
logLine(name, "ok", "scenario completed");
} catch (e) {
fail++;
failures.push({ scenario: name, msg: "uncaught", extra: { err: e.message } });
logLine(name, "fail", "uncaught exception", { err: e.message });
}
}
} finally {
if (server) await server.stop();
await browser.close();
}
logLine("runner", "summary", "done", { pass, fail, warn, failures });
process.exit(fail > 0 ? 1 : 0);