Skip to content
Open
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
171 changes: 170 additions & 1 deletion backend/src/middleware/__tests__/rateLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals';
import express, { type Express } from 'express';
import request from 'supertest';

const mockLoggerError = jest.fn();
const mockLoggerWarn = jest.fn();
Expand Down Expand Up @@ -30,7 +32,13 @@ jest.unstable_mockModule('redis', () => ({
}),
}));

const { createRateLimiter } = await import('../rateLimiter.js');
const {
createRateLimiter,
challengeRateLimiter,
loginRateLimiter,
ipLoginRateLimiter,
verifyRateLimiter,
} = await import('../rateLimiter.js');

const originalNodeEnv = process.env.NODE_ENV;
const originalJestWorkerId = process.env.JEST_WORKER_ID;
Expand Down Expand Up @@ -88,3 +96,164 @@ describe('rateLimiter Redis client error handling (#1852)', () => {
expect(mockLoggerError).not.toHaveBeenCalled();
});
});

describe('distributed counters, key isolation, and reset headers (#2091)', () => {
const WINDOW_MS = 60_000;
let clock = Date.UTC(2026, 5, 1);
const nowSpy = jest.spyOn(Date, 'now');

const buildTestApp = (): Express => {
const app = express();
app.set('trust proxy', 'loopback');
app.use(express.json());

app.post('/test/login', loginRateLimiter, (_req, res) => {
res.json({ ok: true });
});
app.post('/test/ip-login', ipLoginRateLimiter, (_req, res) => {
res.json({ ok: true });
});
app.post('/test/challenge', challengeRateLimiter, (_req, res) => {
res.json({ ok: true });
});
app.post('/test/verify', verifyRateLimiter, (_req, res) => {
res.json({ ok: true });
});

return app;
};

const app = buildTestApp();

beforeEach(() => {
clock += WINDOW_MS * 10;
nowSpy.mockImplementation(() => clock);
});

afterEach(() => {
nowSpy.mockReset();
});

describe('key isolation across endpoints and identity dimensions', () => {
it('loginRateLimiter compound key isolates distinct publicKeys on the same IP', async () => {
const ip = '198.51.100.22';

// User A makes 5 requests from ip
for (let i = 0; i < 5; i++) {
const res = await request(app)
.post('/test/login')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_ALICE' });
expect(res.status).toBe(200);
}

// User A 6th request is blocked (429)
const blockedA = await request(app)
.post('/test/login')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_ALICE' });
expect(blockedA.status).toBe(429);
expect(blockedA.headers['retry-after']).toBe('60');
expect(blockedA.body).toEqual({
success: false,
message: 'Too many login attempts, please try again later.',
});

// User B from same IP is NOT blocked because key is compound (ip:publicKey)
const resB = await request(app)
.post('/test/login')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_BOB' });
expect(resB.status).toBe(200);
});

it('ipLoginRateLimiter enforces aggregate limit per IP regardless of publicKey', async () => {
const ip = '198.51.100.33';

for (let i = 0; i < 5; i++) {
const res = await request(app)
.post('/test/ip-login')
.set('X-Forwarded-For', ip)
.send({ publicKey: `G_ACCOUNT_${i}` });
expect(res.status).toBe(200);
}

const blocked = await request(app)
.post('/test/ip-login')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_FRESH_ACCOUNT' });
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBe('60');
expect(blocked.body).toEqual({
success: false,
message: 'Too many login attempts from this IP, please try again later.',
});
});
});

describe('reset and retry-after header contracts', () => {
it('challengeRateLimiter sets standard headers and Retry-After upon exhaustion', async () => {
const ip = '198.51.100.44';

for (let i = 0; i < 10; i++) {
const res = await request(app)
.post('/test/challenge')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_CHALLENGE_USER' });
expect(res.status).toBe(200);
expect(res.headers['ratelimit-remaining']).toBe(String(10 - (i + 1)));
}

const blocked = await request(app)
.post('/test/challenge')
.set('X-Forwarded-For', ip)
.send({ publicKey: 'G_CHALLENGE_USER' });
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBe('60');
expect(blocked.body).toEqual({
success: false,
message: 'Too many challenge requests, please try again later.',
});
});

it('verifyRateLimiter sets Retry-After on 429 response', async () => {
const ip = '198.51.100.55';

for (let i = 0; i < 10; i++) {
const res = await request(app).post('/test/verify').set('X-Forwarded-For', ip).send({});
expect(res.status).toBe(200);
}

const blocked = await request(app).post('/test/verify').set('X-Forwarded-For', ip).send({});
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBe('60');
expect(blocked.body).toEqual({
success: false,
message: 'Too many verification attempts',
});
});
});

describe('Redis outages and fail-open resilience in production mode', () => {
it('fails open when Redis sendCommand rejects or times out', async () => {
delete (process.env as Record<string, string | undefined>).JEST_WORKER_ID;
process.env.NODE_ENV = 'development';

mockSendCommand.mockRejectedValue(new Error('ETIMEDOUT: Redis node unreachable'));

const resilientLimiter = createRateLimiter(1, 1, 'resilience-test');
const testApp = express();
testApp.set('trust proxy', 'loopback');
testApp.get('/test/resilience', resilientLimiter, (_req, res) => {
res.json({ success: true });
});

const res = await request(testApp)
.get('/test/resilience')
.set('X-Forwarded-For', '10.200.1.1');

expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
});
});
Loading