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('never reports more seconds than the ttl or the block duration (sub-ms rounding)', async () => { const ns = randomUUID(); await newApp(ns); const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); // The column keeps milliseconds while now() has microseconds; rounding // up by < 1 ms used to turn 60.0004 s into 61. About half of all // timestamps round up, so 60 fresh keys catch it reliably. for (let i = 0; i < 60; i += 1) { const record = await storage.increment(`ms-${i}`, 60000, 0, 60000, 'd'); expect(record.timeToExpire).toBeGreaterThanOrEqual(59); expect(record.timeToExpire).toBeLessThanOrEqual(60); expect(record.isBlocked).toBe(true); expect(record.timeToBlockExpire).toBeGreaterThanOrEqual(59); expect(record.timeToBlockExpire).toBeLessThanOrEqual(60); } }); it('keeps the same bounds when a window restarts or a block starts on an existing row', async () => { const ns = randomUUID(); await newApp(ns); const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < 60; i += 1) { // Restart branch: the 1 ms window is over, the row starts again at 1. await storage.increment(`re-${i}`, 1, 10, 60000, 'd'); await sleep(5); const restarted = await storage.increment( `re-${i}`, 60000, 10, 60000, 'd', ); expect(restarted.totalHits).toBe(1); expect(restarted.timeToExpire).toBeGreaterThanOrEqual(59); expect(restarted.timeToExpire).toBeLessThanOrEqual(60); // Block branch: hits + 1 > limit on an open window sets blockedUntil. await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd'); const blocked = await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd'); expect(blocked.isBlocked).toBe(true); expect(blocked.timeToBlockExpire).toBeGreaterThanOrEqual(59); expect(blocked.timeToBlockExpire).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); }); });