Initial Commit
This commit is contained in:
139
packages/foundation/flow/rate-limiter/tests/integration.test.ts
Normal file
139
packages/foundation/flow/rate-limiter/tests/integration.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FixedWindow } from "../src/core/fixed-window/fixed-window.js";
|
||||
import { createRateLimiter } from "../src/core/rate-limiter/rate-limiter.js";
|
||||
import { SlidingWindow } from "../src/core/sliding-window/sliding-window.js";
|
||||
import { TokenBucket } from "../src/core/token-bucket/token-bucket.js";
|
||||
import { MemoryStorage } from "../src/storage/memory-storage/memory-storage.js";
|
||||
|
||||
describe("Integration: multi-algorithm coordination", () => {
|
||||
let t: number;
|
||||
let clock: () => number;
|
||||
|
||||
beforeEach(() => {
|
||||
t = 1_000_000;
|
||||
clock = () => t;
|
||||
});
|
||||
|
||||
it("token bucket + storage round-trip", { timeout: 5000 }, () => {
|
||||
const bucket = new TokenBucket({
|
||||
capacity: 10,
|
||||
refillRate: 0.01,
|
||||
clock,
|
||||
});
|
||||
// Drain bucket
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(bucket.consume().allowed).toBe(true);
|
||||
}
|
||||
expect(bucket.consume().allowed).toBe(false);
|
||||
// Refill via time
|
||||
t += 1000; // +10 tokens
|
||||
expect(bucket.consume().allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("sliding window per-key isolation", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage({ clock });
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 2, window: 60_000 },
|
||||
storage,
|
||||
clock,
|
||||
keyPrefix: "rl",
|
||||
});
|
||||
|
||||
// Exhaust key "a:user:1"
|
||||
await limiter.check({ key: "a:user:1", tenantId: "a" });
|
||||
await limiter.check({ key: "a:user:1", tenantId: "a" });
|
||||
const r1 = await limiter.check({ key: "a:user:1", tenantId: "a" });
|
||||
expect(r1.allowed).toBe(false);
|
||||
|
||||
// "b:user:1" is a separate storage key and is unaffected
|
||||
const r2 = await limiter.check({ key: "b:user:1", tenantId: "b" });
|
||||
expect(r2.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("fixed window and sliding window agreement on boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
t = 0;
|
||||
const fw = new FixedWindow({ limit: 3, windowMs: 1000, clock });
|
||||
const sw = new SlidingWindow({ limit: 3, windowMs: 1000, clock });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(fw.check("k").allowed).toBe(true);
|
||||
expect(sw.check("k").allowed).toBe(true);
|
||||
}
|
||||
// Both reject
|
||||
expect(fw.check("k").allowed).toBe(false);
|
||||
expect(sw.check("k").allowed).toBe(false);
|
||||
|
||||
// Advance past window
|
||||
t = 1001;
|
||||
expect(fw.check("k").allowed).toBe(true);
|
||||
expect(sw.check("k").allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("rate limiter with all rule conditions", { timeout: 5000 }, async () => {
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 100, window: 1000 },
|
||||
rules: [
|
||||
{
|
||||
id: "ip-block",
|
||||
limit: 0,
|
||||
window: 1000,
|
||||
priority: 90,
|
||||
bypass: false,
|
||||
conditions: [
|
||||
{ field: "ip", operator: "eq", value: "10.0.0.1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin-pass",
|
||||
limit: 0,
|
||||
window: 1000,
|
||||
priority: 100,
|
||||
bypass: true,
|
||||
conditions: [
|
||||
{ field: "userId", operator: "in", value: ["admin"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
clock,
|
||||
});
|
||||
|
||||
// Admin bypasses all limits
|
||||
const adminResult = await limiter.check({
|
||||
key: "admin-key",
|
||||
userId: "admin",
|
||||
});
|
||||
expect(adminResult.allowed).toBe(true);
|
||||
|
||||
// Blocked IP hits limit immediately
|
||||
const blockedResult = await limiter.check({
|
||||
key: "blocked-ip",
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(blockedResult.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("getState after multiple checks is consistent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage({ clock });
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 5, window: 1000 },
|
||||
storage,
|
||||
clock,
|
||||
});
|
||||
|
||||
await limiter.check({ key: "u1" });
|
||||
await limiter.check({ key: "u1" });
|
||||
await limiter.check({ key: "u1" });
|
||||
|
||||
const state = await limiter.getState("u1");
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.algorithm).toBe("sliding-window");
|
||||
expect("requests" in state!.state).toBe(true);
|
||||
if ("requests" in state!.state) {
|
||||
expect(state!.state.requests.length).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
281
packages/foundation/flow/rate-limiter/tests/property.test.ts
Normal file
281
packages/foundation/flow/rate-limiter/tests/property.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { createRateLimiter } from "../src/core/rate-limiter/rate-limiter.js";
|
||||
import { SlidingWindow } from "../src/core/sliding-window/sliding-window.js";
|
||||
import { TokenBucket } from "../src/core/token-bucket/token-bucket.js";
|
||||
import { MemoryStorage } from "../src/storage/memory-storage/memory-storage.js";
|
||||
import type { RateLimitState } from "../src/types/rate-limiter.types.js";
|
||||
|
||||
describe("Property: TokenBucket invariants", () => {
|
||||
it("available never exceeds capacity", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 1000 }),
|
||||
fc.float({ min: Math.fround(0.001), max: Math.fround(10) }),
|
||||
fc.array(fc.integer({ min: 0, max: 1000 }), {
|
||||
minLength: 0,
|
||||
maxLength: 50,
|
||||
}),
|
||||
(capacity, refillRate, delays) => {
|
||||
let t = 0;
|
||||
const clock = () => t;
|
||||
const bucket = new TokenBucket({
|
||||
capacity,
|
||||
refillRate,
|
||||
clock,
|
||||
});
|
||||
for (const delay of delays) {
|
||||
t += delay;
|
||||
bucket.consume();
|
||||
}
|
||||
// After any sequence, available must be <= capacity
|
||||
expect(bucket.available).toBeLessThanOrEqual(capacity);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("available never goes below 0", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
fc.float({ min: Math.fround(0.001), max: Math.fround(1) }),
|
||||
fc.integer({ min: 1, max: 200 }),
|
||||
(capacity, refillRate, consumeCount) => {
|
||||
const t = 0;
|
||||
const clock = () => t;
|
||||
const bucket = new TokenBucket({
|
||||
capacity,
|
||||
refillRate,
|
||||
initialTokens: 0,
|
||||
clock,
|
||||
});
|
||||
for (let i = 0; i < consumeCount; i++) {
|
||||
bucket.consume();
|
||||
}
|
||||
expect(bucket.available).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("consume(n) decreases available by exactly n when allowed", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2, max: 100 }),
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
(capacity, consume) => {
|
||||
fc.pre(consume <= capacity);
|
||||
const t = 0;
|
||||
const clock = () => t;
|
||||
const bucket = new TokenBucket({
|
||||
capacity,
|
||||
refillRate: 0,
|
||||
clock,
|
||||
});
|
||||
const before = bucket.available;
|
||||
const result = bucket.consume(consume);
|
||||
if (result.allowed) {
|
||||
expect(bucket.available).toBe(before - consume);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: tenant multiplier correctness", () => {
|
||||
it("effective limit = base * multiplier for same key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 2, max: 10 }),
|
||||
fc.integer({ min: 2, max: 4 }),
|
||||
async (baseLimit, multiplierInt) => {
|
||||
const multiplier = multiplierInt; // integer multiplier avoids float rounding
|
||||
const effectiveLimit = baseLimit * multiplier;
|
||||
const t = 0;
|
||||
const clock = () => t;
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: baseLimit, window: 60_000 },
|
||||
enableTenantLimits: true,
|
||||
tenantMultipliers: { "test-tenant": multiplier },
|
||||
clock,
|
||||
});
|
||||
|
||||
const key = `tenant-same-key-${baseLimit}-${multiplier}`;
|
||||
let allowed = 0;
|
||||
for (let i = 0; i < effectiveLimit + 3; i++) {
|
||||
const r = await limiter.check({
|
||||
key,
|
||||
tenantId: "test-tenant",
|
||||
});
|
||||
if (r.allowed) allowed++;
|
||||
}
|
||||
|
||||
// Exactly effectiveLimit requests on the same key should be allowed
|
||||
expect(allowed).toBe(effectiveLimit);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: SlidingWindow monotonicity", () => {
|
||||
it("remaining never increases between consecutive checks without reset", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 50 }),
|
||||
fc.integer({ min: 100, max: 10_000 }),
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
(limit, windowMs, checks) => {
|
||||
const t = 1_000_000;
|
||||
const clock = () => t;
|
||||
const sw = new SlidingWindow({ limit, windowMs, clock });
|
||||
let lastRemaining = limit;
|
||||
let wasReset = false;
|
||||
|
||||
for (let i = 0; i < checks; i++) {
|
||||
const r = sw.check("monotone-key");
|
||||
if (r.allowed) {
|
||||
// remaining should be <= previous remaining (or equal if we're at 0)
|
||||
if (!wasReset) {
|
||||
expect(r.remaining).toBeLessThanOrEqual(
|
||||
lastRemaining
|
||||
);
|
||||
}
|
||||
}
|
||||
lastRemaining = r.remaining;
|
||||
wasReset = false;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: MemoryStorage round-trip", () => {
|
||||
it("set then get returns deep-equal state within TTL", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.string({ minLength: 1, maxLength: 50 }),
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
async (key, count) => {
|
||||
const t = 1_000_000;
|
||||
const clock = () => t;
|
||||
const storage = new MemoryStorage({ clock });
|
||||
const state: RateLimitState = {
|
||||
key,
|
||||
algorithm: "sliding-window",
|
||||
state: {
|
||||
requests: Array.from(
|
||||
{ length: count % 10 },
|
||||
(_, i) => t - i * 100
|
||||
),
|
||||
},
|
||||
config: { limit: 100, window: 60_000 },
|
||||
updatedAt: t,
|
||||
};
|
||||
await storage.set(key, state, 60_000);
|
||||
const loaded = await storage.get(key);
|
||||
expect(loaded).toEqual(state);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("get returns null after TTL expires", { timeout: 30000 }, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.string({ minLength: 1, maxLength: 20 }),
|
||||
fc.integer({ min: 1, max: 5000 }),
|
||||
async (key, ttl) => {
|
||||
let t = 0;
|
||||
const clock = () => t;
|
||||
const storage = new MemoryStorage({ clock });
|
||||
const state: RateLimitState = {
|
||||
key,
|
||||
algorithm: "fixed-window",
|
||||
state: { count: 1, windowStart: 0 },
|
||||
config: { limit: 10, window: ttl },
|
||||
updatedAt: 0,
|
||||
};
|
||||
await storage.set(key, state, ttl);
|
||||
t = ttl + 1;
|
||||
expect(await storage.get(key)).toBeNull();
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: rule priority ordering", () => {
|
||||
it("higher priority rule always wins over lower", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 50 }),
|
||||
fc.integer({ min: 51, max: 100 }),
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.integer({ min: 21, max: 100 }),
|
||||
async (lowLimit, highLimit, lowPriority, highPriority) => {
|
||||
const t = 0;
|
||||
const clock = () => t;
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 1000, window: 60_000 },
|
||||
rules: [
|
||||
{
|
||||
id: "low",
|
||||
limit: lowLimit,
|
||||
window: 60_000,
|
||||
priority: lowPriority,
|
||||
conditions: [
|
||||
{
|
||||
field: "path",
|
||||
operator: "eq",
|
||||
value: "/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "high",
|
||||
limit: highLimit,
|
||||
window: 60_000,
|
||||
priority: highPriority,
|
||||
conditions: [
|
||||
{
|
||||
field: "path",
|
||||
operator: "eq",
|
||||
value: "/test",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
clock,
|
||||
});
|
||||
|
||||
// Use unique key per test to avoid cross-run contamination
|
||||
const key = `prop-${lowLimit}-${highPriority}`;
|
||||
let allowed = 0;
|
||||
for (let i = 0; i < highLimit + 5; i++) {
|
||||
const r = await limiter.check({
|
||||
key: `${key}-${i}`,
|
||||
path: "/test",
|
||||
});
|
||||
if (r.allowed) allowed++;
|
||||
}
|
||||
// All requests on unique keys should pass (one request per unique key)
|
||||
expect(allowed).toBe(highLimit + 5);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user