Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions src/rate-limiting/guards/quota.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { QuotaGuard } from './quota.guard';
import { QuotaTrackingService } from '../services/quota-tracking.service';
import { UserTier } from '../rate-limiting.constants';
import { RateLimitExceededException } from '../../common/exceptions/app.exceptions';

// Mock QuotaTrackingService
class MockQuotaTrackingService {
checkAndIncrement = jest.fn().mockResolvedValue({
allowed: true,
remaining: { minute: 5, hour: 50, day: 200 },
limit: { minute: 10, hour: 100, day: 500 },
});
}

describe('QuotaGuard', () => {
let guard: QuotaGuard;
let trackingService: QuotaTrackingService;
let reflector: Reflector;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QuotaGuard,
{ provide: QuotaTrackingService, useClass: MockQuotaTrackingService },
Reflector,
],
}).compile();

guard = module.get<QuotaGuard>(QuotaGuard);
trackingService = module.get<QuotaTrackingService>(QuotaTrackingService);
reflector = module.get<Reflector>(Reflector);

// Suppress logger output during tests
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
});

it('should be defined', () => {
expect(guard).toBeDefined();
});

it('should use user:{id} as identifier for authenticated requests with req.user.id', async () => {
// Create mock execution context
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: { id: 'user123', tier: 'FREE' },
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

// Mock reflector to return no quota options
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
expect(trackingService.checkAndIncrement).toHaveBeenCalledWith('user:user123', UserTier.FREE);
});

it('should use user:{sub} as identifier for authenticated requests with req.user.sub', async () => {
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: { sub: 'auth0|user456', tier: 'PRO' },
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
expect(trackingService.checkAndIncrement).toHaveBeenCalledWith(
'user:auth0|user456',
UserTier.PRO,
);
});

it('should use ip:{address} as identifier for unauthenticated requests', async () => {
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: undefined,
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
expect(trackingService.checkAndIncrement).toHaveBeenCalledWith(
'ip:192.168.1.1',
UserTier.UNAUTHENTICATED,
);
});

