Merge main checkpoint c01a388 into feat/erp-inventar-export
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]>
This commit is contained in:
commit
53fab102f2
347 files changed
+26087
-2141
No files matched your search
@@ -1,112 +1,91 @@
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
API_KEY_FAILURE_THROTTLER,
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
|
||||
describe('FailedAttemptLimiter', () => {
|
||||
let now: number;
|
||||
let limiter: 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(() => {
|
||||
now = 1_000_000;
|
||||
limiter = new FailedAttemptLimiter({
|
||||
maxFailures: 3,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 100,
|
||||
clock: () => now,
|
||||
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 eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||
);
|
||||
|
||||
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('trennt Quellen voneinander', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
||||
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||
now += 60_001;
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
||||
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||
});
|
||||
|
||||
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
limitReached: false,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
now += 60_001;
|
||||
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(1);
|
||||
expect(small.isBlocked('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 5,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
});
|
||||
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 2,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('angreifer');
|
||||
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
||||
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
||||
small.recordFailure('neu');
|
||||
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||
);
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
||||
expect(small.isBlocked('angreifer')).toBe(true);
|
||||
// Verdrängt wurde der nicht gesperrte Eintrag.
|
||||
expect(small.recordFailure('harmlos')).toBe(false);
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
limitReached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
||||
const small = new FailedAttemptLimiter({
|
||||
maxFailures: 1,
|
||||
windowMs: 60_000,
|
||||
maxTrackedSources: 2,
|
||||
clock: () => now,
|
||||
});
|
||||
small.recordFailure('a');
|
||||
small.recordFailure('b');
|
||||
small.recordFailure('c');
|
||||
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||
);
|
||||
|
||||
expect(small.trackedSources()).toBe(2);
|
||||
expect(small.isBlocked('a')).toBe(false);
|
||||
expect(small.isBlocked('b')).toBe(true);
|
||||
expect(small.isBlocked('c')).toBe(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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user