feat(security): share rate limits across instances, drain syncs on shutdown

- 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>
This commit is contained in:
MastersGame
2026-11-08 12:21:15 +01:00
parent 35a7c27cb7
commit 3dd5369175
14 changed files with 414 additions and 34 deletions

View File

@@ -10,14 +10,18 @@ import { PrismaService } from './../prisma/prisma.service';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
const hiboutikService = { startSync: jest.fn() };
// Rate limiting counters (ThrottlerDatabaseStorage): the database answers "not blocked" unless a test says otherwise
const notBlocked = { totalHits: 1, timeToExpire: 60, isBlocked: false, timeToBlockExpire: 0 };
const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) } };
beforeEach(async () => {
hiboutikService.startSync.mockReset();
prisma.$queryRaw.mockReset().mockResolvedValue([notBlocked]);
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue({})
.useValue(prisma)
.overrideProvider(HiboutikService)
.useValue(hiboutikService)
.compile();
@@ -36,6 +40,13 @@ describe('AppController (e2e)', () => {
return request(app.getHttpServer()).post('/sync').expect(409);
});
it('POST /sync answers 429 when the shared rate limiting counter is blocked', async () => {
prisma.$queryRaw.mockResolvedValue([{ totalHits: 6, timeToExpire: 30, isBlocked: true, timeToBlockExpire: 30 }]);
await request(app.getHttpServer()).post('/sync').expect(429).expect('Retry-After', '30');
expect(hiboutikService.startSync).not.toHaveBeenCalled();
});
afterEach(async () => {
await app.close();
});