/** * Regression guard — likes, comments and comment deletions are rate limited. * * These were the only mutating endpoints in the app with no limit at all. Every other write path * -- upload, join, recover, export, admin login -- carried one; `social.rs` carried none, so the * coverage was asymmetric rather than deliberately open. * * Severity is genuinely low for an invited-guest event, and the amplification worry is contained: * a like does fan an SSE broadcast to every connected client, but the export regeneration a * comment deletion triggers is debounced (REGEN_DEBOUNCE 20s) and superseded workers are inert. So * this closes the gap for symmetry, and the ceiling is set well above anything a real guest * produces -- it bounds a script, not an enthusiastic double-tapper. * * The bucket is shared across all three actions on purpose: separate buckets would let a caller * triple the aggregate write rate just by alternating between them. That is what the second test * pins, and it is the part most likely to be lost in a refactor. * * Keyed per USER, not per IP — at a venue every guest is behind one NAT, so an IP key would hand * the whole party one bucket. Third test. */ import { test, expect } from '../../fixtures/test'; import { seedUpload } from '../../helpers/seed'; import { BASE } from '../../helpers/env'; const like = (jwt: string, uploadId: string) => fetch(`${BASE}/api/v1/upload/${uploadId}/like`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, }); const comment = (jwt: string, uploadId: string, body: string) => fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ body }), }); test.describe('Social — rate limit', () => { test('a burst of likes past the ceiling returns 429 with Retry-After', async ({ api, adminToken, guest, }) => { await api.patchConfig(adminToken, { rate_limits_enabled: 'true', social_rate_enabled: 'true', social_rate_per_min: '3', }); const g = await guest('Tapper'); const uploadId = await seedUpload(g.jwt); // Sequential, not parallel: a toggle flips state, so ordering matters for the assertion. const statuses: number[] = []; for (let i = 0; i < 5; i++) statuses.push((await like(g.jwt, uploadId)).status); expect(statuses.slice(0, 3), 'the first three are within the ceiling').toEqual([200, 200, 200]); expect(statuses.slice(3), 'everything past it is refused').toEqual([429, 429]); const limited = await like(g.jwt, uploadId); expect(limited.status).toBe(429); expect( limited.headers.get('retry-after'), 'a 429 without Retry-After tells the client nothing about when to come back' ).toBeTruthy(); }); test('likes and comments share one bucket', async ({ api, adminToken, guest }) => { // THE assertion. Per-action buckets would let a caller triple the aggregate write rate by // alternating, which defeats the point of having a ceiling at all. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', social_rate_enabled: 'true', social_rate_per_min: '2', }); const g = await guest('Mixer'); const uploadId = await seedUpload(g.jwt); expect((await like(g.jwt, uploadId)).status).toBe(200); expect((await comment(g.jwt, uploadId, 'schön!')).status).toBe(201); // Two writes spent, whichever endpoints they went to. expect( (await comment(g.jwt, uploadId, 'noch eins')).status, 'a comment must consume the same budget a like does' ).toBe(429); expect((await like(g.jwt, uploadId)).status).toBe(429); }); test('one guest hitting the ceiling does not block another', async ({ api, adminToken, guest, }) => { // Keyed per user, not per IP. Every request in this suite comes from one address, which is // exactly the venue-NAT shape that made the /join and /feed limits turn guests away. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', social_rate_enabled: 'true', social_rate_per_min: '2', }); const noisy = await guest('Noisy'); const quiet = await guest('Quiet'); const uploadId = await seedUpload(noisy.jwt); for (let i = 0; i < 3; i++) await like(noisy.jwt, uploadId); expect((await like(noisy.jwt, uploadId)).status).toBe(429); expect( (await like(quiet.jwt, uploadId)).status, 'a second guest behind the same IP must have their own budget' ).toBe(200); }); test('flipping social_rate_enabled off bypasses the limit', async ({ api, adminToken, guest, }) => { // The toggle has to actually be honoured, or the admin switch is decorative — the failure // mode two other per-area toggles already shipped with. await api.patchConfig(adminToken, { rate_limits_enabled: 'true', social_rate_enabled: 'false', social_rate_per_min: '2', }); const g = await guest('Unlimited'); const uploadId = await seedUpload(g.jwt); const statuses: number[] = []; for (let i = 0; i < 6; i++) statuses.push((await like(g.jwt, uploadId)).status); expect(statuses.every((s) => s === 200)).toBe(true); }); });