Skip to content

Commit b7e7509

Browse files
committed
test: add auth guard matrix contract tests (#571)
1 parent 4b6576e commit b7e7509

2 files changed

Lines changed: 678 additions & 0 deletions

File tree

src/auth/guard-composition.spec.ts

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
/**
2+
* Guard Composition Unit Tests
3+
*
4+
* Tests the composition and interaction of auth guards at the unit level.
5+
* Verifies execution order, context propagation, and error handling.
6+
*/
7+
8+
import { Test, TestingModule } from '@nestjs/testing';
9+
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
10+
import { Reflector } from '@nestjs/core';
11+
import { ApiKeyGuard, IS_PUBLIC, REQUIRE_API_KEY } from '../api-keys/api-key.guard';
12+
import { AuthRateLimitGuard } from './auth-rate-limit.guard';
13+
14+
describe('Guard Composition', () => {
15+
let apiKeyGuard: ApiKeyGuard;
16+
let rateLimitGuard: AuthRateLimitGuard;
17+
let reflector: Reflector;
18+
let apiKeyService: any;
19+
let rateLimitService: any;
20+
21+
beforeEach(async () => {
22+
// Mock services
23+
apiKeyService = {
24+
validateApiKey: jest.fn(),
25+
};
26+
27+
rateLimitService = {
28+
checkRateLimit: jest.fn(),
29+
getConfig: jest.fn().mockReturnValue({ windowMs: 3600000 }),
30+
};
31+
32+
const authMetricsService = {
33+
recordRateLimitHit: jest.fn(),
34+
};
35+
36+
const module: TestingModule = await Test.createTestingModule({
37+
providers: [
38+
{ provide: 'ApiKeyService', useValue: apiKeyService },
39+
{ provide: 'AuthRateLimitService', useValue: rateLimitService },
40+
{ provide: 'AuthMetricsService', useValue: authMetricsService },
41+
Reflector,
42+
],
43+
}).compile();
44+
45+
reflector = module.get<Reflector>(Reflector);
46+
apiKeyGuard = new ApiKeyGuard(apiKeyService, reflector);
47+
rateLimitGuard = new AuthRateLimitGuard(
48+
rateLimitService,
49+
authMetricsService,
50+
);
51+
});
52+
53+
describe('Execution Order: ApiKeyGuard -> AuthRateLimitGuard', () => {
54+
it('should check API key before rate limit', async () => {
55+
const context = createMockExecutionContext({
56+
headers: { authorization: '' }, // No API key
57+
});
58+
59+
// Should throw on API key check, never reach rate limit check
60+
let thrownError: any;
61+
try {
62+
await apiKeyGuard.canActivate(context);
63+
} catch (error) {
64+
thrownError = error;
65+
}
66+
67+
expect(thrownError).toBeDefined();
68+
expect(rateLimitService.checkRateLimit).not.toHaveBeenCalled();
69+
});
70+
71+
it('should check rate limit after valid API key', async () => {
72+
const context = createMockExecutionContext({
73+
headers: { authorization: 'Bearer valid-key' },
74+
});
75+
76+
apiKeyService.validateApiKey.mockResolvedValue({
77+
apiKey: { id: 'key-1' },
78+
project: { rateLimitRpm: 1000 },
79+
});
80+
81+
rateLimitService.checkRateLimit.mockResolvedValue({
82+
allowed: true,
83+
limit: 1000,
84+
remaining: 999,
85+
resetTime: new Date(Date.now() + 3600000),
86+
});
87+
88+
const result = await apiKeyGuard.canActivate(context);
89+
const rateLimitResult = await rateLimitGuard.canActivate(context);
90+
91+
expect(result).toBe(true);
92+
expect(apiKeyService.validateApiKey).toHaveBeenCalledWith('valid-key');
93+
expect(rateLimitResult).toBe(true);
94+
expect(rateLimitService.checkRateLimit).toHaveBeenCalled();
95+
});
96+
});
97+
98+
describe('Context Propagation', () => {
99+
it('should attach API key context to request', async () => {
100+
const context = createMockExecutionContext({
101+
headers: { authorization: 'Bearer valid-key' },
102+
});
103+
104+
const apiKeyContext = {
105+
apiKey: { id: 'key-1', name: 'Test Key' },
106+
project: { id: 'proj-1', rateLimitRpm: 1000 },
107+
};
108+
109+
apiKeyService.validateApiKey.mockResolvedValue(apiKeyContext);
110+
111+
await apiKeyGuard.canActivate(context);
112+
113+
const request = context.switchToHttp().getRequest();
114+
expect(request.apiKeyContext).toEqual(apiKeyContext);
115+
expect(request.apiKeyInfo).toBeDefined();
116+
expect(request.apiKeyInfo.id).toBe('key-1');
117+
});
118+
119+
it('should propagate rate limit context to response headers', async () => {
120+
const context = createMockExecutionContext({
121+
headers: { authorization: 'Bearer valid-key' },
122+
});
123+
124+
const rateLimitInfo = {
125+
allowed: true,
126+
limit: 1000,
127+
remaining: 999,
128+
resetTime: new Date(Date.now() + 3600000),
129+
retryAfterSeconds: null,
130+
};
131+
132+
rateLimitService.checkRateLimit.mockResolvedValue(rateLimitInfo);
133+
134+
await rateLimitGuard.canActivate(context);
135+
136+
const response = context.switchToHttp().getResponse();
137+
expect(response.setHeader).toHaveBeenCalledWith(
138+
'X-RateLimit-Limit',
139+
1000,
140+
);
141+
expect(response.setHeader).toHaveBeenCalledWith(
142+
'X-RateLimit-Remaining',
143+
999,
144+
);
145+
expect(response.setHeader).toHaveBeenCalledWith(
146+
'X-RateLimit-Reset',
147+
expect.any(Number),
148+
);
149+
});
150+
});
151+
152+
describe('Error Handling', () => {
153+
it('should throw HttpException with proper status on API key validation failure', async () => {
154+
const context = createMockExecutionContext({
155+
headers: { authorization: 'Bearer invalid-key' },
156+
});
157+
158+
apiKeyService.validateApiKey.mockRejectedValue(
159+
new HttpException('Invalid API key', HttpStatus.UNAUTHORIZED),
160+
);
161+
162+
let thrownError: any;
163+
try {
164+
await apiKeyGuard.canActivate(context);
165+
} catch (error) {
166+
thrownError = error;
167+
}
168+
169+
expect(thrownError).toBeInstanceOf(HttpException);
170+
expect(thrownError.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
171+
});
172+
173+
it('should throw HttpException with 429 on rate limit exceeded', async () => {
174+
const context = createMockExecutionContext({
175+
headers: { authorization: 'Bearer valid-key' },
176+
});
177+
178+
rateLimitService.checkRateLimit.mockResolvedValue({
179+
allowed: false,
180+
limit: 100,
181+
remaining: 0,
182+
resetTime: new Date(Date.now() + 3600000),
183+
retryAfterSeconds: 3600,
184+
});
185+
186+
let thrownError: any;
187+
try {
188+
await rateLimitGuard.canActivate(context);
189+
} catch (error) {
190+
thrownError = error;
191+
}
192+
193+
expect(thrownError).toBeInstanceOf(HttpException);
194+
expect(thrownError.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
195+
});
196+
197+
it('should set Retry-After header on rate limit error', async () => {
198+
const context = createMockExecutionContext({
199+
headers: { authorization: 'Bearer valid-key' },
200+
});
201+
202+
rateLimitService.checkRateLimit.mockResolvedValue({
203+
allowed: false,
204+
limit: 100,
205+
remaining: 0,
206+
resetTime: new Date(Date.now() + 3600000),
207+
retryAfterSeconds: 3600,
208+
});
209+
210+
try {
211+
await rateLimitGuard.canActivate(context);
212+
} catch (error) {
213+
// Expected to throw
214+
}
215+
216+
const response = context.switchToHttp().getResponse();
217+
expect(response.setHeader).toHaveBeenCalledWith('Retry-After', '3600');
218+
});
219+
});
220+
221+
describe('Decorator Metadata Handling', () => {
222+
it('should skip authentication for @Public() decorated routes', async () => {
223+
const context = createMockExecutionContext(
224+
{ headers: {} },
225+
{ isPublic: true },
226+
);
227+
228+
const result = await apiKeyGuard.canActivate(context);
229+
230+
expect(result).toBe(true);
231+
expect(apiKeyService.validateApiKey).not.toHaveBeenCalled();
232+
});
233+
234+
it('should enforce authentication for non-public routes', async () => {
235+
const context = createMockExecutionContext({ headers: {} });
236+
237+
let thrownError: any;
238+
try {
239+
await apiKeyGuard.canActivate(context);
240+
} catch (error) {
241+
thrownError = error;
242+
}
243+
244+
expect(thrownError).toBeDefined();
245+
expect(thrownError.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
246+
});
247+
});
248+
249+
describe('IP Address Extraction for Rate Limiting', () => {
250+
it('should extract IP from X-Forwarded-For header', async () => {
251+
const context = createMockExecutionContext({
252+
headers: { 'x-forwarded-for': '192.168.1.1, 10.0.0.1' },
253+
});
254+
255+
rateLimitService.checkRateLimit.mockResolvedValue({
256+
allowed: true,
257+
limit: 100,
258+
remaining: 99,
259+
resetTime: new Date(),
260+
});
261+
262+
await rateLimitGuard.canActivate(context);
263+
264+
expect(rateLimitService.checkRateLimit).toHaveBeenCalledWith('192.168.1.1');
265+
});
266+
267+
it('should fallback to connection remoteAddress if X-Forwarded-For absent', async () => {
268+
const context = createMockExecutionContext({
269+
headers: {},
270+
remoteAddress: '10.0.0.2',
271+
});
272+
273+
rateLimitService.checkRateLimit.mockResolvedValue({
274+
allowed: true,
275+
limit: 100,
276+
remaining: 99,
277+
resetTime: new Date(),
278+
});
279+
280+
await rateLimitGuard.canActivate(context);
281+
282+
expect(rateLimitService.checkRateLimit).toHaveBeenCalledWith('10.0.0.2');
283+
});
284+
});
285+
286+
describe('Concurrent Guard Execution', () => {
287+
it('should handle concurrent requests with different API keys independently', async () => {
288+
const context1 = createMockExecutionContext({
289+
headers: { authorization: 'Bearer key-1' },
290+
});
291+
const context2 = createMockExecutionContext({
292+
headers: { authorization: 'Bearer key-2' },
293+
});
294+
295+
apiKeyService.validateApiKey
296+
.mockResolvedValueOnce({
297+
apiKey: { id: 'key-1' },
298+
project: { rateLimitRpm: 1000 },
299+
})
300+
.mockResolvedValueOnce({
301+
apiKey: { id: 'key-2' },
302+
project: { rateLimitRpm: 500 },
303+
});
304+
305+
rateLimitService.checkRateLimit
306+
.mockResolvedValueOnce({
307+
allowed: true,
308+
limit: 1000,
309+
remaining: 999,
310+
resetTime: new Date(),
311+
})
312+
.mockResolvedValueOnce({
313+
allowed: true,
314+
limit: 500,
315+
remaining: 499,
316+
resetTime: new Date(),
317+
});
318+
319+
const [result1, result2] = await Promise.all([
320+
apiKeyGuard.canActivate(context1),
321+
apiKeyGuard.canActivate(context2),
322+
]);
323+
324+
expect(result1).toBe(true);
325+
expect(result2).toBe(true);
326+
expect(context1.switchToHttp().getRequest().apiKeyContext.apiKey.id).toBe(
327+
'key-1',
328+
);
329+
expect(context2.switchToHttp().getRequest().apiKeyContext.apiKey.id).toBe(
330+
'key-2',
331+
);
332+
});
333+
});
334+
});
335+
336+
// Helper to create mock ExecutionContext
337+
function createMockExecutionContext(
338+
requestOptions: any,
339+
metadata: any = {},
340+
): any {
341+
const request = {
342+
headers: requestOptions.headers || {},
343+
connection: { remoteAddress: requestOptions.remoteAddress || '127.0.0.1' },
344+
socket: { remoteAddress: requestOptions.remoteAddress || '127.0.0.1' },
345+
};
346+
347+
const response = {
348+
setHeader: jest.fn().mockReturnThis(),
349+
getHeader: jest.fn(),
350+
};
351+
352+
const contextClass = {
353+
canActivate: jest.fn(),
354+
};
355+
356+
return {
357+
switchToHttp: jest.fn().mockReturnValue({
358+
getRequest: jest.fn().mockReturnValue(request),
359+
getResponse: jest.fn().mockReturnValue(response),
360+
}),
361+
getHandler: jest.fn().mockReturnValue(contextClass.canActivate),
362+
getClass: jest.fn().mockReturnValue(contextClass),
363+
};
364+
}

0 commit comments

Comments
 (0)