import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { INestApplication, HttpStatus, Logger } from "@nestjs/common"; import request from "supertest"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { ThrottlerModule } from "@nestjs/throttler"; import { APP_GUARD } from "@nestjs/core"; import { ThrottlerApiKeyGuard } from "../common/throttler"; /** * Rate Limiting Tests for Auth Controller Catch-All Route * * These tests verify that rate limiting is properly enforced on the auth * catch-all route to prevent brute-force attacks (SEC-API-10). * * Test Coverage: * - Rate limit enforcement (429 status after 10 requests in 1 minute) * - Retry-After header inclusion * - Logging occurs for auth catch-all hits */ describe("AuthController - Rate Limiting", () => { let app: INestApplication; let loggerSpy: ReturnType; const mockNodeHandler = vi.fn( (_req: unknown, res: { statusCode: number; end: (body: string) => void }) => { res.statusCode = 200; res.end(JSON.stringify({})); return Promise.resolve(); } ); const mockAuthService = { getAuth: vi.fn(), getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler), }; beforeEach(async () => { // Spy on Logger.prototype.debug to verify logging loggerSpy = vi.spyOn(Logger.prototype, "debug").mockImplementation(() => {}); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ ThrottlerModule.forRoot([ { ttl: 60000, // 1 minute limit: 10, // Match the "strict" tier limit }, ]), ], controllers: [AuthController], providers: [ { provide: AuthService, useValue: mockAuthService }, { provide: APP_GUARD, useClass: ThrottlerApiKeyGuard, }, ], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); vi.clearAllMocks(); }); afterEach(async () => { await app.close(); loggerSpy.mockRestore(); }); describe("Auth Catch-All Route - Rate Limiting", () => { it("should allow requests within rate limit", async () => { // Make 3 requests (within limit of 10) for (let i = 0; i < 3; i++) { const response = await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); // Should not be rate limited expect(response.status).not.toBe(HttpStatus.TOO_MANY_REQUESTS); } expect(mockAuthService.getNodeHandler).toHaveBeenCalledTimes(3); }); it("should return 429 when rate limit is exceeded", async () => { // Exhaust rate limit (10 requests) for (let i = 0; i < 10; i++) { await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); } // The 11th request should be rate limited const response = await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); }); it("should include Retry-After header in 429 response", async () => { // Exhaust rate limit (10 requests) for (let i = 0; i < 10; i++) { await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); } // Get rate limited response const response = await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(response.headers).toHaveProperty("retry-after"); expect(parseInt(response.headers["retry-after"])).toBeGreaterThan(0); }); it("should rate limit different auth endpoints under the same limit", async () => { // Make 5 sign-in requests for (let i = 0; i < 5; i++) { await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); } // Make 5 sign-up requests (total now 10) for (let i = 0; i < 5; i++) { await request(app.getHttpServer()).post("/auth/sign-up").send({ email: "test@example.com", password: "password", name: "Test User", }); } // The 11th request (any auth endpoint) should be rate limited const response = await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); }); }); describe("Auth Catch-All Route - Logging", () => { it("should log auth catch-all hits with request details", async () => { await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); // Verify logging was called expect(loggerSpy).toHaveBeenCalled(); // Find the log call that contains our expected message const logCalls = loggerSpy.mock.calls; const authLogCall = logCalls.find( (call) => typeof call[0] === "string" && call[0].includes("Auth catch-all:") ); expect(authLogCall).toBeDefined(); expect(authLogCall?.[0]).toMatch(/Auth catch-all: POST/); }); it("should log different HTTP methods correctly", async () => { // Test GET request await request(app.getHttpServer()).get("/auth/callback"); const logCalls = loggerSpy.mock.calls; const getLogCall = logCalls.find( (call) => typeof call[0] === "string" && call[0].includes("Auth catch-all:") && call[0].includes("GET") ); expect(getLogCall).toBeDefined(); }); }); describe("Per-IP Rate Limiting", () => { it("should track rate limits per IP independently", async () => { // Note: In a real scenario, different IPs would have different limits // This test verifies the rate limit tracking behavior // Exhaust rate limit with requests for (let i = 0; i < 10; i++) { await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); } // Should be rate limited now const response = await request(app.getHttpServer()).post("/auth/sign-in").send({ email: "test@example.com", password: "password", }); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); }); }); });