import { randomUUID } from 'node:crypto'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; // Public batches allow 30 requests per minute and client network. const PUBLIC_BATCH_LIMIT = 30; const PARALLEL_REQUESTS = 40; describe('Database-backed throttler (e2e)', () => { const suffix = randomUUID(); const path = `/api/v1/public/batches/NONE-${suffix}`; const namespaces: string[] = []; const apps: INestApplication[] = []; let prisma: PrismaService; const newApp = async ( throttlerNamespace: string, ): Promise> => { namespaces.push(throttlerNamespace); const app = await createTestApp({ throttlerNamespace }); apps.push(app); prisma = app.get(PrismaService); return app; }; const status = async (app: INestApplication): Promise => (await request(app.getHttpServer()).get(path)).status; afterAll(async () => { for (const ns of namespaces) { await prisma.throttlerHit.deleteMany({ where: { key: { startsWith: `${ns}:` } }, }); } for (const app of apps) await app.close(); }); it('counts across two app instances sharing one namespace', async () => { const ns = randomUUID(); const [a, b] = [await newApp(ns), await newApp(ns)]; for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) { await request((i % 2 ? a : b).getHttpServer()) .get(path) .expect(404); } await request(a.getHttpServer()).get(path).expect(429); await request(b.getHttpServer()).get(path).expect(429); }); it('40 parallel requests over two instances yield exactly 10 × 429', async () => { const ns = randomUUID(); const [a, b] = [await newApp(ns), await newApp(ns)]; const statuses = await Promise.all( Array.from({ length: PARALLEL_REQUESTS }, (_, i) => status(i % 2 ? a : b), ), ); expect(statuses.filter((s) => s === 429)).toHaveLength( PARALLEL_REQUESTS - PUBLIC_BATCH_LIMIT, ); expect(statuses.filter((s) => s === 404)).toHaveLength(PUBLIC_BATCH_LIMIT); }); it('separate namespaces never share counters', async () => { const [a, b] = [await newApp(randomUUID()), await newApp(randomUUID())]; for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) { await request(a.getHttpServer()).get(path).expect(404); } await request(a.getHttpServer()).get(path).expect(429); await request(b.getHttpServer()).get(path).expect(404); }); it('an expired window starts again at 1', async () => { const ns = randomUUID(); await newApp(ns); const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); for (let i = 0; i < 3; i += 1) { await storage.increment('window', 60000, 10, 60000, 'default'); } // The window ends in the past, decided by the database clock. await prisma.$executeRaw` UPDATE "ThrottlerHit" SET "expiresAt" = now() - interval '1 second' WHERE "key" = ${`${ns}:window`}`; const record = await storage.increment( 'window', 60000, 10, 60000, 'default', ); expect(record.totalHits).toBe(1); expect(record.isBlocked).toBe(false); expect(record.timeToExpire).toBeGreaterThan(0); expect(record.timeToExpire).toBeLessThanOrEqual(60); }); it('keeps hits while blocked and releases the block after it expired', async () => { const ns = randomUUID(); await newApp(ns); const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); await storage.increment('block', 60000, 1, 60000, 'default'); const blocked = await storage.increment( 'block', 60000, 1, 60000, 'default', ); expect(blocked.isBlocked).toBe(true); expect(blocked.totalHits).toBe(2); expect(blocked.timeToBlockExpire).toBeGreaterThan(0); const stillBlocked = await storage.increment( 'block', 60000, 1, 60000, 'default', ); expect(stillBlocked.isBlocked).toBe(true); expect(stillBlocked.totalHits).toBe(2); await prisma.$executeRaw` UPDATE "ThrottlerHit" SET "blockedUntil" = now() - interval '1 second' WHERE "key" = ${`${ns}:block`}`; const released = await storage.increment( 'block', 60000, 1, 60000, 'default', ); expect(released.isBlocked).toBe(false); expect(released.totalHits).toBe(1); expect(released.timeToBlockExpire).toBe(0); }); it('blocks the first hit when the limit is 0', async () => { const ns = randomUUID(); await newApp(ns); const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); const first = await storage.increment('zero', 60000, 0, 60000, 'default'); expect(first.isBlocked).toBe(true); expect(first.totalHits).toBe(1); // Same after a window or block has expired. await prisma.$executeRaw` UPDATE "ThrottlerHit" SET "expiresAt" = now() - interval '1 second', "blockedUntil" = now() - interval '1 second' WHERE "key" = ${`${ns}:zero`}`; const again = await storage.increment('zero', 60000, 0, 60000, 'default'); expect(again.isBlocked).toBe(true); expect(again.totalHits).toBe(1); }); });