it('should apply separate quotas for different users on the same IP', async () => {
// First user from IP 192.168.1.1
const mockContext1 = {
switchToHttp: () => ({
getRequest: () => ({
user: { id: 'user1', tier: 'FREE' },
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

// Second user from the same IP
const mockContext2 = {
switchToHttp: () => ({
getRequest: () => ({
user: { id: 'user2', tier: 'FREE' },
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);
const checkAndIncrementSpy = jest.spyOn(trackingService, 'checkAndIncrement');

await guard.canActivate(mockContext1);
await guard.canActivate(mockContext2);

// Verify different identifiers were used for the same IP
expect(checkAndIncrementSpy).toHaveBeenNthCalledWith(1, 'user:user1', UserTier.FREE);
expect(checkAndIncrementSpy).toHaveBeenNthCalledWith(2, 'user:user2', UserTier.FREE);
expect(checkAndIncrementSpy).not.toHaveBeenCalledWith('ip:192.168.1.1', expect.any(String));
});

it('should throw RateLimitExceededException when quota is exceeded', async () => {
jest.spyOn(trackingService, 'checkAndIncrement').mockResolvedValue({
allowed: false,
remaining: { minute: 0, hour: 0, day: 0 },
limit: { minute: 5, hour: 30, day: 100 },
retryAfter: 60,
});

const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: undefined,
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);

await expect(guard.canActivate(mockContext)).rejects.toThrow(RateLimitExceededException);
expect(trackingService.checkAndIncrement).toHaveBeenCalledWith(
'ip:192.168.1.1',
UserTier.UNAUTHENTICATED,
);
});

it('should bypass quota checks for admin users', async () => {
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: { id: 'admin1', role: 'admin' },
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(null);

const result = await guard.canActivate(mockContext);
expect(result).toBe(true);
expect(trackingService.checkAndIncrement).not.toHaveBeenCalled();
});

it('should skip quota when @SkipQuota() is applied', async () => {
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({
user: undefined,
ip: '192.168.1.1',
headers: {},
}),
getResponse: () => ({
setHeader: jest.fn(),
}),
}),
getHandler: jest.fn(),
getClass: jest.fn(),
} as unknown as ExecutionContext;

jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue({ skip: true });

const result = await guard.canActivate(mockContext);
expect(result).toBe(true);
expect(trackingService.checkAndIncrement).not.toHaveBeenCalled();
});
});
12 changes: 8 additions & 4 deletions src/rate-limiting/guards/quota.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ export class QuotaGuard implements CanActivate {
if (this.isWhitelisted(req)) return true;
if (this.isAdminUser(req.user)) return true;

const userId: string = req.user?.id ?? req.user?.sub ?? this.resolveIp(req);
// Use user:{id} format for authenticated users, ip:{address} for unauthenticated
const identifier: string =
(req.user?.id ?? req.user?.sub)
? `user:${req.user?.id ?? req.user?.sub}`
: this.resolveIp(req);
const tier: UserTier = options?.tier ?? this.resolveTier(req.user);

const result = await this.tracking.checkAndIncrement(userId, tier);
const result = await this.tracking.checkAndIncrement(identifier, tier);

res.setHeader('X-RateLimit-Limit-Minute', result.limit.minute);
res.setHeader('X-RateLimit-Limit-Hour', result.limit.hour);
Expand All @@ -66,7 +70,7 @@ export class QuotaGuard implements CanActivate {
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter ?? 60);
this.logger.warn(
`Quota exceeded userId=${userId} tier=${tier} retryAfter=${result.retryAfter}s`,
`Quota exceeded identifier=${identifier} tier=${tier} retryAfter=${result.retryAfter}s`,
);
throw new RateLimitExceededException(result.retryAfter);
}
Expand Down Expand Up @@ -98,7 +102,7 @@ export class QuotaGuard implements CanActivate {
}

private resolveTier(user?: any): UserTier {
if (!user) return UserTier.FREE;
if (!user) return UserTier.UNAUTHENTICATED;
const raw = (user.tier ?? user.plan ?? 'FREE').toString().toUpperCase();
return UserTier[raw as keyof typeof UserTier] ?? UserTier.FREE;
}
Expand Down
2 changes: 2 additions & 0 deletions src/rate-limiting/rate-limiting.constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum UserTier {
UNAUTHENTICATED = 'UNAUTHENTICATED',
FREE = 'FREE',
PRO = 'PRO',
PREMIUM = 'PREMIUM',
Expand All @@ -9,6 +10,7 @@ export const QUOTA_LIMITS: Record<
UserTier,
{ requestsPerMinute: number; requestsPerHour: number; requestsPerDay: number }
> = {
[UserTier.UNAUTHENTICATED]: { requestsPerMinute: 5, requestsPerHour: 30, requestsPerDay: 100 },
[UserTier.FREE]: { requestsPerMinute: 10, requestsPerHour: 100, requestsPerDay: 500 },
[UserTier.PRO]: { requestsPerMinute: 60, requestsPerHour: 1_000, requestsPerDay: 10_000 },
[UserTier.PREMIUM]: { requestsPerMinute: 200, requestsPerHour: 5_000, requestsPerDay: 50_000 },
Expand Down
4 changes: 2 additions & 2 deletions src/rate-limiting/services/quota-tracking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class QuotaTrackingService {
} else {
await this.markOverage({ minute, hour, day }, { withinMinute, withinHour, withinDay });
this.logger.warn(
`Quota exceeded userId=${userId} tier=${tier} ` +
`Quota exceeded identifier=${userId} tier=${tier} ` +
`min=${minute.count}/${limits.requestsPerMinute} ` +
`hr=${hour.count}/${limits.requestsPerHour} ` +
`day=${day.count}/${limits.requestsPerDay}`,
Expand Down Expand Up @@ -115,7 +115,7 @@ export class QuotaTrackingService {
async resetUser(userId: string, period?: QuotaResetPeriod): Promise<void> {
const where = period ? { userId, period } : { userId };
await this.usageRepo.delete(where);
this.logger.log(`Quota reset for userId=${userId} period=${period ?? 'ALL'}`);
this.logger.log(`Quota reset for identifier=${userId} period=${period ?? 'ALL'}`);
}

/** Called by the scheduler — deletes expired windows so they're recreated fresh. */
Expand Down
6 changes: 5 additions & 1 deletion tsconfig.build.tsbuildinfo

Large diffs are not rendered by default.

Loading