diff --git a/package-lock.json b/package-lock.json index 9c475db..eb23573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1905,6 +1905,12 @@ } } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", + "dev": true + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2416,6 +2422,12 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true }, + "denque": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", + "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==", + "dev": true + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -4418,6 +4430,34 @@ "loose-envify": "^1.0.0" } }, + "ioredis": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.14.0.tgz", + "integrity": "sha512-vGzyW9QTdGMjaAPUhMj48Z31mIO5qJLzkbsE5dg+orNi7L5Ph035htmkBZNDTDdDk7kp7e9UJUr+alhRuaWp8g==", + "dev": true, + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.1.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "redis-commands": "1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.0.1" + }, + "dependencies": { + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dev": true, + "requires": { + "redis-errors": "^1.0.0" + } + } + } + }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -5538,12 +5578,24 @@ "integrity": "sha1-+CbJtOKoUR2E46yinbBeGk87cqk=", "dev": true }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=", + "dev": true + }, "lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=", "dev": true }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -10105,6 +10157,12 @@ "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==", "dev": true }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "dev": true + }, "redis-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", @@ -11016,6 +11074,12 @@ "integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==", "dev": true }, + "standard-as-callback": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.0.1.tgz", + "integrity": "sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg==", + "dev": true + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", diff --git a/package.json b/package.json index a15e332..974d0ce 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "eslint-config-xo-space": "0.21.0", "eslint-config-xo-typescript": "0.17.0", "eslint-plugin-import": "2.18.2", + "ioredis": "^4.14.0", "jest": "24.9.0", "jest-junit": "8.0.0", "koa": "2.8.1", diff --git a/test/index.spec.ts b/test/index.spec.ts index 1712357..2fc2d33 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,11 +1,13 @@ import Koa from 'koa'; import redis from 'redis'; +import Redis from 'ioredis'; import request from 'supertest'; import ratelimit from '../src'; const db = redis.createClient(); +const ioDb = new Redis(); -describe('ratelimit middleware', () => { +describe('ratelimit middleware with `redis`', () => { const rateLimitDuration = 300; const goodBody = 'Num times hit: '; @@ -365,3 +367,364 @@ describe('ratelimit middleware', () => { }); }); }); + +describe('ratelimit middleware with `ioredis`', () => { + const rateLimitDuration = 300; + const goodBody = 'Num times hit: '; + + beforeEach(done => { + ioDb.keys('limit:*', (err, rows) => { + if (err) { + throw err; + } + + rows.forEach(n => ioDb.del(n)); + }); + + done(); + }); + + afterAll(() => { + return ioDb.end(true); + }); + + describe('limit', () => { + let guard; + let app; + + const routeHitOnlyOnce = () => { + expect(guard).toBe(1); + }; + + beforeEach(done => { + app = new Koa(); + + app.use( + ratelimit({ + duration: rateLimitDuration, + db: ioDb, + max: 1, + }), + ); + + app.use((ctx, next) => { + guard += 1; + ctx.body = `${goodBody}${guard}`; + return next(); + }); + + guard = 0; + + setTimeout(() => { + request(app.callback()) + .get('/') + .expect(200, `${goodBody}1`) + .expect(routeHitOnlyOnce) + .end(done); + }, rateLimitDuration); + }); + + it('should respond with 429 when rate limit is exceeded', done => { + request(app.callback()) + .get('/') + .expect('X-RateLimit-Remaining', '0') + .expect(429) + .end(done); + }); + + it('should not yield downstream if ratelimit is exceeded', done => { + request(app.callback()) + .get('/') + .expect(429) + .end(() => { + routeHitOnlyOnce(); + done(); + }); + }); + }); + + describe('limit twice', () => { + let guard; + let app; + + const routeHitOnlyOnce = () => { + expect(guard).toBe(1); + }; + + const routeHitTwice = () => { + expect(guard).toBe(2); + }; + + beforeEach(done => { + app = new Koa(); + + app.use( + ratelimit({ + duration: rateLimitDuration, + db: ioDb, + max: 2, + }), + ); + + app.use((ctx, next) => { + guard += 1; + ctx.body = `${goodBody}${guard}`; + return next(); + }); + + guard = 0; + + const listen = app.callback(); + setTimeout(() => { + request(listen) + .get('/') + .expect(200, `${goodBody}1`) + .expect(routeHitOnlyOnce) + .end(() => { + request(listen) + .get('/') + .expect(200, `${goodBody}2`) + .expect(routeHitTwice) + .end(done); + }); + }, rateLimitDuration * 2); + }); + + it('should respond with 429 when rate limit is exceeded', done => { + request(app.callback()) + .get('/') + .expect('X-RateLimit-Remaining', '0') + .expect(429) + .end(done); + }); + + it('should not yield downstream if ratelimit is exceeded', done => { + request(app.callback()) + .get('/') + .expect(429) + .end(() => { + routeHitTwice(); + done(); + }); + }); + }); + + describe('shortlimit', () => { + let guard; + let app; + + const routeHitOnlyOnce = () => { + expect(guard).toBe(1); + }; + + beforeEach(done => { + app = new Koa(); + + app.use( + ratelimit({ + duration: 1, + db: ioDb, + max: 1, + id: () => 'id', + }), + ); + + app.use((ctx, next) => { + guard += 1; + ctx.body = `${goodBody}${guard}`; + return next(); + }); + + guard = 0; + done(); + }); + it('should fix an id with -1 ttl', done => { + ioDb.decr('limit:id:count'); + request(app.callback()) + .get('/') + .expect('X-RateLimit-Remaining', '0') + .expect(routeHitOnlyOnce) + .expect(200) + .end(done); + }); + }); + + describe('limit with throw', () => { + let guard; + let app; + + const routeHitOnlyOnce = () => { + expect(guard).toBe(1); + }; + + beforeEach(done => { + app = new Koa(); + + app.use((ctx, next) => + next().catch(e => { + ctx.body = e.message; + ctx.set(e.headers); + }), + ); + + app.use( + ratelimit({ + duration: rateLimitDuration, + db: ioDb, + max: 1, + throw: true, + }), + ); + + app.use((ctx, next) => { + guard += 1; + ctx.body = `${goodBody}${guard}`; + return next(); + }); + + guard = 0; + + setTimeout(() => { + request(app.callback()) + .get('/') + .expect(200, `${goodBody}1`) + .expect(routeHitOnlyOnce) + .end(done); + }, rateLimitDuration); + }); + + it('responds with 429 when rate limit is exceeded', done => { + request(app.callback()) + .get('/') + .expect('X-RateLimit-Remaining', '0') + .expect(429) + .end(done); + }); + }); + + describe('id', () => { + it('should allow specifying a custom `id` function', done => { + const app = new Koa(); + + app.use( + ratelimit({ + db: ioDb, + duration: rateLimitDuration, + max: 1, + id: ctx => ctx.request.header.foo, + }), + ); + + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(res => { + expect(res.header['x-ratelimit-remaining']).toBe('0'); + }) + .end(done); + }); + + it('should not limit if `id` returns `false`', async () => { + const app = new Koa(); + + app.use( + ratelimit({ + db: ioDb, + duration: rateLimitDuration, + id: () => false, + max: 5, + }), + ); + + return request(app.callback()) + .get('/') + .expect(res => expect(res.header['x-ratelimit-remaining']).toBeUndefined()); + }); + + it('should limit using the `id` value', done => { + const app = new Koa(); + + app.use( + ratelimit({ + db: ioDb, + duration: rateLimitDuration, + max: 1, + id: ctx => ctx.request.header.foo, + }), + ); + + app.use(async (ctx, next) => { + ctx.body = ctx.request.header.foo; + return next(); + }); + + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(200, 'bar') + .end(() => { + request(app.callback()) + .get('/') + .set('foo', 'biz') + .expect(200, 'biz') + .end(done); + }); + }); + it('should whitelist using the `id` value', done => { + const app = new Koa(); + + app.use( + ratelimit({ + db: ioDb, + max: 1, + id: ctx => ctx.header.foo, + whitelist: ['bar'], + }), + ); + + app.use(ctx => { + ctx.body = ctx.header.foo; + }); + + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(200, 'bar') + .end(() => { + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(200, 'bar') + .end(done); + }); + }); + it('should blacklist using the `id` value', done => { + const app = new Koa(); + + app.use( + ratelimit({ + db: ioDb, + max: 1, + id: ctx => ctx.header.foo, + blacklist: ['bar'], + }), + ); + + app.use(ctx => { + ctx.body = ctx.header.foo; + }); + + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(200, 'bar') + .end(() => { + request(app.callback()) + .get('/') + .set('foo', 'bar') + .expect(403) + .end(done); + }); + }); + }); +});