Files
stack/apps/orchestrator/src/common/guards/throttler.guard.spec.ts
Jason Woltje ce7fb27c46 fix(#338): Add rate limiting to orchestrator API
- Add @nestjs/throttler for rate limiting support
- Configure multiple throttle profiles: default (100/min), strict (10/min for spawn/kill), status (200/min for polling)
- Apply strict rate limits to spawn and kill endpoints to prevent DoS
- Apply higher rate limits to status/health endpoints for monitoring
- Add OrchestratorThrottlerGuard with X-Forwarded-For support for proxy setups
- Add unit tests for throttler guard

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:26:50 -06:00

123 lines
3.6 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { ExecutionContext } from "@nestjs/common";
import { ThrottlerException, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
import { Reflector } from "@nestjs/core";
import { OrchestratorThrottlerGuard } from "./throttler.guard";
describe("OrchestratorThrottlerGuard", () => {
let guard: OrchestratorThrottlerGuard;
beforeEach(() => {
// Create guard with minimal mocks for testing protected methods
const options: ThrottlerModuleOptions = {
throttlers: [{ name: "default", ttl: 60000, limit: 100 }],
};
const storageService = {} as ThrottlerStorage;
const reflector = {} as Reflector;
guard = new OrchestratorThrottlerGuard(options, storageService, reflector);
});
describe("getTracker", () => {
it("should extract IP from X-Forwarded-For header", async () => {
const req = {
headers: {
"x-forwarded-for": "192.168.1.1, 10.0.0.1",
},
ip: "127.0.0.1",
};
// Access protected method for testing
const tracker = await (
guard as unknown as { getTracker: (req: unknown) => Promise<string> }
).getTracker(req);
expect(tracker).toBe("192.168.1.1");
});
it("should handle X-Forwarded-For as array", async () => {
const req = {
headers: {
"x-forwarded-for": ["192.168.1.1, 10.0.0.1"],
},
ip: "127.0.0.1",
};
const tracker = await (
guard as unknown as { getTracker: (req: unknown) => Promise<string> }
).getTracker(req);
expect(tracker).toBe("192.168.1.1");
});
it("should fallback to request IP when no X-Forwarded-For", async () => {
const req = {
headers: {},
ip: "192.168.2.2",
};
const tracker = await (
guard as unknown as { getTracker: (req: unknown) => Promise<string> }
).getTracker(req);
expect(tracker).toBe("192.168.2.2");
});
it("should fallback to connection remoteAddress when no IP", async () => {
const req = {
headers: {},
connection: {
remoteAddress: "192.168.3.3",
},
};
const tracker = await (
guard as unknown as { getTracker: (req: unknown) => Promise<string> }
).getTracker(req);
expect(tracker).toBe("192.168.3.3");
});
it("should return 'unknown' when no IP available", async () => {
const req = {
headers: {},
};
const tracker = await (
guard as unknown as { getTracker: (req: unknown) => Promise<string> }
).getTracker(req);
expect(tracker).toBe("unknown");
});
});
describe("throwThrottlingException", () => {
it("should throw ThrottlerException with endpoint info", () => {
const mockRequest = {
url: "/agents/spawn",
};
const mockContext = {
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue(mockRequest),
}),
} as unknown as ExecutionContext;
expect(() => {
(
guard as unknown as { throwThrottlingException: (context: ExecutionContext) => void }
).throwThrottlingException(mockContext);
}).toThrow(ThrottlerException);
try {
(
guard as unknown as { throwThrottlingException: (context: ExecutionContext) => void }
).throwThrottlingException(mockContext);
} catch (error) {
expect(error).toBeInstanceOf(ThrottlerException);
expect((error as ThrottlerException).message).toContain("/agents/spawn");
}
});
});
});