Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
92 lines
2.7 KiB
TypeScript
92 lines
2.7 KiB
TypeScript
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
|
import {
|
|
API_KEY_FAILURE_LIMIT,
|
|
API_KEY_FAILURE_THROTTLER,
|
|
FailedAttemptLimiter,
|
|
} from './failed-attempt-limiter';
|
|
|
|
describe('FailedAttemptLimiter', () => {
|
|
const increment = jest.fn();
|
|
const limiter = new FailedAttemptLimiter({
|
|
increment,
|
|
} as unknown as PrismaThrottlerStorage);
|
|
|
|
function record(totalHits: number, isBlocked: boolean) {
|
|
return {
|
|
totalHits,
|
|
timeToExpire: 600,
|
|
isBlocked,
|
|
timeToBlockExpire: isBlocked ? 900 : 0,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
increment.mockReset();
|
|
increment.mockResolvedValue(record(1, false));
|
|
});
|
|
|
|
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
|
await limiter.recordFailure('203.0.113.7');
|
|
|
|
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
|
|
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
|
|
expect(increment).toHaveBeenCalledTimes(1);
|
|
expect(increment).toHaveBeenCalledWith(
|
|
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
|
|
API_KEY_FAILURE_LIMIT.windowMs,
|
|
API_KEY_FAILURE_LIMIT.maxFailures,
|
|
API_KEY_FAILURE_LIMIT.windowMs,
|
|
API_KEY_FAILURE_THROTTLER,
|
|
);
|
|
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
|
|
});
|
|
|
|
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
|
|
expect(API_KEY_FAILURE_LIMIT).toEqual({
|
|
maxFailures: 20,
|
|
windowMs: 15 * 60 * 1000,
|
|
});
|
|
});
|
|
|
|
it('sperrt nicht unterhalb der Grenze', async () => {
|
|
increment.mockResolvedValueOnce(
|
|
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
|
);
|
|
|
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
|
blocked: false,
|
|
limitReached: false,
|
|
});
|
|
});
|
|
|
|
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
|
increment.mockResolvedValueOnce(
|
|
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
|
);
|
|
|
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
|
blocked: false,
|
|
limitReached: true,
|
|
});
|
|
});
|
|
|
|
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
|
increment.mockResolvedValueOnce(
|
|
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
|
);
|
|
|
|
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
|
blocked: true,
|
|
limitReached: false,
|
|
});
|
|
});
|
|
|
|
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
|
increment.mockRejectedValueOnce(new Error('db down'));
|
|
|
|
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
|
'db down',
|
|
);
|
|
});
|
|
});
|