Files
ApiCore/ApiCore-Backend/test/throttler.e2e-spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 6edae3efec
Backend E2E / e2e (push) Failing after 7m26s
Backend Image / build-and-push (push) Failing after 4m20s
Frontend Image / build-and-push (push) Successful in 15m44s
test: Backend-e2e in CI stabil (eigene Jahre, Sperren, ein Server je App)
- daily-evaluation nutzt die Jahre 2011/2012 statt 2001, damit der Job nicht
  die Daten von autopilot und reminders liest und verändert.
- throttler: app.listen(0) einmal je App statt eines Servers je Anfrage
  (ECONNRESET bei 40 parallelen Anfragen).
- outbox-claims: 120 s Zeitlimit für 2000 sequentielle Anweisungen.
- migration-harness: eine `migrate deploy` zur Zeit über eine eigene
  Advisory-Sperre (P1002 bei parallelen Migrations-Suites).
- authority-notices und authority-notices-herd laufen über
  exclusive-suite nacheinander; Herd-Wiederholung räumt eigene Entwürfe.

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:55:56 +02:00

227 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<App>[] = [];
let prisma: PrismaService;
const newApp = async (
throttlerNamespace: string,
): Promise<INestApplication<App>> => {
namespaces.push(throttlerNamespace);
const app = await createTestApp({ throttlerNamespace });
// Listen once: otherwise supertest opens a throwaway server per request
// and 40 parallel ones reset connections (ECONNRESET) on a loaded CI box.
await app.listen(0);
apps.push(app);
prisma = app.get(PrismaService);
return app;
};
const status = async (app: INestApplication<App>): Promise<number> =>
(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<void> =>
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);
});
});