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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFileSync } from 'fs';
/* eslint-disable */
const { readFileSync } = require('fs');

// Reading the SWC compilation config for the spec files
const swcJestConfig = JSON.parse(
Expand All @@ -8,7 +9,7 @@ const swcJestConfig = JSON.parse(
// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves
swcJestConfig.swcrc = false;

export default {
module.exports = {
displayName: '@apps/rate-limiter',
preset: '../../jest.preset.js',
testEnvironment: 'node',
Expand Down
2 changes: 1 addition & 1 deletion apps/rate-limiter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"test:int": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/rate-limiter/jest.int.config.ts",
"jestConfig": "apps/rate-limiter/jest.int.config.cts",
"passWithNoTests": true
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { exectRedisScriptSha } from './redis-script.helper';

describe('exectRedisScriptSha', () => {
let redis: {
scriptLoad: jest.Mock;
evalSha: jest.Mock;
};

const script = 'return redis.call("GET", KEYS[1])';
const keys = ['rate-limit:client-1'];
const args = ['100', '60'];

beforeEach(() => {
redis = {
scriptLoad: jest.fn().mockResolvedValue('sha-abc123'),
evalSha: jest.fn().mockResolvedValue('result-value'),
};
});

describe('script loading', () => {
it('should load script SHA when scriptMeta.sha is null', async () => {
const scriptMeta = { sha: null, script };

await exectRedisScriptSha(redis as any, scriptMeta, keys, args);

expect(redis.scriptLoad).toHaveBeenCalledWith(script);
});

it('should skip scriptLoad when SHA is already set', async () => {
const scriptMeta = { sha: 'existing-sha', script };

await exectRedisScriptSha(redis as any, scriptMeta, keys, args);

expect(redis.scriptLoad).not.toHaveBeenCalled();
});

it('should persist loaded SHA on scriptMeta for reuse', async () => {
const scriptMeta = { sha: null as string | null, script };

await exectRedisScriptSha(redis as any, scriptMeta, keys, args);

expect(scriptMeta.sha).toBe('sha-abc123');
});
});

describe('script execution', () => {
it('should call evalSha with loaded SHA, keys, and args', async () => {
const scriptMeta = { sha: null, script };

await exectRedisScriptSha(redis as any, scriptMeta, keys, args);

expect(redis.evalSha).toHaveBeenCalledWith('sha-abc123', {
keys,
arguments: args,
});
});

it('should return the result from evalSha', async () => {
const scriptMeta = { sha: 'existing-sha', script };

const result = await exectRedisScriptSha(
redis as any,
scriptMeta,
keys,
args,
);

expect(result).toBe('result-value');
});
});

describe('NOSCRIPT error recovery', () => {
it('should reload script and retry on NOSCRIPT error', async () => {
const scriptMeta = { sha: 'stale-sha' as string | null, script };

redis.evalSha
.mockRejectedValueOnce(new Error('NOSCRIPT No matching script'))
.mockResolvedValueOnce('recovered-value');
redis.scriptLoad.mockResolvedValue('new-sha');

const result = await exectRedisScriptSha(
redis as any,
scriptMeta,
keys,
args,
);

expect(result).toBe('recovered-value');
expect(redis.scriptLoad).toHaveBeenCalledWith(script);
expect(redis.evalSha).toHaveBeenCalledTimes(2);
});

it('should update scriptMeta.sha after NOSCRIPT recovery', async () => {
const scriptMeta = { sha: 'stale-sha' as string | null, script };

redis.evalSha
.mockRejectedValueOnce(new Error('NOSCRIPT No matching script'))
.mockResolvedValueOnce('ok');
redis.scriptLoad.mockResolvedValue('fresh-sha');

await exectRedisScriptSha(redis as any, scriptMeta, keys, args);

expect(scriptMeta.sha).toBe('fresh-sha');
});
});

describe('non-NOSCRIPT errors', () => {
it('should re-throw non-NOSCRIPT errors without retry', async () => {
const scriptMeta = { sha: 'existing-sha', script };

redis.evalSha.mockRejectedValue(new Error('WRONGTYPE Operation'));

await expect(
exectRedisScriptSha(redis as any, scriptMeta, keys, args),
).rejects.toThrow('WRONGTYPE Operation');

expect(redis.scriptLoad).not.toHaveBeenCalled();
expect(redis.evalSha).toHaveBeenCalledTimes(1);
});
});
});
146 changes: 146 additions & 0 deletions apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RateLimitExceededException } from '../exceptions';
import type { RateLimitResult } from '../rate-limiter.types';
import { ClientIdentifierService } from '../services/client-identifier.service';
import { RateLimiterService } from '../services/rate-limiter.service';
import { RateLimitGuard } from './rate-limit.guard';

describe('RateLimitGuard', () => {
let guard: RateLimitGuard;
let reflector: jest.Mocked<Reflector>;
let rateLimiterService: jest.Mocked<RateLimiterService>;
let clientIdentifierService: jest.Mocked<ClientIdentifierService>;

const mockSetHeader = jest.fn();

const mockContext: ExecutionContext = {
getHandler: jest.fn(),
switchToHttp: () => ({
getRequest: () => ({}),
getResponse: () => ({ setHeader: mockSetHeader }),
}),
} as unknown as ExecutionContext;

const allowedResult: RateLimitResult = {
allowed: true,
limit: 100,
remaining: 99,
resetTime: 1711353600000, // fixed timestamp for predictable testing
};

const deniedResult: RateLimitResult = {
allowed: false,
limit: 100,
remaining: 0,
resetTime: 1711353600000,
};

beforeEach(() => {
reflector = {
get: jest.fn(),
} as any;

rateLimiterService = {
check: jest.fn().mockResolvedValue(allowedResult),
} as any;

clientIdentifierService = {
identifyClient: jest.fn().mockReturnValue('user:test-123'),
} as any;

guard = new RateLimitGuard(
reflector,
rateLimiterService,
clientIdentifierService,
);

mockSetHeader.mockClear();
});

describe('canActivate', () => {
it('should return true when no @RateLimit() decorator', async () => {
reflector.get.mockReturnValue(undefined);

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
expect(rateLimiterService.check).not.toHaveBeenCalled();
});

it('should return true (fail-open) when client cannot be identified', async () => {
reflector.get.mockReturnValue({ ruleId: 'default' });
clientIdentifierService.identifyClient.mockReturnValue(null);

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
expect(rateLimiterService.check).not.toHaveBeenCalled();
});

it('should call rateLimiterService.check with clientId and ruleId', async () => {
reflector.get.mockReturnValue({ ruleId: 'strict' });

await guard.canActivate(mockContext);

expect(rateLimiterService.check).toHaveBeenCalledWith(
'user:test-123',
'strict',
);
});

it('should return true when rate limit check allows', async () => {
reflector.get.mockReturnValue({ ruleId: 'default' });

const result = await guard.canActivate(mockContext);

expect(result).toBe(true);
});

it('should throw RateLimitExceededException when not allowed', async () => {
reflector.get.mockReturnValue({ ruleId: 'default' });
rateLimiterService.check.mockResolvedValue(deniedResult);

await expect(guard.canActivate(mockContext)).rejects.toThrow(
RateLimitExceededException,
);
});

it('should propagate errors from rateLimiterService.check', async () => {
reflector.get.mockReturnValue({ ruleId: 'default' });
rateLimiterService.check.mockRejectedValue(new Error('ECONNREFUSED'));

await expect(guard.canActivate(mockContext)).rejects.toThrow(
'ECONNREFUSED',
);
});
});

describe('rate limit headers', () => {
beforeEach(() => {
reflector.get.mockReturnValue({ ruleId: 'default' });
});

it('should set X-RateLimit-Limit header', async () => {
await guard.canActivate(mockContext);

expect(mockSetHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 100);
});

it('should set X-RateLimit-Remaining header', async () => {
await guard.canActivate(mockContext);

expect(mockSetHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 99);
});

it('should set X-RateLimit-Reset as Unix seconds (not ms)', async () => {
await guard.canActivate(mockContext);

// resetTime is 1711353600000ms → 1711353600 seconds
expect(mockSetHeader).toHaveBeenCalledWith(
'X-RateLimit-Reset',
Math.floor(1711353600000 / 1000),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NotFoundException } from '@nestjs/common';
import type { IRateLimitAlgorithm } from '../algorithms/base';
import type { AlgorithmType } from '../rate-limiter.types';
import { AlgorithmManagerService } from './algorithm-manager.service';

describe('AlgorithmManagerService', () => {
let service: AlgorithmManagerService;

// Minimal stubs — just need to be distinguishable objects
const fixedWindow = {
name: 'fixed-window',
} as unknown as IRateLimitAlgorithm;
const slidingWindowLog = {
name: 'sliding-window-log',
} as unknown as IRateLimitAlgorithm;
const slidingWindowCounter = {
name: 'sliding-window-counter',
} as unknown as IRateLimitAlgorithm;
const tokenBucket = {
name: 'token-bucket',
} as unknown as IRateLimitAlgorithm;

beforeEach(() => {
service = new AlgorithmManagerService(
fixedWindow as any,
slidingWindowLog as any,
slidingWindowCounter as any,
tokenBucket as any,
);
service.onModuleInit();
});

describe('getAlgorithm', () => {
const cases: { type: AlgorithmType; expected: IRateLimitAlgorithm }[] = [
{ type: 'fixed-window', expected: fixedWindow },
{ type: 'sliding-window-log', expected: slidingWindowLog },
{ type: 'sliding-window-counter', expected: slidingWindowCounter },
{ type: 'token-bucket', expected: tokenBucket },
];

it.each(cases)(
'should return $type algorithm after init',
({ type, expected }) => {
expect(service.getAlgorithm(type)).toBe(expected);
},
);

it('should throw NotFoundException for unknown algorithm type', () => {
expect(() => service.getAlgorithm('unknown' as AlgorithmType)).toThrow(
NotFoundException,
);
expect(() => service.getAlgorithm('unknown' as AlgorithmType)).toThrow(
'Rate limiting algorithm not found: unknown',
);
});
});
});
Loading
Loading