All checks were successful
ci/woodpecker/push/api Pipeline was successful
The constant-time comparison test used Date.now() deltas with a 10ms threshold which is unreliable in CI. Replace with deterministic tests that verify both same-length and different-length key rejection paths work correctly. The actual timing-safe behavior is guaranteed by Node's crypto.timingSafeEqual which the guard uses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { ApiKeyGuard } from "./api-key.guard";
|
|
|
|
describe("ApiKeyGuard", () => {
|
|
let guard: ApiKeyGuard;
|
|
let mockConfigService: ConfigService;
|
|
|
|
beforeEach(() => {
|
|
mockConfigService = {
|
|
get: vi.fn(),
|
|
} as unknown as ConfigService;
|
|
|
|
guard = new ApiKeyGuard(mockConfigService);
|
|
});
|
|
|
|
const createMockExecutionContext = (headers: Record<string, string>): ExecutionContext => {
|
|
return {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
headers,
|
|
}),
|
|
}),
|
|
} as ExecutionContext;
|
|
};
|
|
|
|
describe("canActivate", () => {
|
|
it("should return true when valid API key is provided", () => {
|
|
const validApiKey = "test-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockConfigService.get).toHaveBeenCalledWith("COORDINATOR_API_KEY");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when no API key is provided", () => {
|
|
const context = createMockExecutionContext({});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("No API key provided");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when API key is invalid", () => {
|
|
const validApiKey = "correct-api-key";
|
|
const invalidApiKey = "wrong-api-key";
|
|
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": invalidApiKey,
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("Invalid API key");
|
|
});
|
|
|
|
it("should throw UnauthorizedException when COORDINATOR_API_KEY is not configured", () => {
|
|
vi.mocked(mockConfigService.get).mockReturnValue(undefined);
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": "some-key",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("API key authentication not configured");
|
|
});
|
|
|
|
it("should handle uppercase header name (X-API-Key)", () => {
|
|
const validApiKey = "test-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"X-API-Key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should handle mixed case header name (X-Api-Key)", () => {
|
|
const validApiKey = "test-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
const context = createMockExecutionContext({
|
|
"X-Api-Key": validApiKey,
|
|
});
|
|
|
|
const result = guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should reject empty string API key", () => {
|
|
vi.mocked(mockConfigService.get).mockReturnValue("valid-key");
|
|
|
|
const context = createMockExecutionContext({
|
|
"x-api-key": "",
|
|
});
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
|
|
expect(() => guard.canActivate(context)).toThrow("No API key provided");
|
|
});
|
|
|
|
it("should use constant-time comparison to prevent timing attacks", () => {
|
|
const validApiKey = "test-api-key-12345";
|
|
vi.mocked(mockConfigService.get).mockReturnValue(validApiKey);
|
|
|
|
// Verify that same-length keys are compared properly (exercises timingSafeEqual path)
|
|
// and different-length keys are rejected before comparison
|
|
const sameLength = createMockExecutionContext({
|
|
"x-api-key": "test-api-key-12344", // Same length, one char different
|
|
});
|
|
const differentLength = createMockExecutionContext({
|
|
"x-api-key": "short", // Different length
|
|
});
|
|
|
|
// Both should throw, proving the comparison logic handles both cases
|
|
expect(() => guard.canActivate(sameLength)).toThrow("Invalid API key");
|
|
expect(() => guard.canActivate(differentLength)).toThrow("Invalid API key");
|
|
|
|
// Correct key should pass
|
|
const correct = createMockExecutionContext({
|
|
"x-api-key": validApiKey,
|
|
});
|
|
expect(guard.canActivate(correct)).toBe(true);
|
|
});
|
|
});
|
|
});
|