MiniSearch / server /verifyTokenAndRateLimit.test.ts
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
caa8784
Raw
History Blame Contribute Delete
8.73 kB
import type { IncomingMessage } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
let mockArgon2VerifyResult = true;
let mockIsVerifiedToken = false;
let mockRateLimiterShouldFail = false;
const mockAddVerifiedToken = vi.fn();
const mockConsume = vi.fn();
vi.mock("hash-wasm", () => ({
argon2Verify: vi.fn(() => Promise.resolve(mockArgon2VerifyResult)),
}));
vi.mock("rate-limiter-flexible", () => ({
RateLimiterMemory: class {
consume = vi.fn((key: string) => {
mockConsume(key);
if (mockRateLimiterShouldFail) {
return Promise.reject(new Error("Rate limit exceeded"));
}
return Promise.resolve(undefined);
});
},
}));
vi.mock("./searchToken", () => ({
getSearchToken: vi.fn().mockReturnValue("dummy-token"),
}));
vi.mock("./verifiedTokens", () => ({
addVerifiedToken: vi.fn((token: string) => mockAddVerifiedToken(token)),
isVerifiedToken: vi.fn(() => mockIsVerifiedToken),
}));
function makeMockRequest(ip: string): IncomingMessage {
return {
headers: { "x-forwarded-for": ip },
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
describe("verifyTokenAndRateLimit", () => {
beforeEach(() => {
vi.clearAllMocks();
mockArgon2VerifyResult = true;
mockIsVerifiedToken = false;
mockRateLimiterShouldFail = false;
});
it("should reject missing token", async () => {
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const result = await verifyTokenAndRateLimit(null);
expect(result.isAuthorized).toBe(false);
expect(result.statusCode).toBe(400);
expect(result.error).toBe("Missing token.");
});
it("should reject invalid token", async () => {
mockArgon2VerifyResult = false;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const result = await verifyTokenAndRateLimit("invalid-token");
expect(result.isAuthorized).toBe(false);
expect(result.statusCode).toBe(401);
expect(result.error).toBe("Invalid token.");
});
it("should accept valid token and add to verified tokens", async () => {
mockArgon2VerifyResult = true;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const result = await verifyTokenAndRateLimit("valid-token");
expect(result.isAuthorized).toBe(true);
expect(result).not.toHaveProperty("statusCode");
expect(mockAddVerifiedToken).toHaveBeenCalledWith("valid-token");
});
it("should skip verification for already verified tokens", async () => {
mockIsVerifiedToken = true;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const hashWasm = await import("hash-wasm");
const result = await verifyTokenAndRateLimit("already-verified-token");
expect(result.isAuthorized).toBe(true);
expect(hashWasm.argon2Verify).not.toHaveBeenCalled();
});
it("should enforce rate limiting", async () => {
mockRateLimiterShouldFail = true;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const result = await verifyTokenAndRateLimit("rate-limit-token");
expect(result.isAuthorized).toBe(false);
expect(result.statusCode).toBe(429);
expect(result.error).toBe("Too many requests.");
});
it("should key rate limiter on the socket address by default (untrusted proxy)", async () => {
mockRateLimiterShouldFail = false;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
// makeMockRequest sets a spoofable X-Forwarded-For, but with TRUST_PROXY
// off the real TCP peer (socket.remoteAddress) must be used instead.
const mockReq = makeMockRequest("192.168.1.100");
const result = await verifyTokenAndRateLimit("valid-token", mockReq);
expect(result.isAuthorized).toBe(true);
expect(mockConsume).toHaveBeenCalledWith("127.0.0.1");
});
it("should key rate limiter on the forwarded client IP when TRUST_PROXY is enabled", async () => {
mockRateLimiterShouldFail = false;
vi.stubEnv("TRUST_PROXY", "true");
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const mockReq = makeMockRequest("192.168.1.100");
const result = await verifyTokenAndRateLimit("valid-token", mockReq);
expect(result.isAuthorized).toBe(true);
expect(mockConsume).toHaveBeenCalledWith("192.168.1.100");
vi.unstubAllEnvs();
});
it("should fall back to token as rate limit key when no request", async () => {
mockRateLimiterShouldFail = false;
vi.resetModules();
const { verifyTokenAndRateLimit } = await import(
"./verifyTokenAndRateLimit"
);
const result = await verifyTokenAndRateLimit("fallback-token");
expect(result.isAuthorized).toBe(true);
expect(mockConsume).toHaveBeenCalledWith("fallback-token");
});
});
describe("getClientIp", () => {
describe("when TRUST_PROXY is enabled", () => {
beforeEach(() => {
vi.stubEnv("TRUST_PROXY", "true");
});
it("should extract last (trusted) IP from X-Forwarded-For", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2, 192.168.1.50" },
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("192.168.1.50");
});
});
it("should reject spoofed leftmost X-Forwarded-For entry", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-forwarded-for": "1.2.3.4, 192.168.1.50" },
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("192.168.1.50");
});
});
it("should fall back to X-Real-IP", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-real-ip": "172.16.0.1" },
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("172.16.0.1");
});
});
it("should fall back to socket.remoteAddress", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: {},
socket: { remoteAddress: "192.168.0.1" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("192.168.0.1");
});
});
it("should reject non-IP X-Forwarded-For entries", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-forwarded-for": "not-an-ip, also-not-an-ip" },
socket: { remoteAddress: "10.0.0.5" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("10.0.0.5");
});
});
it("should handle array-valued X-Forwarded-For header", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: {
"x-forwarded-for": ["10.0.0.1", "10.0.0.2", "192.168.1.50"],
},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("192.168.1.50");
});
});
});
describe("when TRUST_PROXY is disabled (default)", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("should ignore a spoofable X-Forwarded-For and use the socket address", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
socket: { remoteAddress: "10.0.0.5" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("10.0.0.5");
});
});
it("should ignore X-Real-IP and use the socket address", () => {
vi.resetModules();
return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => {
const req = {
headers: { "x-real-ip": "172.16.0.1" },
socket: { remoteAddress: "10.0.0.5" },
} as unknown as IncomingMessage;
expect(getClientIp(req)).toBe("10.0.0.5");
});
});
});
});