import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { INestApplication, HttpStatus } from "@nestjs/common"; import request from "supertest"; import { StitcherController } from "./stitcher.controller"; import { StitcherService } from "./stitcher.service"; import { ThrottlerModule } from "@nestjs/throttler"; import { APP_GUARD } from "@nestjs/core"; import { ConfigService } from "@nestjs/config"; import { ApiKeyGuard } from "../common/guards"; import { ThrottlerApiKeyGuard } from "../common/throttler"; /** * Rate Limiting Tests for Stitcher Endpoints * * These tests verify that rate limiting is properly enforced on webhook endpoints * to prevent DoS attacks. * * Test Coverage: * - Rate limit enforcement (429 status) * - Retry-After header inclusion * - Per-IP rate limiting * - Requests within limit are allowed */ describe("StitcherController - Rate Limiting", () => { let app: INestApplication; let service: StitcherService; const mockStitcherService = { dispatchJob: vi.fn().mockResolvedValue({ jobId: "job-123", queueName: "mosaic-jobs", status: "PENDING", }), handleWebhook: vi.fn().mockResolvedValue({ jobId: "job-456", queueName: "mosaic-jobs", status: "PENDING", }), }; const mockConfigService = { get: vi.fn((key: string) => { const config: Record = { STITCHER_API_KEY: "test-api-key-12345", RATE_LIMIT_TTL: "1", // 1 second for faster tests RATE_LIMIT_WEBHOOK_LIMIT: "5", }; return config[key]; }), }; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ ThrottlerModule.forRoot([ { ttl: 1000, // 1 second for testing limit: 5, // 5 requests per window }, ]), ], controllers: [StitcherController], providers: [ { provide: StitcherService, useValue: mockStitcherService }, { provide: ConfigService, useValue: mockConfigService }, { provide: APP_GUARD, useClass: ThrottlerApiKeyGuard, }, ], }) .overrideGuard(ApiKeyGuard) .useValue({ canActivate: () => true }) .compile(); app = moduleFixture.createNestApplication(); await app.init(); service = moduleFixture.get(StitcherService); vi.clearAllMocks(); }); afterEach(async () => { await app.close(); }); describe("POST /stitcher/webhook - Rate Limiting", () => { it("should allow requests within rate limit", async () => { const payload = { issueNumber: "42", repository: "mosaic/stack", action: "assigned", }; // Make 3 requests (within limit of 60 as configured in controller) for (let i = 0; i < 3; i++) { const response = await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-12345") .send(payload); expect(response.status).toBe(HttpStatus.CREATED); expect(response.body).toHaveProperty("jobId"); } expect(mockStitcherService.handleWebhook).toHaveBeenCalledTimes(3); }); it("should return 429 when rate limit is exceeded", async () => { const payload = { issueNumber: "42", repository: "mosaic/stack", action: "assigned", }; // Make requests up to the limit (60 as configured in controller) for (let i = 0; i < 60; i++) { await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-12345") .send(payload); } // The 61st request should be rate limited const response = await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-12345") .send(payload); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); }); it("should include Retry-After header in 429 response", async () => { const payload = { issueNumber: "42", repository: "mosaic/stack", action: "assigned", }; // Exhaust rate limit (60 requests) for (let i = 0; i < 60; i++) { await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-12345") .send(payload); } // Get rate limited response const response = await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-12345") .send(payload); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(response.headers).toHaveProperty("retry-after"); expect(parseInt(response.headers["retry-after"])).toBeGreaterThan(0); }); it("should enforce rate limits per API key", async () => { const payload = { issueNumber: "42", repository: "mosaic/stack", action: "assigned", }; // Exhaust rate limit from first API key for (let i = 0; i < 60; i++) { await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-1") .send(payload); } // First API key should be rate limited const response1 = await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-1") .send(payload); expect(response1.status).toBe(HttpStatus.TOO_MANY_REQUESTS); // Second API key should still be allowed const response2 = await request(app.getHttpServer()) .post("/stitcher/webhook") .set("X-API-Key", "test-api-key-2") .send(payload); expect(response2.status).toBe(HttpStatus.CREATED); }); }); describe("POST /stitcher/dispatch - Rate Limiting", () => { it("should allow requests within rate limit", async () => { const payload = { workspaceId: "workspace-123", type: "code-task", context: { issueId: "42" }, }; // Make 3 requests (within limit of 60) for (let i = 0; i < 3; i++) { const response = await request(app.getHttpServer()) .post("/stitcher/dispatch") .set("X-API-Key", "test-api-key-12345") .send(payload); expect(response.status).toBe(HttpStatus.CREATED); } expect(mockStitcherService.dispatchJob).toHaveBeenCalledTimes(3); }); it("should return 429 when rate limit is exceeded", async () => { const payload = { workspaceId: "workspace-123", type: "code-task", context: { issueId: "42" }, }; // Exhaust rate limit (60 requests) for (let i = 0; i < 60; i++) { await request(app.getHttpServer()) .post("/stitcher/dispatch") .set("X-API-Key", "test-api-key-12345") .send(payload); } // The 61st request should be rate limited const response = await request(app.getHttpServer()) .post("/stitcher/dispatch") .set("X-API-Key", "test-api-key-12345") .send(payload); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); }); }); });