Some checks failed
ci/woodpecker/push/api Pipeline failed
BetterAuth expects Web API Request objects (Fetch API standard) with headers.get(), but NestJS/Express passes IncomingMessage objects with headers[] property access. Use better-auth/node's toNodeHandler to properly convert between Express req/res and BetterAuth's Web API handler. Also fixes vitest SWC config to read the correct tsconfig for NestJS decorator metadata emission, which was causing DI injection failures in tests. Fixes #410 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
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<typeof vi.spyOn>;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|