Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher des Pods und ist auf 10.000 Adressen begrenzt. Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx) und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt, widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
78 lines
2.6 KiB
TypeScript
78 lines
2.6 KiB
TypeScript
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
|
|
|
describe('FailedAttemptLimiter', () => {
|
|
let now: number;
|
|
let limiter: FailedAttemptLimiter;
|
|
|
|
beforeEach(() => {
|
|
now = 1_000_000;
|
|
limiter = new FailedAttemptLimiter({
|
|
maxFailures: 3,
|
|
windowMs: 60_000,
|
|
maxTrackedSources: 100,
|
|
clock: () => now,
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
// 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,
|
|
});
|
|
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);
|
|
});
|
|
});
|