150 lines
4.3 KiB
TypeScript
150 lines
4.3 KiB
TypeScript
import { metrics } from "@opentelemetry/api";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { resetHibpMetricsCache } from "../observability/metrics.js";
|
|
import { RATE_LIMIT_MIN_GAP_MS, RateLimitGate } from "./rate-limit-gate.js";
|
|
|
|
interface CounterCall {
|
|
name: string;
|
|
value: number;
|
|
attrs: Record<string, string>;
|
|
}
|
|
|
|
function makeFakeMeterProvider(): {
|
|
counters: CounterCall[];
|
|
install(): void;
|
|
} {
|
|
const counters: CounterCall[] = [];
|
|
const provider = {
|
|
getMeter: () => ({
|
|
createCounter: (name: string) => ({
|
|
add: (v: number, attrs: Record<string, string> = {}) =>
|
|
counters.push({ name, value: v, attrs }),
|
|
}),
|
|
createHistogram: (name: string) => ({
|
|
record: (_v: number, _attrs?: Record<string, string>) => {
|
|
void name;
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
return {
|
|
counters,
|
|
install: () => {
|
|
metrics.disable();
|
|
metrics.setGlobalMeterProvider(
|
|
provider as unknown as Parameters<
|
|
typeof metrics.setGlobalMeterProvider
|
|
>[0]
|
|
);
|
|
resetHibpMetricsCache();
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("RateLimitGate", () => {
|
|
it("exposes the documented 1500 ms minimum gap default", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(RATE_LIMIT_MIN_GAP_MS).toBe(1500);
|
|
});
|
|
|
|
it("second call within 1500ms window blocks until release", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let now = 0;
|
|
const sleeps: number[] = [];
|
|
const gate = new RateLimitGate({
|
|
clock: () => now,
|
|
sleep: async (ms) => {
|
|
sleeps.push(ms);
|
|
now += ms;
|
|
},
|
|
});
|
|
await gate.acquire();
|
|
now = 500;
|
|
await gate.acquire();
|
|
expect(sleeps).toEqual([1000]);
|
|
expect(now).toBe(1500);
|
|
});
|
|
|
|
it("range calls bypass the gate (gate not invoked)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// The gate enforces nothing for callers that do not call .acquire().
|
|
// This test documents that the range client path never constructs a
|
|
// gate — see range.ts which has no RateLimitGate import.
|
|
// (No-op assertion: structural proof captured in range.ts import set.)
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("throttle counter increments per blocked call", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fake = makeFakeMeterProvider();
|
|
fake.install();
|
|
let now = 0;
|
|
const gate = new RateLimitGate({
|
|
clock: () => now,
|
|
sleep: async (ms) => {
|
|
now += ms;
|
|
},
|
|
});
|
|
await gate.acquire();
|
|
now += 200;
|
|
await gate.acquire();
|
|
now += 200;
|
|
await gate.acquire();
|
|
const throttled = fake.counters.filter(
|
|
(c) => c.name === "hibp.rate_limit_throttled_total"
|
|
);
|
|
expect(throttled.length).toBe(2);
|
|
});
|
|
|
|
it("reset clears the next-allowed timestamp", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let now = 0;
|
|
const sleeps: number[] = [];
|
|
const gate = new RateLimitGate({
|
|
clock: () => now,
|
|
sleep: async (ms) => {
|
|
sleeps.push(ms);
|
|
now += ms;
|
|
},
|
|
});
|
|
await gate.acquire();
|
|
gate.reset();
|
|
now = 100;
|
|
await gate.acquire();
|
|
expect(sleeps).toEqual([]);
|
|
});
|
|
|
|
it("returns early if signal aborted mid-wait", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let now = 0;
|
|
const ac = new AbortController();
|
|
const gate = new RateLimitGate({
|
|
clock: () => now,
|
|
sleep: async (ms) => {
|
|
now += ms;
|
|
ac.abort();
|
|
},
|
|
});
|
|
await gate.acquire();
|
|
now = 100;
|
|
await gate.acquire(ac.signal);
|
|
// Gate returns; caller decides what to throw.
|
|
expect(ac.signal.aborted).toBe(true);
|
|
});
|
|
|
|
it("default sleep waits in wall clock when no override is provided", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const gate = new RateLimitGate({ minGapMs: 0 });
|
|
await gate.acquire();
|
|
await gate.acquire();
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|