Skip to content

Commit 576c28b

Browse files
authored
Merge pull request #621 from talatu4sambo-cmyk/feature/509-rate-limit-auth-endpoints-staging
feat(auth): enhance rate limiting on authentication endpoints
2 parents 7eda016 + 23245b7 commit 576c28b

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

src/auth/auth-orchestrator.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ export class AuthOrchestratorController {
306306
},
307307
})
308308
@Get('validate/:authId')
309+
@UseGuards(AuthRateLimitGuard)
309310
async validateAuthentication(@Param('authId') authId: string) {
310311
const isValid = await this.authOrchestrator.validateAuthentication(authId);
311312
return { valid: isValid };
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { AuthRateLimitService } from './auth-rate-limit.service';
4+
import { PrismaClient } from '../generated/prisma/client';
5+
6+
// Mock Prisma
7+
jest.mock('../generated/prisma/client', () => {
8+
return {
9+
PrismaClient: jest.fn(),
10+
};
11+
});
12+
13+
describe('AuthRateLimitService', () => {
14+
let service: AuthRateLimitService;
15+
let configService: jest.Mocked<ConfigService>;
16+
let prismaMock: any;
17+
18+
beforeEach(async () => {
19+
// Setup Prisma mock
20+
prismaMock = {
21+
rateLimitRecord: {
22+
findUnique: jest.fn(),
23+
deleteMany: jest.fn(),
24+
create: jest.fn(),
25+
update: jest.fn(),
26+
},
27+
};
28+
29+
(PrismaClient as jest.Mock).mockImplementation(() => prismaMock);
30+
31+
// Setup ConfigService mock
32+
configService = {
33+
get: jest.fn((key: string, defaultValue: string) => {
34+
const config: Record<string, string> = {
35+
AUTH_RATE_LIMIT_MAX: '10',
36+
AUTH_RATE_LIMIT_WINDOW_MS: '60000',
37+
};
38+
return config[key] || defaultValue;
39+
}),
40+
} as any;
41+
42+
const module: TestingModule = await Test.createTestingModule({
43+
providers: [
44+
AuthRateLimitService,
45+
{
46+
provide: ConfigService,
47+
useValue: configService,
48+
},
49+
],
50+
}).compile();
51+
52+
service = module.get<AuthRateLimitService>(AuthRateLimitService);
53+
});
54+
55+
afterEach(() => {
56+
jest.clearAllMocks();
57+
});
58+
59+
describe('checkRateLimit', () => {
60+
it('should allow request when within limit', async () => {
61+
const ipAddress = '192.168.1.1';
62+
63+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue(null);
64+
prismaMock.rateLimitRecord.create.mockResolvedValue({
65+
id: 'record-1',
66+
apiKeyId: `auth-rate-limit:${ipAddress}`,
67+
endpoint: 'POST /auth/authenticate',
68+
windowStart: new Date(),
69+
requestCount: 1,
70+
});
71+
72+
const result = await service.checkRateLimit(ipAddress);
73+
74+
expect(result.allowed).toBe(true);
75+
expect(result.remaining).toBe(9);
76+
expect(result.limit).toBe(10);
77+
});
78+
79+
it('should reject request when limit exceeded', async () => {
80+
const ipAddress = '192.168.1.1';
81+
const now = new Date();
82+
const windowStart = new Date(
83+
Math.floor(now.getTime() / 60000) * 60000,
84+
);
85+
86+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue({
87+
id: 'record-1',
88+
apiKeyId: `auth-rate-limit:${ipAddress}`,
89+
endpoint: 'POST /auth/authenticate',
90+
windowStart,
91+
requestCount: 10, // Already at limit
92+
});
93+
94+
const result = await service.checkRateLimit(ipAddress);
95+
96+
expect(result.allowed).toBe(false);
97+
expect(result.remaining).toBe(0);
98+
expect(result.limit).toBe(10);
99+
expect(result.retryAfterSeconds).toBeGreaterThan(0);
100+
});
101+
102+
it('should increment request count for existing record', async () => {
103+
const ipAddress = '192.168.1.1';
104+
const now = new Date();
105+
const windowStart = new Date(
106+
Math.floor(now.getTime() / 60000) * 60000,
107+
);
108+
109+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue({
110+
id: 'record-1',
111+
apiKeyId: `auth-rate-limit:${ipAddress}`,
112+
endpoint: 'POST /auth/authenticate',
113+
windowStart,
114+
requestCount: 5,
115+
});
116+
117+
prismaMock.rateLimitRecord.update.mockResolvedValue({
118+
id: 'record-1',
119+
apiKeyId: `auth-rate-limit:${ipAddress}`,
120+
endpoint: 'POST /auth/authenticate',
121+
windowStart,
122+
requestCount: 6,
123+
});
124+
125+
const result = await service.checkRateLimit(ipAddress);
126+
127+
expect(result.allowed).toBe(true);
128+
expect(result.remaining).toBe(4);
129+
});
130+
131+
it('should clean up old records for same IP', async () => {
132+
const ipAddress = '192.168.1.1';
133+
const now = new Date();
134+
const windowStart = new Date(
135+
Math.floor(now.getTime() / 60000) * 60000,
136+
);
137+
138+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue(null);
139+
prismaMock.rateLimitRecord.deleteMany.mockResolvedValue({ count: 3 });
140+
prismaMock.rateLimitRecord.create.mockResolvedValue({
141+
id: 'record-new',
142+
apiKeyId: `auth-rate-limit:${ipAddress}`,
143+
endpoint: 'POST /auth/authenticate',
144+
windowStart,
145+
requestCount: 1,
146+
});
147+
148+
await service.checkRateLimit(ipAddress);
149+
150+
expect(prismaMock.rateLimitRecord.deleteMany).toHaveBeenCalled();
151+
expect(prismaMock.rateLimitRecord.create).toHaveBeenCalled();
152+
});
153+
154+
it('should handle database errors gracefully', async () => {
155+
const ipAddress = '192.168.1.1';
156+
157+
prismaMock.rateLimitRecord.findUnique.mockRejectedValue(
158+
new Error('Database error'),
159+
);
160+
161+
const result = await service.checkRateLimit(ipAddress);
162+
163+
// Should fail open on error
164+
expect(result.allowed).toBe(true);
165+
expect(result.limit).toBe(10);
166+
expect(result.remaining).toBe(10);
167+
});
168+
});
169+
170+
describe('getConfig', () => {
171+
it('should return rate limit configuration', () => {
172+
const config = service.getConfig();
173+
174+
expect(config.maxRequests).toBe(10);
175+
expect(config.windowMs).toBe(60000);
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)