419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
import { redisTest } from "@internal/testcontainers";
|
|
import { describe, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
|
|
|
|
// Mock the logger
|
|
vi.mock("./logger.server", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import type { Express } from "express";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
|
|
|
|
describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", () => {
|
|
let app: Express;
|
|
|
|
beforeEach(() => {
|
|
app = express();
|
|
});
|
|
|
|
redisTest("should allow requests within the rate limit", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 10,
|
|
interval: "1m",
|
|
maxTokens: 100,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
log: {
|
|
rejections: false,
|
|
requests: false,
|
|
},
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => {
|
|
res.status(200).json({ message: "Success" });
|
|
});
|
|
|
|
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ message: "Success" });
|
|
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
|
|
expect(response.headers["x-ratelimit-remaining"]).toBeDefined();
|
|
expect(response.headers["x-ratelimit-reset"]).toBeDefined();
|
|
});
|
|
|
|
redisTest("should reject requests without an Authorization header", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 10,
|
|
interval: "1m",
|
|
maxTokens: 100,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => {
|
|
res.status(200).json({ message: "Success" });
|
|
});
|
|
|
|
const response = await request(app).get("/api/test");
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toHaveProperty("title", "Unauthorized");
|
|
});
|
|
|
|
redisTest("should reject requests that exceed the rate limit", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 1,
|
|
interval: "1m",
|
|
maxTokens: 1,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => {
|
|
res.status(200).json({ message: "Success" });
|
|
});
|
|
|
|
// First request should succeed
|
|
await request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
// Second request should be rate limited
|
|
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(response.body).toHaveProperty("title", "Rate Limit Exceeded");
|
|
});
|
|
|
|
redisTest("should not apply rate limiting to whitelisted paths", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 10,
|
|
interval: "1m",
|
|
maxTokens: 100,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
pathWhiteList: ["/api/whitelist"],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/whitelist", (req, res) => {
|
|
res.status(200).json({ message: "Whitelisted" });
|
|
});
|
|
|
|
const response = await request(app)
|
|
.get("/api/whitelist")
|
|
.set("Authorization", "Bearer test-token");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ message: "Whitelisted" });
|
|
expect(response.headers["x-ratelimit-limit"]).toBeUndefined();
|
|
});
|
|
|
|
redisTest(
|
|
"should apply different rate limits based on limiterConfigOverride",
|
|
async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 1,
|
|
interval: "1m",
|
|
maxTokens: 1,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
limiterConfigOverride: async (authorizationValue) => {
|
|
if (authorizationValue === "Bearer premium-token") {
|
|
return {
|
|
type: "tokenBucket",
|
|
refillRate: 10,
|
|
interval: "1m",
|
|
maxTokens: 100,
|
|
};
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => {
|
|
res.status(200).json({ message: "Success" });
|
|
});
|
|
|
|
// Regular user should be rate limited after 1 request
|
|
await request(app).get("/api/test").set("Authorization", "Bearer regular-token");
|
|
const regularResponse = await request(app)
|
|
.get("/api/test")
|
|
.set("Authorization", "Bearer regular-token");
|
|
expect(regularResponse.status).toBe(429);
|
|
|
|
// Premium user should be able to make multiple requests
|
|
const premiumResponse1 = await request(app)
|
|
.get("/api/test")
|
|
.set("Authorization", "Bearer premium-token");
|
|
expect(premiumResponse1.status).toBe(200);
|
|
const premiumResponse2 = await request(app)
|
|
.get("/api/test")
|
|
.set("Authorization", "Bearer premium-token");
|
|
expect(premiumResponse2.status).toBe(200);
|
|
}
|
|
);
|
|
|
|
describe("Advanced Cases", () => {
|
|
// 1. Test different rate limit configurations
|
|
redisTest("should enforce fixed window rate limiting", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test-fixed",
|
|
defaultLimiter: {
|
|
type: "fixedWindow",
|
|
window: "10s",
|
|
tokens: 3,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
|
|
|
|
const makeRequest = () =>
|
|
request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
// Should allow 3 requests
|
|
for (let i = 0; i < 3; i++) {
|
|
const response = await makeRequest();
|
|
expect(response.status).toBe(200);
|
|
}
|
|
|
|
// 4th request should be rate limited
|
|
const limitedResponse = await makeRequest();
|
|
expect(limitedResponse.status).toBe(429);
|
|
|
|
// Wait for the window to reset
|
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
|
|
// Should allow requests again
|
|
const newResponse = await makeRequest();
|
|
expect(newResponse.status).toBe(200);
|
|
});
|
|
|
|
redisTest("should enforce sliding window rate limiting", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test-sliding",
|
|
defaultLimiter: {
|
|
type: "slidingWindow",
|
|
window: "10s",
|
|
tokens: 3,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
|
|
|
|
const makeRequest = () =>
|
|
request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
// Should allow 3 requests
|
|
for (let i = 0; i < 3; i++) {
|
|
const response = await makeRequest();
|
|
expect(response.status).toBe(200);
|
|
}
|
|
|
|
// 4th request should be rate limited
|
|
const limitedResponse = await makeRequest();
|
|
expect(limitedResponse.status).toBe(429);
|
|
|
|
// Wait for part of the window to pass
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// Should still be limited
|
|
const stillLimitedResponse = await makeRequest();
|
|
expect(stillLimitedResponse.status).toBe(429);
|
|
|
|
// Wait for the full window to pass
|
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
|
|
// Should allow requests again
|
|
const newResponse = await makeRequest();
|
|
expect(newResponse.status).toBe(200);
|
|
});
|
|
|
|
// 2. Test edge cases around rate limit calculations
|
|
redisTest("should handle token refill correctly", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test-refill",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 1,
|
|
interval: "5s",
|
|
maxTokens: 3,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
|
|
|
|
const makeRequest = () =>
|
|
request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
// Use up all tokens
|
|
for (let i = 0; i < 3; i++) {
|
|
const response = await makeRequest();
|
|
expect(response.status).toBe(200);
|
|
}
|
|
|
|
// Next request should be limited
|
|
const limitedResponse = await makeRequest();
|
|
expect(limitedResponse.status).toBe(429);
|
|
|
|
// Wait for one token to be refilled
|
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
|
|
// Should allow one request
|
|
const newResponse = await makeRequest();
|
|
expect(newResponse.status).toBe(200);
|
|
|
|
// But the next one should be limited again
|
|
const limitedAgainResponse = await makeRequest();
|
|
expect(limitedAgainResponse.status).toBe(429);
|
|
});
|
|
|
|
redisTest("should handle near-zero remaining tokens correctly", async ({ redisOptions }) => {
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test-near-zero",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 1, // 1 token every 5 seconds
|
|
interval: "5s",
|
|
maxTokens: 1,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
|
|
|
|
const makeRequest = () =>
|
|
request(app).get("/api/test").set("Authorization", "Bearer test-token");
|
|
|
|
// First request should succeed
|
|
const firstResponse = await makeRequest();
|
|
expect(firstResponse.status).toBe(200);
|
|
|
|
// Immediate second request should fail
|
|
const secondResponse = await makeRequest();
|
|
expect(secondResponse.status).toBe(429);
|
|
|
|
// Wait for almost one token to be refilled (4.9 seconds)
|
|
await new Promise((resolve) => setTimeout(resolve, 4900));
|
|
|
|
// This request should still fail as we're just shy of a full token
|
|
const thirdResponse = await makeRequest();
|
|
expect(thirdResponse.status).toBe(429);
|
|
|
|
// Wait for the full token to be refilled (additional 200ms)
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// This request should now succeed
|
|
const fourthResponse = await makeRequest();
|
|
expect(fourthResponse.status).toBe(200);
|
|
|
|
// Immediate next request should fail again
|
|
const fifthResponse = await makeRequest();
|
|
expect(fifthResponse.status).toBe(429);
|
|
});
|
|
|
|
// 3. Test the limiterCache functionality
|
|
redisTest("should use cached limiter configurations", async ({ redisOptions }) => {
|
|
let configOverrideCalls = 0;
|
|
const rateLimitMiddleware = authorizationRateLimitMiddleware({
|
|
redis: redisOptions,
|
|
keyPrefix: "test-cache",
|
|
defaultLimiter: {
|
|
type: "tokenBucket",
|
|
refillRate: 1,
|
|
interval: "1m",
|
|
maxTokens: 10,
|
|
},
|
|
pathMatchers: [/^\/api/],
|
|
limiterCache: {
|
|
fresh: 1000, // 1 second
|
|
stale: 2000, // 2 seconds
|
|
maxItems: 1000,
|
|
},
|
|
limiterConfigOverride: async (authorizationValue) => {
|
|
configOverrideCalls++;
|
|
if (authorizationValue === "Bearer premium-token") {
|
|
return {
|
|
type: "tokenBucket",
|
|
refillRate: 10,
|
|
interval: "1m",
|
|
maxTokens: 100,
|
|
};
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
|
|
app.use(rateLimitMiddleware);
|
|
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
|
|
|
|
const makeRequest = () =>
|
|
request(app).get("/api/test").set("Authorization", "Bearer premium-token");
|
|
|
|
// First request should call the override
|
|
await makeRequest();
|
|
expect(configOverrideCalls).toBe(1);
|
|
|
|
// Subsequent requests within 1 second should use the cache
|
|
await makeRequest();
|
|
await makeRequest();
|
|
expect(configOverrideCalls).toBe(1);
|
|
|
|
// Wait for the cache to become stale
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
|
|
// This should still use the cache, but also trigger a refresh
|
|
await makeRequest();
|
|
expect(configOverrideCalls).toBe(2);
|
|
|
|
// Wait for the cache to expire completely
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// This should trigger a new override call
|
|
await makeRequest();
|
|
expect(configOverrideCalls).toBe(3);
|
|
});
|
|
});
|
|
});
|