- Rate limiting counters live in PostgreSQL (throttler_records), shared by every instance, one atomic upsert per request; fails open. - On shutdown, new syncs are refused (503) and the running one is waited for up to SYNC_SHUTDOWN_TIMEOUT; Prisma disconnects last. - Hiboutik calls never follow redirects and are capped at 50 MB. - Prisma errors no longer print query arguments (errorFormat minimal). - In production, DATABASE_URL must require TLS. - The sync lock connection times out after 10 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57 lines
2.6 KiB
TypeScript
57 lines
2.6 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { ThrottlerDatabaseStorage } from './throttlerStorage.database.repository';
|
|
|
|
// The SQL itself (window, block, reset) was checked on a real Postgres engine; these tests cover the TypeScript side.
|
|
describe('ThrottlerDatabaseStorage', () => {
|
|
const record = { totalHits: 2, timeToExpire: 40, isBlocked: false, timeToBlockExpire: 0 };
|
|
const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn() } };
|
|
let logError: jest.SpyInstance;
|
|
let random: jest.Mock;
|
|
let storage: ThrottlerDatabaseStorage;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
prisma.$queryRaw.mockResolvedValue([record]);
|
|
prisma.throttlerRecord.deleteMany.mockResolvedValue({ count: 0 });
|
|
random = jest.fn().mockReturnValue(0.5);
|
|
storage = new ThrottlerDatabaseStorage(prisma as never, random);
|
|
});
|
|
|
|
it('returns the record computed by the database, with parameters bound (never interpolated)', async () => {
|
|
await expect(storage.increment('key-hash', 60_000, 5, 30_000)).resolves.toEqual(record);
|
|
|
|
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [TemplateStringsArray, ...unknown[]];
|
|
expect(values).toEqual(['key-hash', 60_000, 5, 30_000]);
|
|
expect(strings.join('?')).not.toContain('key-hash');
|
|
});
|
|
|
|
it('purges the finished counters on about one request in 100', async () => {
|
|
await storage.increment('k', 60_000, 5, 60_000);
|
|
expect(prisma.throttlerRecord.deleteMany).not.toHaveBeenCalled();
|
|
|
|
random.mockReturnValue(0.001);
|
|
await storage.increment('k', 60_000, 5, 60_000);
|
|
expect(prisma.throttlerRecord.deleteMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('lets the request through when the database fails, logging at most once a minute', async () => {
|
|
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
|
|
|
|
await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toMatchObject({ isBlocked: false });
|
|
await storage.increment('k', 60_000, 5, 60_000);
|
|
|
|
expect(logError).toHaveBeenCalledTimes(1);
|
|
expect(logError).toHaveBeenCalledWith(expect.stringContaining('connection refused'));
|
|
});
|
|
|
|
it('does not fail the request when the purge fails', async () => {
|
|
random.mockReturnValue(0);
|
|
prisma.throttlerRecord.deleteMany.mockRejectedValue(new Error('boom'));
|
|
|
|
await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toEqual(record);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(logError).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
});
|
|
});
|