/** * Phase 2 adversarial — small-scale DDoS / oversized-body tests. These are * NOT real load tests. We just verify that obvious abuse is rate-limited * or rejected gracefully without crashing the backend. */ import { test, expect } from '../../fixtures/test'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; test.describe('Adversarial — small-scale abuse', () => { // Note: the truncate auto-fixture resets every rate-limit toggle back to false // before each test, so we re-enable in beforeEach (not beforeAll). test.beforeEach(async ({ api, adminToken }) => { await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' }); }); test('20 parallel /join from one IP — rate limiter catches the excess', async () => { const requests = Array.from({ length: 20 }, (_, i) => fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: `Flood${i}_${Date.now()}` }), }) ); const statuses = (await Promise.all(requests)).map((r) => r.status); // 5/min limit → at least some should be 429. expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0); // Server stays up — at least one succeeded. expect(statuses.some((s) => s === 201 || s === 409)).toBe(true); }); test('10 MB comment body is rejected (multipart-less endpoint)', async ({ guest }) => { const g = await guest('BigComment'); const huge = 'A'.repeat(10 * 1024 * 1024); const res = await fetch(`${BASE}/api/v1/upload/00000000-0000-0000-0000-000000000000/comments`, { method: 'POST', headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ body: huge }), }); // 400 (length cap), 404 (no such upload), 413 (payload too large), 429 (rate-limited), // or 502 (Caddy rejected the body before it reached the backend) — all fine. expect([400, 404, 413, 429, 502]).toContain(res.status); // Not 200 — that would mean we accepted a 10 MB comment. expect(res.status).not.toBe(200); }); test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => { const g = await guest('SseFlood'); const controllers = Array.from({ length: 10 }, () => new AbortController()); const requests = controllers.map((c) => fetch(`${BASE}/api/v1/stream?token=${encodeURIComponent(g.jwt)}`, { signal: c.signal }) ); const responses = await Promise.all(requests); // All accepted (or some rate-limited — both fine). for (const r of responses) { expect([200, 429]).toContain(r.status); } // Tear them all down so the next test doesn't see leaked connections. controllers.forEach((c) => c.abort()); // Sanity: a new request still works. const ping = await fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${g.jwt}` }, }); expect(ping.status).toBe(200); }); test('malformed JSON in /join is rejected with 400, not 500', async () => { const res = await fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"display_name":', }); expect([400, 422]).toContain(res.status); expect(res.status).not.toBe(500); }); });