From fcfb35dcc6145a4ceb5ec4ae9b493f637a270732 Mon Sep 17 00:00:00 2001 From: Pablo Ubal <134373651+pubalokta@users.noreply.github.com> Date: Thu, 1 Aug 2024 11:22:44 +0200 Subject: [PATCH] feat:support redis username (#76) * add tests for the clustered environment * add username support for clustered environments --- .github/workflows/ci.yml | 29 +- Makefile | 22 + README.md | 37 +- docker-compose-cluster.yml | 43 + lib/client.js | 2 +- lib/db.js | 3 + package.json | 3 +- .../redis-cluster/node-1/conf/redis.conf | 10 + .../redis-cluster/node-2/conf/redis.conf | 10 + .../redis-cluster/node-3/conf/redis.conf | 10 + .../redis-cluster/redis-cluster-create.sh | 5 + test/client.clustermode.tests.js | 39 + test/client.standalonemode.tests.js | 30 + test/client.tests.js | 337 +- test/db.clustermode.tests.js | 31 + test/db.standalonemode.tests.js | 198 + test/db.tests.js | 3909 ++++++++--------- 17 files changed, 2558 insertions(+), 2160 deletions(-) create mode 100644 Makefile create mode 100644 docker-compose-cluster.yml create mode 100644 test-resources/redis-cluster/node-1/conf/redis.conf create mode 100644 test-resources/redis-cluster/node-2/conf/redis.conf create mode 100644 test-resources/redis-cluster/node-3/conf/redis.conf create mode 100755 test-resources/redis-cluster/redis-cluster-create.sh create mode 100644 test/client.clustermode.tests.js create mode 100644 test/client.standalonemode.tests.js create mode 100644 test/db.clustermode.tests.js create mode 100644 test/db.standalonemode.tests.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79a3256..16da035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,19 +20,34 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Start containers - run: docker-compose -f "docker-compose.yml" up -d --build - - name: Install node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: install redis-cli + run: sudo apt-get install redis-tools + - name: Install dependencies run: npm install - - name: Run tests - run: npm run test + - name: Setup Standalone Tests + run: make test-standalone-setup + + - name: Run Standalone tests + run: make test-standalone + + - name: Teardown Standalone Tests + run: make test-standalone-teardown + + - name: Setup Clustered Tests + run: make test-cluster-setup + + - name: Check Redis Cluster + run: timeout 60 bash <<< "until redis-cli -c -p 16371 cluster info | grep 'cluster_state:ok'; do sleep 1; done" + + - name: Run Clustered tests + run: make test-cluster - - name: Stop containers - run: docker-compose -f "docker-compose.yml" down + - name: Teardown Clustered Tests + run: make test-cluster-teardown diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..604f21f --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +export CLUSTER_NO_TLS_VALIDATION=true + +test-standalone-setup: + docker-compose up -d + +test-standalone-teardown: + docker-compose down + +test-cluster-setup: + docker-compose -f docker-compose-cluster.yml up -d + +test-cluster-teardown: + docker-compose -f docker-compose-cluster.yml down + +test-cluster: + npm run test-cluster +test-standalone: + npm run test-standalone + +test: test-standalone test-cluster +test-setup: test-standalone-setup test-cluster-setup +test-teardown: test-standalone-teardown test-cluster-teardown \ No newline at end of file diff --git a/README.md b/README.md index 7f2531e..b24b9bd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,30 @@ `limitd-redis` is client for limits on top of `redis` using [Token Buckets](https://en.wikipedia.org/wiki/Token_bucket). It's a fork from [LimitDB](https://github.com/limitd/limitdb). +## Table of Contents + +- [Installation](#installation) +- [Configure](#configure) + - [Options available](#options-available) + - [Buckets](#buckets) + - [Ping](#ping) +- [Overrides](#overrides) +- [ERL (Elevated Rate Limits)](#erl-elevated-rate-limits) + - [Prerequisites](#prerequisites) + - [Introduction](#introduction) + - [Configuration](#configuration) + - [ERL Quota](#erl-quota) + - [Use of Redis hash tags](#use-of-redis-hash-tags) +- [Breaking changes from `Limitdb`](#breaking-changes-from-limitdb) +- [TAKE](#take) +- [TAKEELEVATED](#takeelevated) +- [PUT](#put) +- [Overriding Configuration at Runtime](#overriding-configuration-at-runtime) + - [Overriding Configuration at Runtime with ERL](#overriding-configuration-at-runtime-with-erl) +- [Testing](#testing) +- [Author](#author) +- [License](#license) + ## Installation ``` @@ -34,7 +58,9 @@ const limitd = new Limitd({ interval: 1000, maxFailedAttempts: 5, reconnectIfFailed: true - } + }, + username: 'username', + password: 'password' }); ``` @@ -45,6 +71,8 @@ const limitd = new Limitd({ - `buckets` (object): Setup your bucket types. - `prefix` (string): Prefix keys in Redis. - `ping` (object): Configure ping to Redis DB. +- `username` (string): Redis username. This is ignored if not in Cluster mode. Needs Redis >= v6. +- `password` (string): Redis password. ### Buckets: @@ -345,6 +373,13 @@ const configOverride = { } ``` +## Testing + +- Setup tests: `make test-setup` +- Run tests: `make test` +- Teardown tests: `make test-teardown` + + ## Author [Auth0](auth0.com) diff --git a/docker-compose-cluster.yml b/docker-compose-cluster.yml new file mode 100644 index 0000000..3af2613 --- /dev/null +++ b/docker-compose-cluster.yml @@ -0,0 +1,43 @@ +services: + redis-1: + image: 'redis:6' + healthcheck: + interval: "1s" + test: [ "CMD", "redis-cli", "-p", "16371", "ping", "|", "grep", "PONG" ] + command: ["redis-server", "/etc/redis/redis.conf"] + volumes: + - ${PWD}/test-resources/redis-cluster/node-1/conf/redis.conf:/etc/redis/redis.conf + network_mode: host + redis-2: + image: 'redis:6' + healthcheck: + interval: "1s" + test: [ "CMD", "redis-cli", "-p", "16372", "ping", "|", "grep", "PONG" ] + command: [ "redis-server", "/etc/redis/redis.conf" ] + volumes: + - ${PWD}/test-resources/redis-cluster/node-2/conf/redis.conf:/etc/redis/redis.conf + network_mode: host + redis-3: + image: 'redis:6' + healthcheck: + interval: "1s" + test: [ "CMD", "redis-cli", "-p", "16373", "ping", "|", "grep", "PONG" ] + command: [ "redis-server", "/etc/redis/redis.conf" ] + volumes: + - ${PWD}/test-resources/redis-cluster/node-3/conf/redis.conf:/etc/redis/redis.conf + network_mode: host + redis-cluster-create: + image: 'redis:6' + command: '/usr/local/etc/redis/redis-cluster-create.sh' + depends_on: + redis-1: + condition: service_healthy + redis-2: + condition: service_healthy + redis-3: + condition: service_healthy + volumes: + - ${PWD}/test-resources/redis-cluster/redis-cluster-create.sh:/usr/local/etc/redis/redis-cluster-create.sh + network_mode: host + healthcheck: + test: ["CMD-SHELL", "redis-cli -p 16371 -c cluster info | grep cluster_state:ok"] \ No newline at end of file diff --git a/lib/client.js b/lib/client.js index cdad356..0fe058e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -31,7 +31,7 @@ class LimitdRedis extends EventEmitter { this.db = new LimitDBRedis(_.pick(params, [ 'uri', 'nodes', 'buckets', 'prefix', 'slotsRefreshTimeout', 'slotsRefreshInterval', - 'password', 'tls', 'dnsLookup', 'globalTTL', 'cacheSize', 'ping'])); + 'username', 'password', 'tls', 'dnsLookup', 'globalTTL', 'cacheSize', 'ping'])); this.db.on('error', (err) => { this.emit('error', err); diff --git a/lib/db.js b/lib/db.js index 74da352..dde39db 100644 --- a/lib/db.js +++ b/lib/db.js @@ -82,6 +82,9 @@ class LimitDBRedis extends EventEmitter { this.redis = null; if (config.nodes) { + if (config.username) { + clusterOptions.redisOptions.username = config.username; + } this.redis = new Redis.Cluster(config.nodes, clusterOptions); } else { this.redis = new Redis(config.uri, redisOptions); diff --git a/package.json b/package.json index 3ef79cc..fe6fb46 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "url": "http://github.com/auth0/limitd-redis.git" }, "scripts": { - "test": "trap 'docker-compose down --remove-orphans -v' EXIT; docker-compose up -d && NODE_ENV=test nyc mocha --exit" + "test-standalone": "NODE_ENV=test nyc mocha --exit --exclude '**/*clustermode*'", + "test-cluster": "NODE_ENV=test nyc mocha --exit --exclude '**/*standalonemode*'" }, "author": "Auth0", "license": "MIT", diff --git a/test-resources/redis-cluster/node-1/conf/redis.conf b/test-resources/redis-cluster/node-1/conf/redis.conf new file mode 100644 index 0000000..a86e879 --- /dev/null +++ b/test-resources/redis-cluster/node-1/conf/redis.conf @@ -0,0 +1,10 @@ +port 16371 +bind 0.0.0.0 +cluster-enabled yes +cluster-config-file nodes.conf +cluster-node-timeout 5000 +cluster-announce-ip 0.0.0.0 +cluster-announce-port 16371 +cluster-announce-bus-port 26371 +appendonly yes +user testuser on >testpass ~* +@all \ No newline at end of file diff --git a/test-resources/redis-cluster/node-2/conf/redis.conf b/test-resources/redis-cluster/node-2/conf/redis.conf new file mode 100644 index 0000000..0855f83 --- /dev/null +++ b/test-resources/redis-cluster/node-2/conf/redis.conf @@ -0,0 +1,10 @@ +port 16372 +bind 0.0.0.0 +cluster-enabled yes +cluster-config-file nodes.conf +cluster-node-timeout 5000 +cluster-announce-ip 0.0.0.0 +cluster-announce-port 16372 +cluster-announce-bus-port 26372 +appendonly yes +user testuser on >testpass ~* +@all \ No newline at end of file diff --git a/test-resources/redis-cluster/node-3/conf/redis.conf b/test-resources/redis-cluster/node-3/conf/redis.conf new file mode 100644 index 0000000..ccd0c40 --- /dev/null +++ b/test-resources/redis-cluster/node-3/conf/redis.conf @@ -0,0 +1,10 @@ +port 16373 +bind 0.0.0.0 +cluster-enabled yes +cluster-config-file nodes.conf +cluster-node-timeout 5000 +cluster-announce-ip 0.0.0.0 +cluster-announce-port 16373 +cluster-announce-bus-port 26373 +appendonly yes +user testuser on >testpass ~* +@all \ No newline at end of file diff --git a/test-resources/redis-cluster/redis-cluster-create.sh b/test-resources/redis-cluster/redis-cluster-create.sh new file mode 100755 index 0000000..a1f4206 --- /dev/null +++ b/test-resources/redis-cluster/redis-cluster-create.sh @@ -0,0 +1,5 @@ +node_1_ip="0.0.0.0" +node_2_ip="0.0.0.0" +node_3_ip="0.0.0.0" + +redis-cli --cluster create $node_1_ip:16371 $node_2_ip:16372 $node_3_ip:16373 --cluster-replicas 0 --cluster-yes diff --git a/test/client.clustermode.tests.js b/test/client.clustermode.tests.js new file mode 100644 index 0000000..d1d37fc --- /dev/null +++ b/test/client.clustermode.tests.js @@ -0,0 +1,39 @@ +/* eslint-env node, mocha */ +const _ = require('lodash'); +const assert = require('chai').assert; +const LimitRedis = require('../lib/client'); +const clusterNodes = [{ host: '127.0.0.1', port: 16371 }, { host: '127.0.0.1', port: 16372 }, { host: '127.0.0.1', port: 16373 }]; +const clientTests = require('./client.tests'); + +describe('when using LimitdClient', () => { + describe('in Cluster mode', () => { + const clusteredClientFn = (params) => { + return new LimitRedis({ nodes: clusterNodes, buckets: {}, prefix: 'tests:', ..._.omit(params, ['uri']) }); + }; + + clientTests(clusteredClientFn); + + describe('when using the clustered #constructor', () => { + it('should allow setting username and password', (done) => { + let client = clusteredClientFn({ username: 'testuser', password: 'testpass' }); + client.on('ready', () => { + client.db.redis.acl("WHOAMI", (err, res) => { + assert.equal(res, 'testuser'); + done(); + }) + }); + }); + it('should use the default user if no one is provided', (done) => { + let client = clusteredClientFn(); + client.on('ready', () => { + client.db.redis.acl("WHOAMI", (err, res) => { + assert.equal(res, 'default'); + done(); + }) + }); + }); + }); + }); +}); + + diff --git a/test/client.standalonemode.tests.js b/test/client.standalonemode.tests.js new file mode 100644 index 0000000..012d6b1 --- /dev/null +++ b/test/client.standalonemode.tests.js @@ -0,0 +1,30 @@ +/* eslint-env node, mocha */ +const _ = require('lodash'); +const assert = require('chai').assert; +const LimitRedis = require('../lib/client'); +const clientTests = require('./client.tests'); + + +describe('when using LimitdClient', () => { + describe('Standalone Redis', () => { + const standaloneClientFn = (params) => { + return new LimitRedis({ uri: 'localhost', buckets: {}, prefix: 'tests:', ..._.omit(params, ['nodes']) }); + }; + + clientTests(standaloneClientFn); + + describe('when using the standalone #constructor', () => { + // in cluster mode, ioredis doesn't fail when given a bad node address, it keeps retrying + it('should call error if db fails', (done) => { + let called = false; // avoid uncaught + let client = standaloneClientFn({ uri: 'localhost:fail' }); + client.on('error', () => { + if (!called) { + called = true; + return done(); + } + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/client.tests.js b/test/client.tests.js index 44087db..f764590 100644 --- a/test/client.tests.js +++ b/test/client.tests.js @@ -4,205 +4,196 @@ const assert = require('chai').assert; const LimitRedis = require('../lib/client'); const ValidationError = LimitRedis.ValidationError; -describe('LimitdRedis', () => { - let client; - beforeEach((done) => { - client = new LimitRedis({ uri: 'localhost', buckets: {}, prefix: 'tests:' }); - client.on('error', done); - client.on('ready', done); - }); - - describe('#constructor', () => { - it('should call error if db fails', (done) => { - let called = false; // avoid uncaught - client = new LimitRedis({ uri: 'localhost:fail', buckets: {} }); - client.on('error', () => { - if (!called) { - called = true; - return done(); - } - }); +module.exports = (clientCreator) => { + describe('when using LimitdClient', () => { + let client; + beforeEach((done) => { + client = clientCreator(); + client.on('error', done); + client.on('ready', done); }); - it('should set up retry and circuitbreaker defaults', () => { - assert.equal(client.retryOpts.retries, 3); - assert.equal(client.retryOpts.minTimeout, 10); - assert.equal(client.retryOpts.maxTimeout, 30); - assert.equal(client.breakerOpts.timeout, '0.25s'); - assert.equal(client.breakerOpts.maxFailures, 50); - assert.equal(client.breakerOpts.cooldown, '1s'); - assert.equal(client.breakerOpts.maxCooldown, '3s'); - assert.equal(client.breakerOpts.name, 'limitr'); - assert.equal(client.commandTimeout, 75); - }); + describe('#constructor', () => { + it('should set up retry and circuitbreaker defaults', () => { + assert.equal(client.retryOpts.retries, 3); + assert.equal(client.retryOpts.minTimeout, 10); + assert.equal(client.retryOpts.maxTimeout, 30); + assert.equal(client.breakerOpts.timeout, '0.25s'); + assert.equal(client.breakerOpts.maxFailures, 50); + assert.equal(client.breakerOpts.cooldown, '1s'); + assert.equal(client.breakerOpts.maxCooldown, '3s'); + assert.equal(client.breakerOpts.name, 'limitr'); + assert.equal(client.commandTimeout, 75); + }); - it('should accept circuitbreaker parameters', () => { - client = new LimitRedis({ uri: 'localhost', buckets: {}, circuitbreaker: { onTrip: () => {} } }); - assert.ok(client.breakerOpts.onTrip); - }); + it('should accept circuitbreaker parameters', () => { + client = clientCreator({ circuitbreaker: { onTrip: () => {} } }); + assert.ok(client.breakerOpts.onTrip); + }); - it('should accept retry parameters', () => { - client = new LimitRedis({ uri: 'localhost', buckets: {}, retry: { retries: 5 } }); - assert.equa;(client.retryOpts.retries, 5); + it('should accept retry parameters', () => { + client = clientCreator({ retry: { retries: 5 } }); + assert.equal(client.retryOpts.retries, 5); + }); }); - }); - describe('#handler', () => { - it('should handle count & cb-less calls', (done) => { - client.db.take = (params, cb) => { - cb(); - done(); - }; - client.handler('take', 'test', 'test', 1); - }); - it('should handle count-less & cb-less calls', (done) => { - client.db.take = (params, cb) => { - cb(); - done(); - }; - client.handler('take', 'test', 'test'); - }); - it('should handle count-less & cb calls', (done) => { - client.db.take = (params, cb) => { - cb(); - }; - client.handler('take', 'test', 'test', done); - }); - it('should not retry or circuitbreak on ValidationError', (done) => { - client = new LimitRedis({ uri: 'localhost', buckets: {}, circuitbreaker: { maxFailures: 3, onTrip: () => {} } }); - client.db.take = (params, cb) => { - return cb(new ValidationError('invalid config')); - }; - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', _.noop); - client.handler('take', 'invalid', 'test', (err) => { - assert.notEqual(err.message, 'limitr: the circuit-breaker is open'); - assert.equal(err.message, 'invalid config'); - done(); + describe('#handler', () => { + it('should handle count & cb-less calls', (done) => { + client.db.take = (params, cb) => { + cb(); + done(); + }; + client.handler('take', 'test', 'test', 1); }); - }); - it('should retry on redis errors', (done) => { - let calls = 0; - client.db.take = (params, cb) => { - if (calls === 0) { - calls++; - return cb(new Error()); - } - return cb(); - }; - client.handler('take', 'test', 'test', done); - }); - it('should retry on timeouts against redis', (done) => { - let calls = 0; - client.db.take = (params, cb) => { - if (calls === 0) { - calls++; - return; - } - assert.equal(calls, 1); - return cb(); - }; - client.handler('take', 'test', 'test', done); - }); - it('should circuitbreak', (done) => { - client = new LimitRedis({ uri: 'localhost', buckets: {}, circuitbreaker: { maxFailures: 3, onTrip: () => {} } }); - client.db.take = () => {}; - client.handler('take', 'test', 'test', _.noop); - client.handler('take', 'test', 'test', _.noop); - client.handler('take', 'test', 'test', _.noop); - client.handler('take', 'test', 'test', _.noop); - client.handler('take', 'test', 'test', _.noop); - client.handler('take', 'test', 'test', () => { - client.handler('take', 'test', 'test', (err) => { - assert.equal(err.message, 'limitr: the circuit-breaker is open'); + it('should handle count-less & cb-less calls', (done) => { + client.db.take = (params, cb) => { + cb(); + done(); + }; + client.handler('take', 'test', 'test'); + }); + it('should handle count-less & cb calls', (done) => { + client.db.take = (params, cb) => { + cb(); + }; + client.handler('take', 'test', 'test', done); + }); + it('should not retry or circuitbreak on ValidationError', (done) => { + client = clientCreator({ circuitbreaker: { maxFailures: 3, onTrip: () => {} } }); + client.db.take = (params, cb) => { + return cb(new ValidationError('invalid config')); + }; + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', _.noop); + client.handler('take', 'invalid', 'test', (err) => { + assert.notEqual(err.message, 'limitr: the circuit-breaker is open'); + assert.equal(err.message, 'invalid config'); done(); }); }); + it('should retry on redis errors', (done) => { + let calls = 0; + client.db.take = (params, cb) => { + if (calls === 0) { + calls++; + return cb(new Error()); + } + return cb(); + }; + client.handler('take', 'test', 'test', done); + }); + it('should retry on timeouts against redis', (done) => { + let calls = 0; + client.db.take = (params, cb) => { + if (calls === 0) { + calls++; + return; + } + assert.equal(calls, 1); + return cb(); + }; + client.handler('take', 'test', 'test', done); + }); + it('should circuitbreak', (done) => { + client = clientCreator({ circuitbreaker: { maxFailures: 3, onTrip: () => {} } }); + client.db.take = () => {}; + client.handler('take', 'test', 'test', _.noop); + client.handler('take', 'test', 'test', _.noop); + client.handler('take', 'test', 'test', _.noop); + client.handler('take', 'test', 'test', _.noop); + client.handler('take', 'test', 'test', _.noop); + client.handler('take', 'test', 'test', () => { + client.handler('take', 'test', 'test', (err) => { + assert.equal(err.message, 'limitr: the circuit-breaker is open'); + done(); + }); + }); + }); }); - }); - describe('#take', () => { - it('should call #handle with take as the method', (done) => { - client.handler = (method, type, key, count, cb) => { - assert.equal(method, 'take'); - cb(); - }; - client.take('test', 'test', 1, done); + describe('#take', () => { + it('should call #handle with take as the method', (done) => { + client.handler = (method, type, key, count, cb) => { + assert.equal(method, 'take'); + cb(); + }; + client.take('test', 'test', 1, done); + }); }); - }); - describe('#takeElevated', () => { - it('should call #handle with takeElevated as the method', (done) => { - const elevated_limits = { erl_is_active_key: 'erlKEY', erl_quota_key: 'quotaKEY' }; - client.handler = (method, type, key, opts, cb) => { - assert.equal(method, 'takeElevated'); - assert.isNotNull(opts.elevated_limits); - assert.equal(opts.elevated_limits.erl_is_active_key, elevated_limits.erl_is_active_key); - assert.equal(opts.elevated_limits.erl_quota_key, elevated_limits.erl_quota_key); - cb(); - }; - client.takeElevated('test', 'test', { elevated_limits: elevated_limits }, done); + describe('#takeElevated', () => { + it('should call #handle with takeElevated as the method', (done) => { + const elevated_limits = { erl_is_active_key: 'erlKEY', erl_quota_key: 'quotaKEY' }; + client.handler = (method, type, key, opts, cb) => { + assert.equal(method, 'takeElevated'); + assert.isNotNull(opts.elevated_limits); + assert.equal(opts.elevated_limits.erl_is_active_key, elevated_limits.erl_is_active_key); + assert.equal(opts.elevated_limits.erl_quota_key, elevated_limits.erl_quota_key); + cb(); + }; + client.takeElevated('test', 'test', { elevated_limits: elevated_limits }, done); + }); }); - }); - describe('#wait', () => { - it('should call #handle with take as the method', (done) => { - client.handler = (method, type, key, count, cb) => { - assert.equal(method, 'wait'); - cb(); - }; - client.wait('test', 'test', 1, done); + describe('#wait', () => { + it('should call #handle with take as the method', (done) => { + client.handler = (method, type, key, count, cb) => { + assert.equal(method, 'wait'); + cb(); + }; + client.wait('test', 'test', 1, done); + }); }); - }); - describe('#put', () => { - it('should call #handle with take as the method', (done) => { - client.handler = (method, type, key, count, cb) => { - assert.equal(method, 'put'); - cb(); - }; - client.put('test', 'test', 1, done); + describe('#put', () => { + it('should call #handle with take as the method', (done) => { + client.handler = (method, type, key, count, cb) => { + assert.equal(method, 'put'); + cb(); + }; + client.put('test', 'test', 1, done); + }); }); - }); - describe('#get', () => { - it('should call #handle with get as the method', (done) => { - client.handler = (method, type, key, cb) => { - assert.equal(method, 'get'); - cb(); - }; - client.get('test', 'test', done); + describe('#get', () => { + it('should call #handle with get as the method', (done) => { + client.handler = (method, type, key, cb) => { + assert.equal(method, 'get'); + cb(); + }; + client.get('test', 'test', done); + }); }); - }); - describe('#reset', () => { - it('should call #put', (done) => { - client.put = (type, key, count, cb) => { - cb(); - }; - client.reset('test', 'test', 1, done); + describe('#reset', () => { + it('should call #put', (done) => { + client.put = (type, key, count, cb) => { + cb(); + }; + client.reset('test', 'test', 1, done); + }); }); - }); - describe('#resetAll', () => { - it('should call db.resetAll', (done) => { - client.db.resetAll = (cb) => cb(); - client.resetAll(done); + describe('#resetAll', () => { + it('should call db.resetAll', (done) => { + client.db.resetAll = (cb) => cb(); + client.resetAll(done); + }); }); - }); - describe('#close', () => { - it('should call db.close', (done) => { - client.db.close = (cb) => cb(); - client.close((err) => { - assert.equal(client.db.listenerCount('error'), 0); - assert.equal(client.db.listenerCount('ready'), 0); - done(err); + describe('#close', () => { + it('should call db.close', (done) => { + client.db.close = (cb) => cb(); + client.close((err) => { + assert.equal(client.db.listenerCount('error'), 0); + assert.equal(client.db.listenerCount('ready'), 0); + done(err); + }); }); }); }); -}); +}; \ No newline at end of file diff --git a/test/db.clustermode.tests.js b/test/db.clustermode.tests.js new file mode 100644 index 0000000..1dd614a --- /dev/null +++ b/test/db.clustermode.tests.js @@ -0,0 +1,31 @@ +const LimitDB = require('../lib/db'); +const _ = require('lodash'); +const { tests: dbTests } = require('./db.tests'); +const { assert } = require('chai'); +const clusterNodes = [{ host: '127.0.0.1', port: 16371 }, { host: '127.0.0.1', port: 16372 }, { host: '127.0.0.1', port: 16373 }]; + + + +describe('when using LimitDB', () => { + describe('in cluster mode', () => { + const clientCreator = (params) => { + return new LimitDB({ nodes: clusterNodes, buckets: {}, prefix: 'tests:', ..._.omit(params, ['uri']) }); + }; + + dbTests(clientCreator); + + describe('when using the clustered #constructor', () => { + it('should allow setting username and password', (done) => { + db = clientCreator({ buckets: {}, username: 'testuser', password: 'testpass' }); + db.on('ready', () => { + db.redis.acl("WHOAMI", (err, res) => { + assert.equal(res, 'testuser'); + done(); + }) + }); + db.on('error', (err) => done(err)); + db.on('node error', (err) => done(err)); + }); + }); + }) +}); \ No newline at end of file diff --git a/test/db.standalonemode.tests.js b/test/db.standalonemode.tests.js new file mode 100644 index 0000000..d662994 --- /dev/null +++ b/test/db.standalonemode.tests.js @@ -0,0 +1,198 @@ +const LimitDB = require('../lib/db'); +const _ = require('lodash'); +const { tests: dbTests, buckets} = require('./db.tests'); +const { assert } = require('chai'); +const { Toxiproxy, Toxic } = require('toxiproxy-node-client'); +const crypto = require('crypto'); + + + +describe('when using LimitDB', () => { + describe('in standalone mode', () => { + const clientCreator = (params) => { + return new LimitDB({ uri: 'localhost', buckets: {}, prefix: 'tests:', ..._.omit(params, ['nodes']) }); + }; + + dbTests(clientCreator); + + describe('when using the standalone #constructor', () => { + it('should emit error on failure to connect to redis', (done) => { + let called = false; + db = clientCreator({ uri: 'localhost:fail' }) + db.on('error', () => { + if (!called) { + called = true; + return done(); + } + }); + }); + }); + + describe('LimitDBRedis Ping', () => { + + let ping = { + enabled: () => true, + interval: 10, + maxFailedAttempts: 3, + reconnectIfFailed: () => true, + maxFailedAttemptsToRetryReconnect: 10 + }; + + let config = { + uri: 'localhost:22222', + buckets, + prefix: 'tests:', + ping, + }; + + let redisProxy; + let toxiproxy; + let db; + + beforeEach((done) => { + toxiproxy = new Toxiproxy('http://localhost:8474'); + proxyBody = { + listen: '0.0.0.0:22222', + name: crypto.randomUUID(), //randomize name to avoid concurrency issues + upstream: 'redis:6379' + }; + toxiproxy.createProxy(proxyBody) + .then((proxy) => { + redisProxy = proxy; + done(); + }); + + }); + + afterEach((done) => { + redisProxy.remove().then(() => + db.close((err) => { + // Can't close DB if it was never open + if (err?.message.indexOf('enableOfflineQueue') > 0 || err?.message.indexOf('Connection is closed') >= 0) { + err = undefined; + } + done(err); + }) + ); + }); + + it('should emit ping success', (done) => { + db = createDB({ uri: 'localhost:22222', buckets, prefix: 'tests:', ping }, done); + db.once(('ping'), (result) => { + if (result.status === LimitDB.PING_SUCCESS) { + done(); + } + }); + }); + + it('should emit "ping - error" when redis stops responding pings', (done) => { + let called = false; + + db = createDB(config, done); + db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); + db.on(('ping'), (result) => { + if (result.status === LimitDB.PING_ERROR && !called) { + called = true; + db.removeAllListeners('ping'); + done(); + } + }); + }); + + it('should emit "ping - reconnect" when redis stops responding pings and client is configured to reconnect', (done) => { + let called = false; + db = createDB(config, done); + db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); + db.on(('ping'), (result) => { + if (result.status === LimitDB.PING_RECONNECT && !called) { + called = true; + db.removeAllListeners('ping'); + done(); + } + }); + }); + + it('should emit "ping - reconnect dry run" when redis stops responding pings and client is NOT configured to reconnect', (done) => { + let called = false; + db = createDB({ ...config, ping: { ...ping, reconnectIfFailed: () => false } }, done); + db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); + db.on(('ping'), (result) => { + if (result.status === LimitDB.PING_RECONNECT_DRY_RUN && !called) { + called = true; + db.removeAllListeners('ping'); + done(); + } + }); + }); + + it(`should NOT emit ping events when config.ping is not set`, (done) => { + db = createDB({ ...config, ping: undefined }, done); + + db.once(('ping'), (result) => { + done(new Error(`unexpected ping event emitted ${result}`)); + }); + + //If after 100ms there are no interactions, we mark the test as passed. + setTimeout(done, 100); + }); + + it('should recover from a connection loss', (done) => { + let pingResponded = false; + let reconnected = false; + let toxic = undefined; + let timeoutId; + db = createDB({ ...config, ping: { ...ping, interval: 50 } }, done); + + db.on(('ping'), (result) => { + if (result.status === LimitDB.PING_SUCCESS) { + if (!pingResponded) { + pingResponded = true; + toxic = addLatencyToxic(redisProxy, 20000, (t) => toxic = t); + } else if (reconnected) { + clearTimeout(timeoutId); + db.removeAllListeners('ping'); + done(); + } + } else if (result.status === LimitDB.PING_RECONNECT) { + if (pingResponded && !reconnected) { + reconnected = true; + toxic.remove(); + } + } + }); + + timeoutId = setTimeout(() => done(new Error('Not reconnected')), 1800); + }); + + const createDB = (config, done) => { + let tmpDB = new LimitDB(config); + + tmpDB.on(('error'), (err) => { + //As we actively close the connection, there might be network-related errors while attempting to reconnect + if (err?.message.indexOf('enableOfflineQueue') > 0 || err?.message.indexOf('Command timed out') >= 0) { + err = undefined; + } + + if (err) { + console.log(err, err.message); + done(err); + } + }); + + return tmpDB; + }; + + const addLatencyToxic = (proxy, latency, callback) => { + let toxic = new Toxic( + proxy, + { type: 'latency', attributes: { latency: latency } } + ); + proxy.addToxic(toxic).then(callback); + }; + + + const noop = () => { + }; + }); + }) +}); \ No newline at end of file diff --git a/test/db.tests.js b/test/db.tests.js index 2f9e29b..95f04e3 100644 --- a/test/db.tests.js +++ b/test/db.tests.js @@ -2,10 +2,7 @@ const ms = require('ms'); const async = require('async'); const _ = require('lodash'); -const LimitDB = require('../lib/db'); const assert = require('chai').assert; -const { Toxiproxy, Toxic } = require('toxiproxy-node-client'); -const crypto = require('crypto'); const { endOfMonthTimestamp, replicateHashtag } = require('../lib/utils'); const buckets = { @@ -112,1586 +109,1205 @@ const elevatedBuckets = { }, }; -describe('LimitDBRedis', () => { - let db; - const prefix = 'tests:' - - beforeEach((done) => { - db = new LimitDB({ uri: 'localhost', buckets, prefix: prefix }); - db.once('error', done); - db.once('ready', () => { - db.resetAll(done); - }); - }); - - afterEach((done) => { - db.close((err) => { - // Can't close DB if it was never open - if (err?.message.indexOf('enableOfflineQueue') > 0) { - err = undefined; - } - done(err); +module.exports.buckets = buckets; +module.exports.elevatedBuckets = elevatedBuckets; +module.exports.tests = (clientCreator) => { + describe('LimitDBRedis', () => { + let db; + const prefix = 'tests:' + + beforeEach((done) => { + db = clientCreator({ buckets, prefix: prefix }); + db.once('error', done); + db.once('ready', () => { + db.resetAll(done); + }); }); - }); - describe('#constructor', () => { - it('should throw an when missing redis information', () => { - assert.throws(() => new LimitDB({}), /Redis connection information must be specified/); - }); - it('should throw an when missing bucket configuration', () => { - assert.throws(() => new LimitDB({ uri: 'localhost:test' }), /Buckets must be specified for Limitd/); - }); - it('should emit error on failure to connect to redis', (done) => { - let called = false; - db = new LimitDB({ uri: 'localhost:test', buckets: {} }); - db.on('error', () => { - if (!called) { - called = true; - return done(); + afterEach((done) => { + db.close((err) => { + // Can't close DB if it was never open + if (err?.message.indexOf('enableOfflineQueue') > 0) { + err = undefined; } + done(err); }); }); - }); - describe('#configurateBucketKey', () => { - it('should add new bucket to existing configuration', () => { - db.configurateBucket('test', { size: 5 }); - assert.containsAllKeys(db.buckets, ['ip', 'test']); + describe('#constructor', () => { + it('should throw an when missing redis information', () => { + assert.throws(() => clientCreator({ + uri: undefined, + nodes: undefined + }), /Redis connection information must be specified/); + }); + it('should throw an when missing bucket configuration', () => { + assert.throws(() => clientCreator({ + uri: 'localhost:fail', + nodes: [{ host: 'fakehost', port: 6379 }], + buckets: undefined + }), /Buckets must be specified for Limitd/); + }); + }); - it('should replace configuration of existing type', () => { - db.configurateBucket('ip', { size: 1 }); - assert.equal(db.buckets.ip.size, 1); - assert.equal(Object.keys(db.buckets.ip.overrides).length, 0); + describe('#configurateBucketKey', () => { + it('should add new bucket to existing configuration', () => { + db.configurateBucket('test', { size: 5 }); + assert.containsAllKeys(db.buckets, ['ip', 'test']); + }); + + it('should replace configuration of existing type', () => { + db.configurateBucket('ip', { size: 1 }); + assert.equal(db.buckets.ip.size, 1); + assert.equal(Object.keys(db.buckets.ip.overrides).length, 0); + }); }); - }); - describe('TAKE', () => { - const testsParams = [ - { - name: 'regular take', - init: () => db.configurateBuckets(buckets), - take: (params, callback) => db.take(params, callback), - params: {} - }, - { - name: 'elevated take with no elevated configuration', - init: () => db.configurateBuckets(buckets), - take: (params, callback) => db.takeElevated(params, callback), - params: { - elevated_limits: { - erl_activation_period_seconds: 900, - quota_per_calendar_month: 10, + describe('TAKE', () => { + const testsParams = [ + { + name: 'regular take', + init: () => db.configurateBuckets(buckets), + take: (params, callback) => db.take(params, callback), + params: {} + }, + { + name: 'elevated take with no elevated configuration', + init: () => db.configurateBuckets(buckets), + take: (params, callback) => db.takeElevated(params, callback), + params: { + elevated_limits: { + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10, + } } - } - }, - { - name: 'elevated take', - init: () => db.configurateBuckets(elevatedBuckets), - take: (params, callback) => db.takeElevated(params, callback), - params: { - elevated_limits: { - erl_activation_period_seconds: 900, - quota_per_calendar_month: 10, + }, + { + name: 'elevated take', + init: () => db.configurateBuckets(elevatedBuckets), + take: (params, callback) => db.takeElevated(params, callback), + params: { + elevated_limits: { + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10, + } } } - } - ]; - - testsParams.forEach(testParams => { - describe(`${testParams.name}`, () => { - it(`should fail on validation`, (done) => { - testParams.init(); - testParams.take({ ...testParams.params }, (err) => { - assert.match(err.message, /type is required/); - done(); + ]; + + testsParams.forEach(testParams => { + describe(`${testParams.name}`, () => { + it(`should fail on validation`, (done) => { + testParams.init(); + testParams.take({ ...testParams.params }, (err) => { + assert.match(err.message, /type is required/); + done(); + }); }); - }); - it(`should keep track of a key`, (done) => { - testParams.init(); - const params = { ...testParams.params, type: 'ip', key: '21.17.65.41' }; - testParams.take(params, (err) => { - if (err) { - return done(err); - } - testParams.take(params, (err, result) => { + it(`should keep track of a key`, (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'ip', key: '21.17.65.41' }; + testParams.take(params, (err) => { if (err) { return done(err); } - assert.equal(result.conformant, true); - assert.equal(result.limit, 10); - assert.equal(result.remaining, 8); - done(); + testParams.take(params, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.conformant, true); + assert.equal(result.limit, 10); + assert.equal(result.remaining, 8); + done(); + }); }); }); - }); - it(`should add a ttl to buckets`, (done) => { - testParams.init(); - const params = { ...testParams.params, type: 'ip', key: '211.45.66.1' }; - testParams.take(params, (err) => { - if (err) { - return done(err); - } - db.redis.ttl(`${params.type}:${params.key}`, (err, ttl) => { + it(`should add a ttl to buckets`, (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'ip', key: '211.45.66.1' }; + testParams.take(params, (err) => { if (err) { return done(err); } - assert.equal(db.buckets['ip'].ttl, ttl); - done(); + db.redis.ttl(`${params.type}:${params.key}`, (err, ttl) => { + if (err) { + return done(err); + } + assert.equal(db.buckets['ip'].ttl, ttl); + done(); + }); }); }); - }); - it(`should return TRUE with right remaining and reset after filling up the bucket`, (done) => { - testParams.init(); - const now = Date.now(); - testParams.take({ - ...testParams.params, - type: 'ip', - key: '5.5.5.5' - }, (err) => { - if (err) { - return done(err); - } - db.put({ + it(`should return TRUE with right remaining and reset after filling up the bucket`, (done) => { + testParams.init(); + const now = Date.now(); + testParams.take({ + ...testParams.params, type: 'ip', - key: '5.5.5.5', + key: '5.5.5.5' }, (err) => { if (err) { return done(err); } - testParams.take({ - ...testParams.params, + db.put({ type: 'ip', - key: '5.5.5.5' - }, (err, result) => { + key: '5.5.5.5', + }, (err) => { if (err) { return done(err); } - - assert.ok(result.conformant); - assert.equal(result.remaining, 9); - assert.closeTo(result.reset, now / 1000, 3); - assert.equal(result.limit, 10); - done(); + testParams.take({ + ...testParams.params, + type: 'ip', + key: '5.5.5.5' + }, (err, result) => { + if (err) { + return done(err); + } + + assert.ok(result.conformant); + assert.equal(result.remaining, 9); + assert.closeTo(result.reset, now / 1000, 3); + assert.equal(result.limit, 10); + done(); + }); }); }); }); - }); - - it(`should return TRUE when traffic is conformant`, (done) => { - testParams.init(); - const now = Date.now(); - testParams.take({ - ...testParams.params, - type: 'ip', - key: '1.1.1.1' - }, (err, result) => { - if (err) return done(err); - assert.ok(result.conformant); - assert.equal(result.remaining, 9); - assert.closeTo(result.reset, now / 1000, 3); - assert.equal(result.limit, 10); - done(); - }); - }); - - it(`should return FALSE when requesting more than the size of the bucket`, (done) => { - testParams.init(); - const now = Date.now(); - testParams.take({ - ...testParams.params, - type: 'ip', - key: '2.2.2.2', - count: 12 - }, (err, result) => { - if (err) return done(err); - assert.notOk(result.conformant); - assert.equal(result.remaining, 10); - assert.closeTo(result.reset, now / 1000, 3); - assert.equal(result.limit, 10); - done(); - }); - }); - - it(`should return FALSE when traffic is not conformant`, (done) => { - testParams.init(); - const takeParams = { - ...testParams.params, - type: 'ip', - key: '3.3.3.3' - }; - async.map(_.range(10), (i, done) => { - testParams.take(takeParams, done); - }, (err, responses) => { - if (err) return done(err); - assert.ok(responses.every((r) => { - return r.conformant; - })); - testParams.take(takeParams, (err, response) => { - assert.notOk(response.conformant); - assert.equal(response.limit, 10); - assert.equal(response.remaining, 0); - done(); - }); - }); - }); - it(`should return TRUE if an override by name allows more`, (done) => { - testParams.init(); - const takeParams = { - ...testParams.params, - type: 'ip', - key: '127.0.0.1' - }; - async.each(_.range(10), (i, done) => { - testParams.take(takeParams, done); - }, (err) => { - if (err) return done(err); - testParams.take(takeParams, (err, result) => { + it(`should return TRUE when traffic is conformant`, (done) => { + testParams.init(); + const now = Date.now(); + testParams.take({ + ...testParams.params, + type: 'ip', + key: '1.1.1.1' + }, (err, result) => { if (err) return done(err); assert.ok(result.conformant); - assert.ok(result.remaining, 89); - assert.equal(result.limit, 100); + assert.equal(result.remaining, 9); + assert.closeTo(result.reset, now / 1000, 3); + assert.equal(result.limit, 10); done(); }); }); - }); - it(`should return TRUE if an override allows more`, (done) => { - testParams.init(); - const takeParams = { - ...testParams.params, - type: 'ip', - key: '192.168.0.1' - }; - async.each(_.range(10), (i, done) => { - testParams.take(takeParams, done); - }, (err) => { - if (err) return done(err); - testParams.take(takeParams, (err, result) => { - assert.ok(result.conformant); - assert.ok(result.remaining, 39); - assert.equal(result.limit, 50); + it(`should return FALSE when requesting more than the size of the bucket`, (done) => { + testParams.init(); + const now = Date.now(); + testParams.take({ + ...testParams.params, + type: 'ip', + key: '2.2.2.2', + count: 12 + }, (err, result) => { + if (err) return done(err); + assert.notOk(result.conformant); + assert.equal(result.remaining, 10); + assert.closeTo(result.reset, now / 1000, 3); + assert.equal(result.limit, 10); done(); }); }); - }); - it(`can expire an override`, (done) => { - testParams.init(); - const takeParams = { - ...testParams.params, - type: 'ip', - key: '10.0.0.123' - }; - async.each(_.range(10), (i, cb) => { - testParams.take(takeParams, cb); - }, (err) => { - if (err) { - return done(err); - } - testParams.take(takeParams, (err, response) => { - assert.notOk(response.conformant); - done(); + it(`should return FALSE when traffic is not conformant`, (done) => { + testParams.init(); + const takeParams = { + ...testParams.params, + type: 'ip', + key: '3.3.3.3' + }; + async.map(_.range(10), (i, done) => { + testParams.take(takeParams, done); + }, (err, responses) => { + if (err) return done(err); + assert.ok(responses.every((r) => { + return r.conformant; + })); + testParams.take(takeParams, (err, response) => { + assert.notOk(response.conformant); + assert.equal(response.limit, 10); + assert.equal(response.remaining, 0); + done(); + }); }); }); - }); - it(`can parse a date and expire and override`, (done) => { - testParams.init(); - const takeParams = { - ...testParams.params, - type: 'ip', - key: '10.0.0.124' - }; - async.each(_.range(10), (i, cb) => { - testParams.take(takeParams, cb); - }, (err) => { - if (err) { - return done(err); - } - testParams.take(takeParams, (err, response) => { - assert.notOk(response.conformant); - done(); + it(`should return TRUE if an override by name allows more`, (done) => { + testParams.init(); + const takeParams = { + ...testParams.params, + type: 'ip', + key: '127.0.0.1' + }; + async.each(_.range(10), (i, done) => { + testParams.take(takeParams, done); + }, (err) => { + if (err) return done(err); + testParams.take(takeParams, (err, result) => { + if (err) return done(err); + assert.ok(result.conformant); + assert.ok(result.remaining, 89); + assert.equal(result.limit, 100); + done(); + }); }); }); - }); - - it(`should use seconds ceiling for next reset`, (done) => { - testParams.init(); - // it takes ~1790 msec to fill the bucket with this test - const now = Date.now(); - const requests = _.range(9).map(() => { - return cb => testParams.take({ ...testParams.params, type: 'ip', key: '211.123.12.36' }, cb); - }); - async.series(requests, (err, results) => { - if (err) return done(err); - const lastResult = results[results.length - 1]; - assert.ok(lastResult.conformant); - assert.equal(lastResult.remaining, 1); - assert.closeTo(lastResult.reset, now / 1000, 3); - assert.equal(lastResult.limit, 10); - done(); - }); - }); - - it(`should set reset to UNIX timestamp regardless of period`, (done) => { - testParams.init(); - const now = Date.now(); - testParams.take({ ...testParams.params, type: 'ip', key: '10.0.0.1' }, (err, result) => { - if (err) { - return done(err); - } - assert.ok(result.conformant); - assert.equal(result.remaining, 0); - assert.closeTo(result.reset, now / 1000 + 1800, 1); - assert.equal(result.limit, 1); - done(); - }); - }); - - it(`should not reduce tokens for unlimited`, (done) => { - testParams.init(); - const now = Date.now(); - testParams.take({ ...testParams.params, type: 'ip', key: '0.0.0.0' }, (err, response) => { - if (err) return done(err); - assert.ok(response.conformant); - assert.equal(response.limit, 100); - assert.equal(response.remaining, 100); - assert.closeTo(response.reset, now / 1000, 1); - done(); - }); - }); - it(`should work with a fixed bucket`, (done) => { - testParams.init(); - async.map(_.range(10), (i, done) => { - testParams.take({ ...testParams.params, type: 'ip', key: '8.8.8.8' }, done); - }, (err, results) => { - if (err) return done(err); - results.forEach((r, i) => { - assert.equal(r.remaining + i + 1, 10); - }); - assert.ok(results.every(r => r.conformant)); - testParams.take({ ...testParams.params, type: 'ip', key: '8.8.8.8' }, (err, response) => { - assert.notOk(response.conformant); - done(); + it(`should return TRUE if an override allows more`, (done) => { + testParams.init(); + const takeParams = { + ...testParams.params, + type: 'ip', + key: '192.168.0.1' + }; + async.each(_.range(10), (i, done) => { + testParams.take(takeParams, done); + }, (err) => { + if (err) return done(err); + testParams.take(takeParams, (err, result) => { + assert.ok(result.conformant); + assert.ok(result.remaining, 39); + assert.equal(result.limit, 50); + done(); + }); }); }); - }); - - it(`should work with RegExp`, (done) => { - testParams.init(); - testParams.take({ ...testParams.params, type: 'user', key: 'regexp|test' }, (err, response) => { - if (err) { - return done(err); - } - assert.ok(response.conformant); - assert.equal(response.remaining, 9); - assert.equal(response.limit, 10); - done(); - }); - }); - - it(`should work with "all"`, (done) => { - testParams.init(); - testParams.take({ ...testParams.params, type: 'user', key: 'regexp|test', count: 'all' }, (err, response) => { - if (err) { - return done(err); - } - assert.ok(response.conformant); - assert.equal(response.remaining, 0); - assert.equal(response.limit, 10); - done(); - }); - }); - - it(`should work with count=0`, (done) => { - testParams.init(); - testParams.take({ ...testParams.params, type: 'ip', key: '9.8.7.6', count: 0 }, (err, response) => { - if (err) { - return done(err); - } - assert.ok(response.conformant); - assert.equal(response.remaining, 200); - assert.equal(response.limit, 200); - done(); - }); - }); - [ - '0', - 0.5, - 'ALL', - true, - 1n, - {}, - ].forEach((count) => { - it(`should not work for non-integer count=${count}`, (done) => { + it(`can expire an override`, (done) => { testParams.init(); - const opts = { + const takeParams = { ...testParams.params, type: 'ip', - key: '9.8.7.6', - count, + key: '10.0.0.123' }; - - assert.throws(() => testParams.take(opts, () => { - }), /if provided, count must be 'all' or an integer value/); - done(); - }); - }); - - it(`should call redis and not set local cache count`, (done) => { - testParams.init(); - const params = { ...testParams.params, type: 'global', key: 'aTenant' }; - testParams.take(params, (err) => { - if (err) { - return done(err); - } - - assert.equal(db.callCounts['global:aTenant'], undefined); - done(); + async.each(_.range(10), (i, cb) => { + testParams.take(takeParams, cb); + }, (err) => { + if (err) { + return done(err); + } + testParams.take(takeParams, (err, response) => { + assert.notOk(response.conformant); + done(); + }); + }); }); - }); - describe(`${testParams.name} skip calls`, () => { - it('should skip calls', (done) => { + it(`can parse a date and expire and override`, (done) => { testParams.init(); - const params = { ...testParams.params, type: 'global', key: 'skipit' }; - - async.series([ - (cb) => testParams.take(params, cb), // redis - (cb) => testParams.take(params, cb), // cache - (cb) => testParams.take(params, cb), // cache - (cb) => { - assert.equal(db.callCounts.get('global:skipit').count, 2); - cb(); - }, - (cb) => testParams.take(params, cb), // redis - (cb) => testParams.take(params, cb), // cache - (cb) => testParams.take(params, cb), // cache - (cb) => testParams.take(params, cb), // redis (first nonconformant) - (cb) => testParams.take(params, cb), // cache (first cached) - (cb) => { - assert.equal(db.callCounts.get('global:skipit').count, 1); - assert.notOk(db.callCounts.get('global:skipit').res.conformant); - cb(); - }, - ], (err, _results) => { + const takeParams = { + ...testParams.params, + type: 'ip', + key: '10.0.0.124' + }; + async.each(_.range(10), (i, cb) => { + testParams.take(takeParams, cb); + }, (err) => { if (err) { return done(err); } + testParams.take(takeParams, (err, response) => { + assert.notOk(response.conformant); + done(); + }); + }); + }); + it(`should use seconds ceiling for next reset`, (done) => { + testParams.init(); + // it takes ~1790 msec to fill the bucket with this test + const now = Date.now(); + const requests = _.range(9).map(() => { + return cb => testParams.take({ ...testParams.params, type: 'ip', key: '211.123.12.36' }, cb); + }); + async.series(requests, (err, results) => { + if (err) return done(err); + const lastResult = results[results.length - 1]; + assert.ok(lastResult.conformant); + assert.equal(lastResult.remaining, 1); + assert.closeTo(lastResult.reset, now / 1000, 3); + assert.equal(lastResult.limit, 10); done(); }); }); - it('should take correct number of tokens for skipped calls with single count', (done) => { + it(`should set reset to UNIX timestamp regardless of period`, (done) => { testParams.init(); - const params = { ...testParams.params, type: 'global', key: 'skipOneSize3' }; - - // size = 3 - // skip_n_calls = 1 - // no refill - async.series([ - (cb) => db.get(params, (_, { remaining }) => { - assert.equal(remaining, 3); - cb(); - }), - - // call 1 - redis - // takes 1 token - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 2); - assert.ok(conformant); - cb(); - }), - - // call 2 - skipped - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 2); - assert.ok(conformant); - cb(); - }), - - // call 3 - redis - // takes 2 tokens here, 1 for current call and one for previously skipped call - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 0); - assert.ok(conformant); - cb(); - }), - - // call 4 - skipped - // Note: this is the margin of error introduced by skip_n_calls. Without skip_n_calls, this call would be - // non-conformant. - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 0); - assert.ok(conformant); - cb(); - }), - - // call 5 - redis - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 0); - assert.notOk(conformant); - cb(); - }), - ], (err, _results) => { + const now = Date.now(); + testParams.take({ ...testParams.params, type: 'ip', key: '10.0.0.1' }, (err, result) => { if (err) { return done(err); } + assert.ok(result.conformant); + assert.equal(result.remaining, 0); + assert.closeTo(result.reset, now / 1000 + 1800, 1); + assert.equal(result.limit, 1); done(); }); }); - it('should take correct number of tokens for skipped calls with multi count', (done) => { + it(`should not reduce tokens for unlimited`, (done) => { testParams.init(); - const params = { ...testParams.params, type: 'global', key: 'skipOneSize10', count: 2 }; - - // size = 10 - // skip_n_calls = 1 - // no refill - async.series([ - (cb) => db.get(params, (_, { remaining }) => { - assert.equal(remaining, 10); - cb(); - }), - - // call 1 - redis - // takes 2 tokens - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 8); - assert.ok(conformant); - cb(); - }), - - // call 2 - skipped - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 8); - assert.ok(conformant); - cb(); - }), - - // call 3 - redis - // takes 4 tokens here, 2 for current call and 2 for previously skipped call - (cb) => testParams.take(params, (_, { remaining, conformant }) => { - assert.equal(remaining, 4); - assert.ok(conformant); - cb(); - }), - ], (err, _results) => { - if (err) { - return done(err); - } + const now = Date.now(); + testParams.take({ ...testParams.params, type: 'ip', key: '0.0.0.0' }, (err, response) => { + if (err) return done(err); + assert.ok(response.conformant); + assert.equal(response.limit, 100); + assert.equal(response.remaining, 100); + assert.closeTo(response.reset, now / 1000, 1); done(); }); }); - }); - }); - }); - - it('should use size config override when provided', (done) => { - const configOverride = { size: 7 }; - db.take({ type: 'ip', key: '7.7.7.7', configOverride }, (err, response) => { - if (err) { - return done(err); - } - assert.ok(response.conformant); - assert.equal(response.remaining, 6); - assert.equal(response.limit, 7); - done(); - }); - }); - it('should use per interval config override when provided', (done) => { - const oneDayInMs = ms('24h'); - const configOverride = { per_day: 1 }; - db.take({ type: 'ip', key: '7.7.7.8', configOverride }, (err, response) => { - if (err) { - return done(err); - } - const dayFromNow = Date.now() + oneDayInMs; - assert.closeTo(response.reset, dayFromNow / 1000, 3); - done(); - }); - }); + it(`should work with a fixed bucket`, (done) => { + testParams.init(); + async.map(_.range(10), (i, done) => { + testParams.take({ ...testParams.params, type: 'ip', key: '8.8.8.8' }, done); + }, (err, results) => { + if (err) return done(err); + results.forEach((r, i) => { + assert.equal(r.remaining + i + 1, 10); + }); + assert.ok(results.every(r => r.conformant)); + testParams.take({ ...testParams.params, type: 'ip', key: '8.8.8.8' }, (err, response) => { + assert.notOk(response.conformant); + done(); + }); + }); + }); - it('should use size AND interval config override when provided', (done) => { - const oneDayInMs = ms('24h'); - const configOverride = { size: 3, per_day: 1 }; - db.take({ type: 'ip', key: '7.7.7.8', configOverride }, (err, response) => { - if (err) { - return done(err); - } - assert.ok(response.conformant); - assert.equal(response.remaining, 2); - assert.equal(response.limit, 3); + it(`should work with RegExp`, (done) => { + testParams.init(); + testParams.take({ ...testParams.params, type: 'user', key: 'regexp|test' }, (err, response) => { + if (err) { + return done(err); + } + assert.ok(response.conformant); + assert.equal(response.remaining, 9); + assert.equal(response.limit, 10); + done(); + }); + }); - const dayFromNow = Date.now() + oneDayInMs; - assert.closeTo(response.reset, dayFromNow / 1000, 3); - done(); - }); - }); + it(`should work with "all"`, (done) => { + testParams.init(); + testParams.take({ + ...testParams.params, + type: 'user', + key: 'regexp|test', + count: 'all' + }, (err, response) => { + if (err) { + return done(err); + } + assert.ok(response.conformant); + assert.equal(response.remaining, 0); + assert.equal(response.limit, 10); + done(); + }); + }); - it('should set ttl to reflect config override', (done) => { - const configOverride = { per_day: 5 }; - const params = { type: 'ip', key: '7.7.7.9', configOverride }; - db.take(params, (err) => { - if (err) { - return done(err); - } - db.redis.ttl(`${params.type}:${params.key}`, (err, ttl) => { - if (err) { - return done(err); - } - assert.equal(ttl, 86400); - done(); - }); - }); - }); + it(`should work with count=0`, (done) => { + testParams.init(); + testParams.take({ ...testParams.params, type: 'ip', key: '9.8.7.6', count: 0 }, (err, response) => { + if (err) { + return done(err); + } + assert.ok(response.conformant); + assert.equal(response.remaining, 200); + assert.equal(response.limit, 200); + done(); + }); + }); - it('should work with no overrides', (done) => { - const takeParams = { type: 'tenant', key: 'foo' }; - db.take(takeParams, (err, response) => { - assert.ok(response.conformant); - assert.equal(response.limit, 1); - assert.equal(response.remaining, 0); - done(); - }); - }); + [ + '0', + 0.5, + 'ALL', + true, + 1n, + {}, + ].forEach((count) => { + it(`should not work for non-integer count=${count}`, (done) => { + testParams.init(); + const opts = { + ...testParams.params, + type: 'ip', + key: '9.8.7.6', + count, + }; - it('should work with thousands of overrides', (done) => { - const big = _.cloneDeep(buckets); - for (let i = 0; i < 10000; i++) { - big.ip.overrides[`regex${i}`] = { - match: `172\\.16\\.${i}`, - per_second: 10 - }; - } - db.configurateBuckets(big); - - const takeParams = { type: 'ip', key: '172.16.1.1' }; - async.map(_.range(10), (i, done) => { - db.take(takeParams, done); - }, (err, responses) => { - if (err) return done(err); - assert.ok(responses.every((r) => { - return r.conformant; - })); - db.take(takeParams, (err, response) => { - assert.notOk(response.conformant); - assert.equal(response.limit, 10); - assert.equal(response.remaining, 0); - done(); - }); - }); - }); + assert.throws(() => testParams.take(opts, () => { + }), /if provided, count must be 'all' or an integer value/); + done(); + }); + }); - describe('elevated limits specific tests', () => { - const takeElevatedPromise = (params) => new Promise((resolve, reject) => { - db.takeElevated(params, (err, response) => { - if (err) { - return reject(err); - } - resolve(response); - }); - }); - const takePromise = (params) => new Promise((resolve, reject) => { - db.take(params, (err, response) => { - if (err) { - return reject(err); - } - resolve(response); - }); - }); - const redisExistsPromise = (key) => new Promise((resolve, reject) => { - db.redis.exists(key, (err, exists) => { - if (err) { - return reject(err); - } - resolve(exists); - }); - }); - const redisGetPromise = (key) => new Promise((resolve, reject) => { - db.redis.get(key, (err, value) => { - if (err) { - return reject(err); - } - resolve(value); + it(`should call redis and not set local cache count`, (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'global', key: 'aTenant' }; + testParams.take(params, (err) => { + if (err) { + return done(err); + } + + assert.equal(db.callCounts['global:aTenant'], undefined); + done(); + }); + }); + + describe(`${testParams.name} skip calls`, () => { + it('should skip calls', (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'global', key: 'skipit' }; + + async.series([ + (cb) => testParams.take(params, cb), // redis + (cb) => testParams.take(params, cb), // cache + (cb) => testParams.take(params, cb), // cache + (cb) => { + assert.equal(db.callCounts.get('global:skipit').count, 2); + cb(); + }, + (cb) => testParams.take(params, cb), // redis + (cb) => testParams.take(params, cb), // cache + (cb) => testParams.take(params, cb), // cache + (cb) => testParams.take(params, cb), // redis (first nonconformant) + (cb) => testParams.take(params, cb), // cache (first cached) + (cb) => { + assert.equal(db.callCounts.get('global:skipit').count, 1); + assert.notOk(db.callCounts.get('global:skipit').res.conformant); + cb(); + }, + ], (err, _results) => { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('should take correct number of tokens for skipped calls with single count', (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'global', key: 'skipOneSize3' }; + + // size = 3 + // skip_n_calls = 1 + // no refill + async.series([ + (cb) => db.get(params, (_, { remaining }) => { + assert.equal(remaining, 3); + cb(); + }), + + // call 1 - redis + // takes 1 token + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 2); + assert.ok(conformant); + cb(); + }), + + // call 2 - skipped + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 2); + assert.ok(conformant); + cb(); + }), + + // call 3 - redis + // takes 2 tokens here, 1 for current call and one for previously skipped call + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 0); + assert.ok(conformant); + cb(); + }), + + // call 4 - skipped + // Note: this is the margin of error introduced by skip_n_calls. Without skip_n_calls, this call would be + // non-conformant. + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 0); + assert.ok(conformant); + cb(); + }), + + // call 5 - redis + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 0); + assert.notOk(conformant); + cb(); + }), + ], (err, _results) => { + if (err) { + return done(err); + } + done(); + }); + }); + + it('should take correct number of tokens for skipped calls with multi count', (done) => { + testParams.init(); + const params = { ...testParams.params, type: 'global', key: 'skipOneSize10', count: 2 }; + + // size = 10 + // skip_n_calls = 1 + // no refill + async.series([ + (cb) => db.get(params, (_, { remaining }) => { + assert.equal(remaining, 10); + cb(); + }), + + // call 1 - redis + // takes 2 tokens + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 8); + assert.ok(conformant); + cb(); + }), + + // call 2 - skipped + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 8); + assert.ok(conformant); + cb(); + }), + + // call 3 - redis + // takes 4 tokens here, 2 for current call and 2 for previously skipped call + (cb) => testParams.take(params, (_, { remaining, conformant }) => { + assert.equal(remaining, 4); + assert.ok(conformant); + cb(); + }), + ], (err, _results) => { + if (err) { + return done(err); + } + done(); + }); + }); + }); }); }); - const redisSetPromise = (key, value) => new Promise((resolve, reject) => { - db.redis.set(key, value, (err, value) => { + + it('should use size config override when provided', (done) => { + const configOverride = { size: 7 }; + db.take({ type: 'ip', key: '7.7.7.7', configOverride }, (err, response) => { if (err) { - return reject(err); + return done(err); } - resolve(value); + assert.ok(response.conformant); + assert.equal(response.remaining, 6); + assert.equal(response.limit, 7); + done(); }); }); - const redisSetWithExpirePromise = (key, value, expireSecs) => new Promise((resolve, reject) => { - db.redis.set(key, value, 'EX', expireSecs, (err, value) => { + + it('should use per interval config override when provided', (done) => { + const oneDayInMs = ms('24h'); + const configOverride = { per_day: 1 }; + db.take({ type: 'ip', key: '7.7.7.8', configOverride }, (err, response) => { if (err) { - return reject(err); + return done(err); } - resolve(value); + const dayFromNow = Date.now() + oneDayInMs; + assert.closeTo(response.reset, dayFromNow / 1000, 3); + done(); }); }); - const redisTTLPromise = (key) => new Promise((resolve, reject) => { - db.redis.ttl(key, (err, value) => { + + it('should use size AND interval config override when provided', (done) => { + const oneDayInMs = ms('24h'); + const configOverride = { size: 3, per_day: 1 }; + db.take({ type: 'ip', key: '7.7.7.8', configOverride }, (err, response) => { if (err) { - return reject(err); + return done(err); } - resolve(value); + assert.ok(response.conformant); + assert.equal(response.remaining, 2); + assert.equal(response.limit, 3); + + const dayFromNow = Date.now() + oneDayInMs; + assert.closeTo(response.reset, dayFromNow / 1000, 3); + done(); }); }); - const redisDeletePromise = (key) => new Promise((resolve, reject) => { - db.redis.del(key, (err, value) => { + it('should set ttl to reflect config override', (done) => { + const configOverride = { per_day: 5 }; + const params = { type: 'ip', key: '7.7.7.9', configOverride }; + db.take(params, (err) => { if (err) { - return reject(err); + return done(err); } - resolve(value); + db.redis.ttl(`${params.type}:${params.key}`, (err, ttl) => { + if (err) { + return done(err); + } + assert.equal(ttl, 86400); + done(); + }); }); }); - const bucketName = 'bucket_with_elevated_limits_config'; - const key = 'some_key'; - const erl_is_active_key = 'some_erl_active_identifier'; - const erl_quota_key = 'erlquotakey'; - - it('should set a key at erl_is_active_key when erl is activated for a bucket with elevated_limits configuration', async () => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, + it('should work with no overrides', (done) => { + const takeParams = { type: 'tenant', key: 'foo' }; + db.take(takeParams, (err, response) => { + assert.ok(response.conformant); + assert.equal(response.limit, 1); + assert.equal(response.remaining, 0); + done(); }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + }); - // erl not activated yet - await takeElevatedPromise(params); - await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 0)); + it('should work with thousands of overrides', (done) => { + const big = _.cloneDeep(buckets); + for (let i = 0; i < 10000; i++) { + big.ip.overrides[`regex${i}`] = { + match: `172\\.16\\.${i}`, + per_second: 10 + }; + } + db.configurateBuckets(big); - // erl now activated - await takeElevatedPromise(params); - await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 1)); - }); - it('should return erl_active=false when erl is activated for the given key but the bucket has no elevated_limits configuration', async () => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - // erl not activated yet - await takeElevatedPromise(params); - await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 0)); - - // activate ERL manually (simulates other call activated it) - await redisSetPromise(hashtaggedERLIsActiveKey, 1); - - // erl now activated, verify call is non-conformant and erl_active=false - await takeElevatedPromise(params).then((result) => { - assert.isFalse(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.erl_configured_for_bucket) + const takeParams = { type: 'ip', key: '172.16.1.1' }; + async.map(_.range(10), (i, done) => { + db.take(takeParams, done); + }, (err, responses) => { + if (err) return done(err); + assert.ok(responses.every((r) => { + return r.conformant; + })); + db.take(takeParams, (err, response) => { + assert.notOk(response.conformant); + assert.equal(response.limit, 10); + assert.equal(response.remaining, 0); + done(); + }); }); }); - it('should NOT raise an error if elevated_limits object is not provided for a bucket with elevated_limits configuration', (done) => { - const bucketName = 'bucket_with_elevated_limits_config'; - const params = { type: bucketName, key: 'some_bucket_key' }; - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, - }); - db.takeElevated(params, (err) => { - done(err); - }); - }); - it('should NOT raise an error if elevated_limits.erl_is_active_key is not provided for a bucket with elevated_limits configuration', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - db.takeElevated(params, (err) => { - assert.isNull(err); - done(); - }); - }); - it('should NOT raise an error if elevated_limits.erl_quota_key is not provided for a bucket with elevated_limits configuration', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - db.takeElevated(params, (err) => { - assert.isNull(err); - done(); - }); - }); - it('should raise an error if elevated_limits.erl_activation_period_seconds is not provided for a bucket with elevated_limits configuration', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, + describe('elevated limits specific tests', () => { + const takeElevatedPromise = (params) => new Promise((resolve, reject) => { + db.takeElevated(params, (err, response) => { + if (err) { + return reject(err); + } + resolve(response); + }); }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, quota_per_calendar_month: 10 }, - }; - - db.takeElevated(params, (err) => { - assert.match(err.message, /erl_activation_period_seconds is required for elevated limits/); - done(); + const takePromise = (params) => new Promise((resolve, reject) => { + db.take(params, (err, response) => { + if (err) { + return reject(err); + } + resolve(response); + }); }); - }); - it('should raise an error if elevated_limits.quota_per_calendar_month is not provided for a bucket with elevated_limits configuration', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, + const redisExistsPromise = (key) => new Promise((resolve, reject) => { + db.redis.exists(key, (err, exists) => { + if (err) { + return reject(err); + } + resolve(exists); + }); }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900 }, - }; - - db.takeElevated(params, (err) => { - assert.match(err.message, /a valid quota amount per interval is required for elevated limits/); - done(); + const redisGetPromise = (key) => new Promise((resolve, reject) => { + db.redis.get(key, (err, value) => { + if (err) { + return reject(err); + } + resolve(value); + }); }); - }); - it('should apply erl limits if normal rate limits are exceeded', async () => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 10, - per_minute: 2, - }, + const redisSetPromise = (key, value) => new Promise((resolve, reject) => { + db.redis.set(key, value, (err, value) => { + if (err) { + return reject(err); + } + resolve(value); + }); }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - // first call, still within normal rate limits - await takeElevatedPromise(params).then((result) => { - assert.isFalse(result.elevated_limits.activated); - assert.equal(result.limit, 1); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + const redisSetWithExpirePromise = (key, value, expireSecs) => new Promise((resolve, reject) => { + db.redis.set(key, value, 'EX', expireSecs, (err, value) => { + if (err) { + return reject(err); + } + resolve(value); + }); }); - // second call, normal rate limits exceeded and erl is activated - await takeElevatedPromise(params).then((result) => { - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.isTrue(result.conformant); - assert.equal(result.limit, 10); - assert.equal(result.remaining, 8); + const redisTTLPromise = (key) => new Promise((resolve, reject) => { + db.redis.ttl(key, (err, value) => { + if (err) { + return reject(err); + } + resolve(value); + }); }); - }); - it('should rate limit if both normal and erl rate limit are exceeded', async () => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 2, - per_minute: 2, - }, - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - // first call, still within normal rate limits - await takeElevatedPromise(params).then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.remaining, 0); - assert.equal(result.limit, 1); - }); - // second call, normal rate limits exceeded and erl is activated. - // tokens in bucket is going to be 0 after this call (size 2 - 2 calls) - await takeElevatedPromise(params).then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.remaining, 0); - assert.equal(result.limit, 2); - }); - // third call, erl rate limit exceeded - await takeElevatedPromise(params).then((result) => { - assert.isFalse(result.conformant); // being rate limited - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.remaining, 0); - assert.equal(result.limit, 2); + const redisDeletePromise = (key) => new Promise((resolve, reject) => { + db.redis.del(key, (err, value) => { + if (err) { + return reject(err); + } + resolve(value); + }); }); - }); - it('should deduct already used tokens from new bucket when erl is activated', async () => { - await db.configurateBucket(bucketName, { - size: 2, - per_minute: 1, - elevated_limits: { - size: 10, + + const bucketName = 'bucket_with_elevated_limits_config'; + const key = 'some_key'; + const erl_is_active_key = 'some_erl_active_identifier'; + const erl_quota_key = 'erlquotakey'; + + it('should set a key at erl_is_active_key when erl is activated for a bucket with elevated_limits configuration', async () => { + db.configurateBucket(bucketName, { + size: 1, per_minute: 1, - } - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - await takeElevatedPromise(params); - await takeElevatedPromise(params); - await takeElevatedPromise(params).then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 10); - assert.equal(result.remaining, 7); // Total used tokens so far: 3 + elevated_limits: { + size: 2, + per_minute: 2, + }, + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; + + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + + // erl not activated yet + await takeElevatedPromise(params); + await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 0)); + + // erl now activated + await takeElevatedPromise(params); + await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 1)); }); - }); - it('should use ttl calculated using erl activation period if erl activation period is configured', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 10, + it('should return erl_active=false when erl is activated for the given key but the bucket has no elevated_limits configuration', async () => { + db.configurateBucket(bucketName, { + size: 1, per_minute: 1, - } - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 1200, quota_per_calendar_month: 10 }, - }; - - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - - takeElevatedPromise(params) - .then(() => takeElevatedPromise(params)) - .then(() => db.redis.ttl(hashtaggedERLIsActiveKey, (err, ttl) => { - assert.equal(ttl, 1200); // 20 minutes in seconds - done(); - })); - }); - it('should refill with erl refill rate when erl is active', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 5, - per_interval: 1, - interval: 10, - } + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; + + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + // erl not activated yet + await takeElevatedPromise(params); + await redisExistsPromise(hashtaggedERLIsActiveKey).then((isActive) => assert.equal(isActive, 0)); + + // activate ERL manually (simulates other call activated it) + await redisSetPromise(hashtaggedERLIsActiveKey, 1); + + // erl now activated, verify call is non-conformant and erl_active=false + await takeElevatedPromise(params).then((result) => { + assert.isFalse(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.erl_configured_for_bucket) + }); }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - takeElevatedPromise(params) - .then(() => takeElevatedPromise(params)) // erl activated - .then(() => new Promise((resolve) => setTimeout(resolve, 10))) // wait for 10ms - .then(() => takeElevatedPromise(params)) // refill with erl refill rate - .then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 5); - assert.equal(result.remaining, 3); - done(); + it('should NOT raise an error if elevated_limits object is not provided for a bucket with elevated_limits configuration', (done) => { + const bucketName = 'bucket_with_elevated_limits_config'; + const params = { type: bucketName, key: 'some_bucket_key' }; + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_minute: 2, + }, + }); + + db.takeElevated(params, (err) => { + done(err); }); - }); - it('should go back to standard bucket size and refill rate when we stop using takeElevated', (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_interval: 1, - interval: 20, // 1 token every 20 ms (50 RPS) - elevated_limits: { - size: 5, - per_interval: 1, - interval: 10, // 1 token every 10 ms (100 RPS) - } }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - // first call to take a token - takeElevatedPromise(params) - .then((result) => { - assert.equal(result.limit, 1); - assert.equal(result.remaining, 0); - }) - // second call. erl activated and token taken. tokens in bucket: 3 - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.equal(result.limit, 5); - assert.equal(result.remaining, 3); - }) - // wait for 10ms, refill 1 token while erl active. tokens in bucket: 4 - .then(() => new Promise((resolve) => setTimeout(resolve, 10))) - // take 1 token. tokens in bucket: 3 - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.remaining, 3); - assert.equal(result.limit, 5); - }) - // disable ERL, go back to standard bucket size and refill rate - // tokens in bucket: 1 (= bucket size) - // take 1 token. tokens in bucket: 0 - .then(() => takePromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.notExists(result.elevated_limits); - assert.equal(result.remaining, 0); - assert.equal(result.limit, 1); - }) - // wait for 2ms, refill 1 token while erl inactive. tokens in bucket: 1 - .then(() => new Promise((resolve) => setTimeout(resolve, 20))) - // take 1 token. tokens in bucket: 0 - .then(() => takePromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.notExists(result.elevated_limits); - assert.equal(result.remaining, 0); - assert.equal(result.limit, 1); - done(); + it('should NOT raise an error if elevated_limits.erl_is_active_key is not provided for a bucket with elevated_limits configuration', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_minute: 2, + }, }); - }); - - it("should exhaust all monthly erl quota before rate limiting", (done) => { - db.configurateBucket(bucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 3, - per_minute: 3, - } - }); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 1 }, - }; - - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - const hashtaggedERLQuotaKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_quota_key) - - // check erl not activated yet - redisExistsPromise(params.elevated_limits.erl_is_active_key) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // check erl_quota_key does not exist - .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) - // attempt to take elevated should work for first token - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.triggered); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); - }) - .then(() => redisExistsPromise(params.elevated_limits.erl_is_active_key)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) - // next takeElevated should activate ERL - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.triggered); - assert.equal(result.limit, 3); - }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota was increased - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - // exhaust the bucket - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.triggered); - assert.equal(result.limit, 3); - }) - .then(() => redisGetPromise(hashtaggedERLIsActiveKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - // remove erl_is_active_key to stop ERL - .then(() => redisDeletePromise(hashtaggedERLIsActiveKey)) - // next takeElevated should not activate ERL - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isFalse(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.triggered); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket); - assert.equal(result.limit, 1); - }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // check erlQuota was NOT increased - .then(() => redisExistsPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - .then(() => done()); - }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; - describe('when erl is activated for the tenant with multiple bucket configurations', () => { - const nonERLTestBucket = 'nonerl-test-bucket'; - const ERLBucketName = 'erl-test-bucket'; - const otherERLActiveKey = 'other_erl_key'; - const otherQuotaKey = 'other_quota_key' - const erlParams = { - type: ERLBucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - const otherErlParams = { - type: ERLBucketName, - key: key, - elevated_limits: { erl_is_active_key: otherERLActiveKey, erl_quota_key: otherQuotaKey, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - const nonErlParams = { - type: nonERLTestBucket, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - }; - - beforeEach(async () => { - db.configurateBucket(nonERLTestBucket, { - size: 1, - per_minute: 1, - }); - db.configurateBucket(ERLBucketName, { - size: 1, - per_minute: 1, - elevated_limits: { - size: 5, - per_minute: 1, - interval: 10, - } - }); - await takeElevatedPromise(erlParams) - await takeElevatedPromise(erlParams) // erl activated + db.takeElevated(params, (err) => { + assert.isNull(err); + done(); + }); }); - - describe('when the limit is exceeded for a bucket without erl configuration', async () => { - it('should be non conformant', async () => { - await takeElevatedPromise(nonErlParams) // non-erl bucket now empty - assert.isFalse((await takeElevatedPromise(nonErlParams)).conformant) + it('should NOT raise an error if elevated_limits.erl_quota_key is not provided for a bucket with elevated_limits configuration', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_minute: 2, + }, }); - }) + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; - describe('when the limit is exceeded for a bucket with erl configuration', () => { - it('should use ERL to take from the bucket if the given erl_is_active_key is set in Redis ', async () => { - const hashtaggedERLIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, erl_is_active_key) - const activeKey = await redisExistsPromise(hashtaggedERLIsActiveKey) - assert.equal(activeKey, 1) - await takeElevatedPromise(erlParams) - const result = await takeElevatedPromise(erlParams); - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated) - assert.equal(result.limit, 5); - }); - it('should NOT use ERL to take from the bucket if the given erl_is_active_key is NOT set in Redis', async() => { - const hashtaggedERLIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, erl_is_active_key) - const hashedERLOtherIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, otherERLActiveKey) - const activeKey = await redisExistsPromise(hashtaggedERLIsActiveKey) - assert.equal(activeKey, 1) - const inactiveKey = await redisExistsPromise(hashedERLOtherIsActiveKey) - assert.equal(inactiveKey, 0) - const result = await takeElevatedPromise(otherErlParams); - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated) - assert.equal(result.limit, 1); + db.takeElevated(params, (err) => { + assert.isNull(err); + done(); }); }); - }); - - describe('overrides', () => { - it('should use elevated_limits config override when provided', (done) => { - const bucketName = 'bucket_with_no_elevated_limits_config'; - const erl_is_active_key = 'some_erl_active_identifier'; + it('should raise an error if elevated_limits.erl_activation_period_seconds is not provided for a bucket with elevated_limits configuration', (done) => { db.configurateBucket(bucketName, { size: 1, - per_minute: 1 + per_minute: 1, + elevated_limits: { + size: 2, + per_minute: 2, + }, }); - const configOverride = { size: 1, + const params = { + type: bucketName, + key: key, elevated_limits: { - size: 3, - per_second: 3, - } + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + quota_per_calendar_month: 10 + }, }; + + db.takeElevated(params, (err) => { + assert.match(err.message, /erl_activation_period_seconds is required for elevated limits/); + done(); + }); + }); + it('should raise an error if elevated_limits.quota_per_calendar_month is not provided for a bucket with elevated_limits configuration', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_minute: 2, + }, + }); const params = { type: bucketName, key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - configOverride + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900 + }, }; - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - takeElevatedPromise(params) - .then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); - assert.equal(result.remaining, 0); - }) - .then(() => takeElevatedPromise(params)) - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 3); - assert.equal(result.remaining, 0); - }) - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isFalse(result.conformant); - db.redis.ttl(hashtaggedERLIsActiveKey, (err, ttl) => { - assert.equal(ttl, 900); - done(); - }); - }); + + db.takeElevated(params, (err) => { + assert.match(err.message, /a valid quota amount per interval is required for elevated limits/); + done(); + }); }); - describe('should use config override when elevated_limits is not provided and erl is active for the given key', ()=> { - const tests = [ - { - name: "overrides by param", - bucketConfig: { - size: 1, - per_minute: 1, - }, - configOverride: { - size: 2, - per_minute: 2, - }, - }, - { - name: "overrides in bucket config", - bucketConfig: { - size: 1, - per_minute: 1, - overrides: { - 'some_key': { - size: 2, - per_minute: 2, - }, - }, - }, - configOverride: undefined, + it('should apply erl limits if normal rate limits are exceeded', async () => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 10, + per_minute: 2, }, - { - name: "overrides in bucket config by matching key", - bucketConfig: { - size: 1, - per_minute: 1, - overrides: { - 'local key': { - size: 2, - per_minute: 2, - match: 'some_key', - }, - }, - }, - configOverride: undefined, + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 }, - ] - tests.forEach((test) => { - it(test.name, (done) => { - db.configurateBucket(bucketName, test.bucketConfig); - const params = { - type: bucketName, - key: key, - elevated_limits: { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 10 }, - configOverride: test.configOverride, - }; - redisSetPromise(erl_is_active_key, 1) - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.remaining, 1); - }) - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.erl_configured_for_bucket); - assert.equal(result.remaining, 0); - }) - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isFalse(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isFalse(result.elevated_limits.erl_configured_for_bucket); - assert.equal(result.remaining, 0); - }) - .then(done) - }); + }; + + // first call, still within normal rate limits + await takeElevatedPromise(params).then((result) => { + assert.isFalse(result.elevated_limits.activated); + assert.equal(result.limit, 1); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + }); + // second call, normal rate limits exceeded and erl is activated + await takeElevatedPromise(params).then((result) => { + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.isTrue(result.conformant); + assert.equal(result.limit, 10); + assert.equal(result.remaining, 8); }); - }); - }); - // erlquota tests - describe('erlQuota tests', () => { - const quota_per_calendar_month = 10; - const params = { - type: bucketName, - key: key, - }; - beforeEach(() => { + }); + it('should rate limit if both normal and erl rate limit are exceeded', async () => { db.configurateBucket(bucketName, { size: 1, per_minute: 1, elevated_limits: { size: 2, - per_second: 2, - } + per_minute: 2, + }, }); - }); - const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - const hashtaggedERLQuotaKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_quota_key) - - it('should return quota_remaining = quota_per_calendar_month-1, quota_allocated and erl_activation_period_seconds when ERL is triggered for the first time in the month', (done) => { - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: quota_per_calendar_month }; + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; - // check erl not activated yet - redisExistsPromise(erl_is_active_key) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // check erl_quota_key does not exist - .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key)) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) - // attempt to take elevated should work for first token - .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(erl_is_active_key)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should activate ERL - .then(() => takeElevatedPromise(params)) - .then((response) => { - assert.isTrue(response.elevated_limits.triggered); - assert.isTrue(response.elevated_limits.activated); - assert.isTrue(response.elevated_limits.erl_configured_for_bucket) - assert.equal(response.elevated_limits.quota_remaining, quota_per_calendar_month-1); - assert.isAtLeast(response.elevated_limits.erl_activation_period_seconds, 900); - assert.isAtLeast(response.elevated_limits.quota_allocated, quota_per_calendar_month); - assert.equal(response.limit, 2); - }) - .then(() => done()); + // first call, still within normal rate limits + await takeElevatedPromise(params).then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.remaining, 0); + assert.equal(result.limit, 1); + }); + // second call, normal rate limits exceeded and erl is activated. + // tokens in bucket is going to be 0 after this call (size 2 - 2 calls) + await takeElevatedPromise(params).then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.remaining, 0); + assert.equal(result.limit, 2); + }); + // third call, erl rate limit exceeded + await takeElevatedPromise(params).then((result) => { + assert.isFalse(result.conformant); // being rate limited + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.remaining, 0); + assert.equal(result.limit, 2); + }); }); + it('should deduct already used tokens from new bucket when erl is activated', async () => { + await db.configurateBucket(bucketName, { + size: 2, + per_minute: 1, + elevated_limits: { + size: 10, + per_minute: 1, + } + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; - it('should return quota_remaining = -1 when ERL had already been activated', (done) => { - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: quota_per_calendar_month }; - - // setup ERL - redisSetPromise(erl_is_active_key, 1) - .then(() => redisSetPromise(hashtaggedERLIsActiveKey, params.elevated_limits.per_calendar_month - 1)) - // takeElevated with ERL activated - .then(() => takeElevatedPromise(params)) - .then((response) => { - assert.isFalse(response.elevated_limits.triggered); - assert.isTrue(response.elevated_limits.activated); - assert.isTrue(response.elevated_limits.erl_configured_for_bucket) - assert.equal(response.elevated_limits.quota_remaining, -1); - assert.equal(response.limit, 2); - }) - .then(() => done()); + await takeElevatedPromise(params); + await takeElevatedPromise(params); + await takeElevatedPromise(params).then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 10); + assert.equal(result.remaining, 7); // Total used tokens so far: 3 + }); }); + it('should use ttl calculated using erl activation period if erl activation period is configured', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 10, + per_minute: 1, + } + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 1200, + quota_per_calendar_month: 10 + }, + }; - it('should set ttl accordingly on erl_quota_key when we activate ERL', (done) => { - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: quota_per_calendar_month }; - - const eom = endOfMonthTimestamp(); - // get ms between now and eom - const expectedTTL = Math.floor((eom - Date.now()) / 1000); + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) - // check erl not activated yet - redisExistsPromise(erl_is_active_key) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // check erl_quota_key does not exist - .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key)) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) - // attempt to take elevated should work for first token - .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(erl_is_active_key)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should activate ERL + takeElevatedPromise(params) .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota should be decreased by 1 - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - // check ttl on erl_quota_key - .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) - .then((ttl) => assert.closeTo(ttl, expectedTTL, 2)) - .then(() => done()); + .then(() => db.redis.ttl(hashtaggedERLIsActiveKey, (err, ttl) => { + assert.equal(ttl, 1200); // 20 minutes in seconds + done(); + })); }); - - it('should keep ttl on erl_quota_key after increasing it', (done) => { - params.elevated_limits = { - erl_is_active_key: erl_is_active_key, - erl_quota_key: erl_quota_key, - erl_activation_period_seconds: 900, - quota_per_calendar_month: quota_per_calendar_month, + it('should refill with erl refill rate when erl is active', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 5, + per_interval: 1, + interval: 10, + } + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, }; - let expectedTTL = 0; - // check erl not activated yet - redisExistsPromise(erl_is_active_key) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // check erl_quota_key does not exist - .then(() => redisExistsPromise(hashtaggedERLQuotaKey) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) - // attempt to take elevated should work for first token - .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should activate ERL - .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota should be decreased by 1 - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - // check ttl on erl_quota_key - .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) - .then((ttl) => expectedTTL = ttl) - // stop ERL - .then(() => redisDeletePromise(hashtaggedERLIsActiveKey)) - // next takeElevated should re-activate ERL - .then(() => takeElevatedPromise(params)) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota should be decreased by 1 - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 2)) - // check erlQuota keeps the TTL - .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) - .then((ttl) => assert.equal(ttl, expectedTTL)) - .then(() => done()); + takeElevatedPromise(params) + .then(() => takeElevatedPromise(params)) // erl activated + .then(() => new Promise((resolve) => setTimeout(resolve, 10))) // wait for 10ms + .then(() => takeElevatedPromise(params)) // refill with erl refill rate + .then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 5); + assert.equal(result.remaining, 3); + done(); + }); }); - - it('should increase erlQuota when we activate ERL', (done) => { - // activating ERL with per_calendar_month=1 is testing a border case to make sure decreasing the quota - // is not interpreted in the script as no quota left for activating ERL - params.elevated_limits = { - erl_is_active_key: erl_is_active_key, - erl_quota_key: erl_quota_key, - erl_activation_period_seconds: 900, - quota_per_calendar_month: 10, + it('should go back to standard bucket size and refill rate when we stop using takeElevated', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_interval: 1, + interval: 20, // 1 token every 20 ms (50 RPS) + elevated_limits: { + size: 5, + per_interval: 1, + interval: 10, // 1 token every 10 ms (100 RPS) + } + }); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, }; - // check erl not activated yet - redisExistsPromise(erl_is_active_key) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // check erl_quota_key does not exist - .then(() => redisExistsPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) - // attempt to take elevated should work for first token + // first call to take a token + takeElevatedPromise(params) + .then((result) => { + assert.equal(result.limit, 1); + assert.equal(result.remaining, 0); + }) + // second call. erl activated and token taken. tokens in bucket: 3 .then(() => takeElevatedPromise(params)) .then((result) => { - assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); + assert.equal(result.limit, 5); + assert.equal(result.remaining, 3); }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should activate ERL and return conformant + // wait for 10ms, refill 1 token while erl active. tokens in bucket: 4 + .then(() => new Promise((resolve) => setTimeout(resolve, 10))) + // take 1 token. tokens in bucket: 3 .then(() => takeElevatedPromise(params)) .then((result) => { assert.isTrue(result.conformant); assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.remaining, 3); + assert.equal(result.limit, 5); }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota should be decreased by 1 - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - .then(() => done()); + // disable ERL, go back to standard bucket size and refill rate + // tokens in bucket: 1 (= bucket size) + // take 1 token. tokens in bucket: 0 + .then(() => takePromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.notExists(result.elevated_limits); + assert.equal(result.remaining, 0); + assert.equal(result.limit, 1); + }) + // wait for 2ms, refill 1 token while erl inactive. tokens in bucket: 1 + .then(() => new Promise((resolve) => setTimeout(resolve, 20))) + // take 1 token. tokens in bucket: 0 + .then(() => takePromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.notExists(result.elevated_limits); + assert.equal(result.remaining, 0); + assert.equal(result.limit, 1); + done(); + }); }); - it('should not activate ERL when erlQuota is 0', (done) => { + it("should exhaust all monthly erl quota before rate limiting", (done) => { db.configurateBucket(bucketName, { size: 1, per_minute: 1, elevated_limits: { - size: 2, - per_second: 2, + size: 3, + per_minute: 3, } }); - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: 0 }; + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 1 + }, + }; + + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + const hashtaggedERLQuotaKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_quota_key) // check erl not activated yet - redisExistsPromise(erl_is_active_key) + redisExistsPromise(params.elevated_limits.erl_is_active_key) .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) // check erl_quota_key does not exist .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key) @@ -1701,768 +1317,1107 @@ describe('LimitDBRedis', () => { .then((result) => { assert.isTrue(result.conformant); assert.isFalse(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.triggered); assert.isTrue(result.elevated_limits.erl_configured_for_bucket) assert.equal(result.limit, 1); }) - .then(() => redisExistsPromise(erl_is_active_key)) + .then(() => redisExistsPromise(params.elevated_limits.erl_is_active_key)) .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should have attempted to activate ERL but failed + .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) + // next takeElevated should activate ERL .then(() => takeElevatedPromise(params)) .then((result) => { - assert.isFalse(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.triggered); + assert.equal(result.limit, 3); }) - .then(() => redisExistsPromise(erl_is_active_key)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // check erlQuota was not set - .then(() => redisGetPromise(params.elevated_limits.erl_quota_key)) - .then((erl_quota_keyValue) => assert.isNull(erl_quota_keyValue)) - .then(() => done()); - }); - - it('should not activate ERL if erl_quota_key exists and is at its max allowed', (done) => { - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: quota_per_calendar_month }; - - // set erl_quota_key to the given max allowed per month in redis - redisSetPromise(hashtaggedERLQuotaKey, quota_per_calendar_month) - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) - // check erl not activated yet .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // attempt to take elevated should work for first token + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota was increased + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + // exhaust the bucket .then(() => takeElevatedPromise(params)) .then((result) => { assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); + assert.isTrue(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.triggered); + assert.equal(result.limit, 3); }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should have attempted to activate ERL but failed as quota is at its max allowed + .then(() => redisGetPromise(hashtaggedERLIsActiveKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + // remove erl_is_active_key to stop ERL + .then(() => redisDeletePromise(hashtaggedERLIsActiveKey)) + // next takeElevated should not activate ERL .then(() => takeElevatedPromise(params)) .then((result) => { assert.isFalse(result.conformant); assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.isFalse(result.elevated_limits.triggered); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket); assert.equal(result.limit, 1); }) .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // check erlQuota wasn't modified - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) + // check erlQuota was NOT increased + .then(() => redisExistsPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) .then(() => done()); }); - it('should activate ERL when erl_quota_key expires after reaching allowed per month quota', (done) => { - params.elevated_limits = { erl_is_active_key: erl_is_active_key, erl_quota_key: erl_quota_key, erl_activation_period_seconds: 900, quota_per_calendar_month: quota_per_calendar_month }; + describe('when erl is activated for the tenant with multiple bucket configurations', () => { + const nonERLTestBucket = 'nonerl-test-bucket'; + const ERLBucketName = 'erl-test-bucket'; + const otherERLActiveKey = 'other_erl_key'; + const otherQuotaKey = 'other_quota_key' + const erlParams = { + type: ERLBucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; + const otherErlParams = { + type: ERLBucketName, + key: key, + elevated_limits: { + erl_is_active_key: otherERLActiveKey, + erl_quota_key: otherQuotaKey, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; + const nonErlParams = { + type: nonERLTestBucket, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + }; - // set erl_quota_key to given max quota per month in redis - redisSetWithExpirePromise(hashtaggedERLQuotaKey, quota_per_calendar_month, 1) - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) - .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) - .then((quotaTTL) => assert.equal(quotaTTL, 1)) - // check erl not activated yet - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) - // attempt to take elevated should work for first token - .then(() => takeElevatedPromise(params)) - .then((result) => { + beforeEach(async () => { + db.configurateBucket(nonERLTestBucket, { + size: 1, + per_minute: 1, + }); + db.configurateBucket(ERLBucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 5, + per_minute: 1, + interval: 10, + } + }); + await takeElevatedPromise(erlParams) + await takeElevatedPromise(erlParams) // erl activated + }); + + describe('when the limit is exceeded for a bucket without erl configuration', async () => { + it('should be non conformant', async () => { + await takeElevatedPromise(nonErlParams) // non-erl bucket now empty + assert.isFalse((await takeElevatedPromise(nonErlParams)).conformant) + }); + }) + + describe('when the limit is exceeded for a bucket with erl configuration', () => { + it('should use ERL to take from the bucket if the given erl_is_active_key is set in Redis ', async () => { + const hashtaggedERLIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, erl_is_active_key) + const activeKey = await redisExistsPromise(hashtaggedERLIsActiveKey) + assert.equal(activeKey, 1) + await takeElevatedPromise(erlParams) + const result = await takeElevatedPromise(erlParams); assert.isTrue(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); - }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // next takeElevated should have attempted to activate ERL but failed as quota is at its max allowed - .then(() => takeElevatedPromise(params)) - .then((result) => { - assert.isFalse(result.conformant); - assert.isFalse(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 1); - }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) - // check erlQuota wasn't modified - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) - // wait for a second for erl_quota_key to expire - .then(() => new Promise((resolve) => setTimeout(resolve, 1000))) - .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) - .then((quotaTTL) => assert.isBelow(quotaTTL, 0)) - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.isNull(erl_quota_keyValue)) - // next takeElevated should activate ERL and return conformant - .then(() => takeElevatedPromise(params)) - .then((result) => { + assert.isTrue(result.elevated_limits.activated) + assert.equal(result.limit, 5); + }); + it('should NOT use ERL to take from the bucket if the given erl_is_active_key is NOT set in Redis', async () => { + const hashtaggedERLIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, erl_is_active_key) + const hashedERLOtherIsActiveKey = replicateHashtag(`${ERLBucketName}:${key}`, prefix, otherERLActiveKey) + const activeKey = await redisExistsPromise(hashtaggedERLIsActiveKey) + assert.equal(activeKey, 1) + const inactiveKey = await redisExistsPromise(hashedERLOtherIsActiveKey) + assert.equal(inactiveKey, 0) + const result = await takeElevatedPromise(otherErlParams); assert.isTrue(result.conformant); - assert.isTrue(result.elevated_limits.activated); - assert.isTrue(result.elevated_limits.erl_configured_for_bucket) - assert.equal(result.limit, 2); - }) - .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) - .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) - // check erlQuota was increased - .then(() => redisGetPromise(hashtaggedERLQuotaKey)) - .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) - .then(() => done()); + assert.isFalse(result.elevated_limits.activated) + assert.equal(result.limit, 1); + }); + }); + }); + + describe('overrides', () => { + it('should use elevated_limits config override when provided', (done) => { + const bucketName = 'bucket_with_no_elevated_limits_config'; + const erl_is_active_key = 'some_erl_active_identifier'; + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1 + }); + const configOverride = { + size: 1, + elevated_limits: { + size: 3, + per_second: 3, + } + }; + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + configOverride + }; + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + takeElevatedPromise(params) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + assert.equal(result.remaining, 0); + }) + .then(() => takeElevatedPromise(params)) + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 3); + assert.equal(result.remaining, 0); + }) + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isFalse(result.conformant); + db.redis.ttl(hashtaggedERLIsActiveKey, (err, ttl) => { + assert.equal(ttl, 900); + done(); + }); + }); + }); + describe('should use config override when elevated_limits is not provided and erl is active for the given key', () => { + const tests = [ + { + name: "overrides by param", + bucketConfig: { + size: 1, + per_minute: 1, + }, + configOverride: { + size: 2, + per_minute: 2, + }, + }, + { + name: "overrides in bucket config", + bucketConfig: { + size: 1, + per_minute: 1, + overrides: { + 'some_key': { + size: 2, + per_minute: 2, + }, + }, + }, + configOverride: undefined, + }, + { + name: "overrides in bucket config by matching key", + bucketConfig: { + size: 1, + per_minute: 1, + overrides: { + 'local key': { + size: 2, + per_minute: 2, + match: 'some_key', + }, + }, + }, + configOverride: undefined, + }, + ] + tests.forEach((test) => { + it(test.name, (done) => { + db.configurateBucket(bucketName, test.bucketConfig); + const params = { + type: bucketName, + key: key, + elevated_limits: { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10 + }, + configOverride: test.configOverride, + }; + redisSetPromise(erl_is_active_key, 1) + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.remaining, 1); + }) + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.erl_configured_for_bucket); + assert.equal(result.remaining, 0); + }) + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isFalse(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isFalse(result.elevated_limits.erl_configured_for_bucket); + assert.equal(result.remaining, 0); + }) + .then(done) + }); + }); + }); + }); + + // erlquota tests + describe('erlQuota tests', () => { + const quota_per_calendar_month = 10; + const params = { + type: bucketName, + key: key, + }; + beforeEach(() => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_second: 2, + } + }); + }); + const hashtaggedERLIsActiveKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_is_active_key) + const hashtaggedERLQuotaKey = replicateHashtag(`${bucketName}:${key}`, prefix, erl_quota_key) + + it('should return quota_remaining = quota_per_calendar_month-1, quota_allocated and erl_activation_period_seconds when ERL is triggered for the first time in the month', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month + }; + + // check erl not activated yet + redisExistsPromise(erl_is_active_key) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // check erl_quota_key does not exist + .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key)) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(erl_is_active_key)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should activate ERL + .then(() => takeElevatedPromise(params)) + .then((response) => { + assert.isTrue(response.elevated_limits.triggered); + assert.isTrue(response.elevated_limits.activated); + assert.isTrue(response.elevated_limits.erl_configured_for_bucket) + assert.equal(response.elevated_limits.quota_remaining, quota_per_calendar_month - 1); + assert.isAtLeast(response.elevated_limits.erl_activation_period_seconds, 900); + assert.isAtLeast(response.elevated_limits.quota_allocated, quota_per_calendar_month); + assert.equal(response.limit, 2); + }) + .then(() => done()); + }); + + it('should return quota_remaining = -1 when ERL had already been activated', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month + }; + + // setup ERL + redisSetPromise(erl_is_active_key, 1) + .then(() => redisSetPromise(hashtaggedERLIsActiveKey, params.elevated_limits.per_calendar_month - 1)) + // takeElevated with ERL activated + .then(() => takeElevatedPromise(params)) + .then((response) => { + assert.isFalse(response.elevated_limits.triggered); + assert.isTrue(response.elevated_limits.activated); + assert.isTrue(response.elevated_limits.erl_configured_for_bucket) + assert.equal(response.elevated_limits.quota_remaining, -1); + assert.equal(response.limit, 2); + }) + .then(() => done()); + }); + + it('should set ttl accordingly on erl_quota_key when we activate ERL', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month + }; + + const eom = endOfMonthTimestamp(); + // get ms between now and eom + const expectedTTL = Math.floor((eom - Date.now()) / 1000); + + // check erl not activated yet + redisExistsPromise(erl_is_active_key) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // check erl_quota_key does not exist + .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key)) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(erl_is_active_key)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should activate ERL + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota should be decreased by 1 + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + // check ttl on erl_quota_key + .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) + .then((ttl) => assert.closeTo(ttl, expectedTTL, 2)) + .then(() => done()); + }); + + it('should keep ttl on erl_quota_key after increasing it', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month, + }; + let expectedTTL = 0; + + // check erl not activated yet + redisExistsPromise(erl_is_active_key) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // check erl_quota_key does not exist + .then(() => redisExistsPromise(hashtaggedERLQuotaKey) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should activate ERL + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota should be decreased by 1 + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + // check ttl on erl_quota_key + .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) + .then((ttl) => expectedTTL = ttl) + // stop ERL + .then(() => redisDeletePromise(hashtaggedERLIsActiveKey)) + // next takeElevated should re-activate ERL + .then(() => takeElevatedPromise(params)) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota should be decreased by 1 + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 2)) + // check erlQuota keeps the TTL + .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) + .then((ttl) => assert.equal(ttl, expectedTTL)) + .then(() => done()); + }); + + it('should increase erlQuota when we activate ERL', (done) => { + // activating ERL with per_calendar_month=1 is testing a border case to make sure decreasing the quota + // is not interpreted in the script as no quota left for activating ERL + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 10, + }; + + // check erl not activated yet + redisExistsPromise(erl_is_active_key) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // check erl_quota_key does not exist + .then(() => redisExistsPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0)) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should activate ERL and return conformant + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota should be decreased by 1 + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + .then(() => done()); + }); + + it('should not activate ERL when erlQuota is 0', (done) => { + db.configurateBucket(bucketName, { + size: 1, + per_minute: 1, + elevated_limits: { + size: 2, + per_second: 2, + } + }); + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: 0 + }; + + // check erl not activated yet + redisExistsPromise(erl_is_active_key) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // check erl_quota_key does not exist + .then(() => redisExistsPromise(params.elevated_limits.erl_quota_key) + .then((erl_quota_keyExists) => assert.equal(erl_quota_keyExists, 0))) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(erl_is_active_key)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should have attempted to activate ERL but failed + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isFalse(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(erl_is_active_key)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // check erlQuota was not set + .then(() => redisGetPromise(params.elevated_limits.erl_quota_key)) + .then((erl_quota_keyValue) => assert.isNull(erl_quota_keyValue)) + .then(() => done()); + }); + + it('should not activate ERL if erl_quota_key exists and is at its max allowed', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month + }; + + // set erl_quota_key to the given max allowed per month in redis + redisSetPromise(hashtaggedERLQuotaKey, quota_per_calendar_month) + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) + // check erl not activated yet + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should have attempted to activate ERL but failed as quota is at its max allowed + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isFalse(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // check erlQuota wasn't modified + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) + .then(() => done()); + }); + + it('should activate ERL when erl_quota_key expires after reaching allowed per month quota', (done) => { + params.elevated_limits = { + erl_is_active_key: erl_is_active_key, + erl_quota_key: erl_quota_key, + erl_activation_period_seconds: 900, + quota_per_calendar_month: quota_per_calendar_month + }; + + // set erl_quota_key to given max quota per month in redis + redisSetWithExpirePromise(hashtaggedERLQuotaKey, quota_per_calendar_month, 1) + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) + .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) + .then((quotaTTL) => assert.equal(quotaTTL, 1)) + // check erl not activated yet + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erlIsActiveExists) => assert.equal(erlIsActiveExists, 0)) + // attempt to take elevated should work for first token + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // next takeElevated should have attempted to activate ERL but failed as quota is at its max allowed + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isFalse(result.conformant); + assert.isFalse(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 1); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 0)) + // check erlQuota wasn't modified + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, quota_per_calendar_month)) + // wait for a second for erl_quota_key to expire + .then(() => new Promise((resolve) => setTimeout(resolve, 1000))) + .then(() => redisTTLPromise(hashtaggedERLQuotaKey)) + .then((quotaTTL) => assert.isBelow(quotaTTL, 0)) + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.isNull(erl_quota_keyValue)) + // next takeElevated should activate ERL and return conformant + .then(() => takeElevatedPromise(params)) + .then((result) => { + assert.isTrue(result.conformant); + assert.isTrue(result.elevated_limits.activated); + assert.isTrue(result.elevated_limits.erl_configured_for_bucket) + assert.equal(result.limit, 2); + }) + .then(() => redisExistsPromise(hashtaggedERLIsActiveKey)) + .then((erl_is_active_keyExists) => assert.equal(erl_is_active_keyExists, 1)) + // check erlQuota was increased + .then(() => redisGetPromise(hashtaggedERLQuotaKey)) + .then((erl_quota_keyValue) => assert.equal(erl_quota_keyValue, 1)) + .then(() => done()); + }); }); }); }); - }); - describe('PUT', () => { - it('should fail on validation', (done) => { - db.put({}, (err) => { - assert.match(err.message, /type is required/); - done(); + describe('PUT', () => { + it('should fail on validation', (done) => { + db.put({}, (err) => { + assert.match(err.message, /type is required/); + done(); + }); }); - }); - it('should add to the bucket', (done) => { - db.take({ type: 'ip', key: '8.8.8.8', count: 5 }, (err) => { - if (err) { - return done(err); - } + it('should add to the bucket', (done) => { + db.take({ type: 'ip', key: '8.8.8.8', count: 5 }, (err) => { + if (err) { + return done(err); + } + + db.put({ type: 'ip', key: '8.8.8.8', count: 4 }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 9); + done(); + }); + }); + }); - db.put({ type: 'ip', key: '8.8.8.8', count: 4 }, (err, result) => { + it('should do nothing if bucket is already full', (done) => { + const key = '1.2.3.4'; + db.put({ type: 'ip', key, count: 1 }, (err, result) => { if (err) { return done(err); } - assert.equal(result.remaining, 9); - done(); + assert.equal(result.remaining, 10); + + db.take({ type: 'ip', key, count: 1 }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 9); + done(); + }); }); }); - }); - - it('should do nothing if bucket is already full', (done) => { - const key = '1.2.3.4'; - db.put({ type: 'ip', key, count: 1 }, (err, result) => { - if (err) { - return done(err); - } - assert.equal(result.remaining, 10); - db.take({ type: 'ip', key, count: 1 }, (err, result) => { + it('should not put more than the bucket size', (done) => { + db.take({ type: 'ip', key: '8.8.8.8', count: 2 }, (err) => { if (err) { return done(err); } - assert.equal(result.remaining, 9); - done(); + + db.put({ type: 'ip', key: '8.8.8.8', count: 4 }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 10); + done(); + }); }); }); - }); - it('should not put more than the bucket size', (done) => { - db.take({ type: 'ip', key: '8.8.8.8', count: 2 }, (err) => { - if (err) { - return done(err); - } - - db.put({ type: 'ip', key: '8.8.8.8', count: 4 }, (err, result) => { + it('should not override on unlimited buckets', (done) => { + const bucketKey = { type: 'ip', key: '0.0.0.0', count: 1000 }; + db.put(bucketKey, (err, result) => { if (err) { return done(err); } - assert.equal(result.remaining, 10); + assert.equal(result.remaining, 100); done(); }); }); - }); - - it('should not override on unlimited buckets', (done) => { - const bucketKey = { type: 'ip', key: '0.0.0.0', count: 1000 }; - db.put(bucketKey, (err, result) => { - if (err) { - return done(err); - } - assert.equal(result.remaining, 100); - done(); - }); - }); - it('should restore the bucket when reseting', (done) => { - const bucketKey = { type: 'ip', key: '211.123.12.12' }; - db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { - if (err) return done(err); - db.put(bucketKey, (err) => { + it('should restore the bucket when reseting', (done) => { + const bucketKey = { type: 'ip', key: '211.123.12.12' }; + db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { if (err) return done(err); - db.take(bucketKey, (err, response) => { + db.put(bucketKey, (err) => { if (err) return done(err); - assert.equal(response.remaining, 9); - done(); + db.take(bucketKey, (err, response) => { + if (err) return done(err); + assert.equal(response.remaining, 9); + done(); + }); }); }); }); - }); - it('should restore the bucket when reseting with all', (done) => { - const takeParams = { type: 'ip', key: '21.17.65.41', count: 9 }; - db.take(takeParams, (err) => { - if (err) return done(err); - db.put({ type: 'ip', key: '21.17.65.41', count: 'all' }, (err) => { + it('should restore the bucket when reseting with all', (done) => { + const takeParams = { type: 'ip', key: '21.17.65.41', count: 9 }; + db.take(takeParams, (err) => { if (err) return done(err); - db.take(takeParams, (err, response) => { + db.put({ type: 'ip', key: '21.17.65.41', count: 'all' }, (err) => { if (err) return done(err); - assert.equal(response.conformant, true); - assert.equal(response.remaining, 1); - done(); + db.take(takeParams, (err, response) => { + if (err) return done(err); + assert.equal(response.conformant, true); + assert.equal(response.remaining, 1); + done(); + }); }); }); }); - }); - it('should restore nothing when count=0', (done) => { - db.take({ type: 'ip', key: '9.8.7.6', count: 123 }, (err) => { - if (err) return done(err); - db.put({ type: 'ip', key: '9.8.7.6', count: 0 }, (err) => { + it('should restore nothing when count=0', (done) => { + db.take({ type: 'ip', key: '9.8.7.6', count: 123 }, (err) => { if (err) return done(err); - db.take({ type: 'ip', key: '9.8.7.6', count: 0 }, (err, response) => { + db.put({ type: 'ip', key: '9.8.7.6', count: 0 }, (err) => { if (err) return done(err); - assert.equal(response.conformant, true); - assert.equal(response.remaining, 77); - done(); + db.take({ type: 'ip', key: '9.8.7.6', count: 0 }, (err, response) => { + if (err) return done(err); + assert.equal(response.conformant, true); + assert.equal(response.remaining, 77); + done(); + }); }); }); }); - }); - [ - '0', - 0.5, - 'ALL', - true, - 1n, - {}, - ].forEach((count) => { - it(`should not work for non-integer count=${count}`, (done) => { - const opts = { - type: 'ip', - key: '9.8.7.6', - count, - }; + [ + '0', + 0.5, + 'ALL', + true, + 1n, + {}, + ].forEach((count) => { + it(`should not work for non-integer count=${count}`, (done) => { + const opts = { + type: 'ip', + key: '9.8.7.6', + count, + }; - assert.throws(() => db.put(opts, () => { - }), /if provided, count must be 'all' or an integer value/); - done(); + assert.throws(() => db.put(opts, () => { + }), /if provided, count must be 'all' or an integer value/); + done(); + }); }); - }); - it('should be able to reset without callback', (done) => { - const bucketKey = { type: 'ip', key: '211.123.12.12' }; - db.take(bucketKey, (err) => { - if (err) return done(err); - db.put(bucketKey); - setImmediate(() => { - db.take(bucketKey, (err, response) => { - if (err) return done(err); - assert.equal(response.remaining, 9); - done(); + it('should be able to reset without callback', (done) => { + const bucketKey = { type: 'ip', key: '211.123.12.12' }; + db.take(bucketKey, (err) => { + if (err) return done(err); + db.put(bucketKey); + setImmediate(() => { + db.take(bucketKey, (err, response) => { + if (err) return done(err); + assert.equal(response.remaining, 9); + done(); + }); }); }); }); - }); - it('should work for a fixed bucket', (done) => { - db.take({ type: 'ip', key: '8.8.8.8' }, (err, result) => { - assert.ok(result.conformant); - db.put({ type: 'ip', key: '8.8.8.8' }, (err, result) => { - if (err) return done(err); - assert.equal(result.remaining, 10); - done(); + it('should work for a fixed bucket', (done) => { + db.take({ type: 'ip', key: '8.8.8.8' }, (err, result) => { + assert.ok(result.conformant); + db.put({ type: 'ip', key: '8.8.8.8' }, (err, result) => { + if (err) return done(err); + assert.equal(result.remaining, 10); + done(); + }); }); }); - }); - - it('should work with negative values', (done) => { - db.put({ type: 'ip', key: '8.8.8.1', count: -100 }, (err, result) => { - if (err) { - return done(err); - } - assert.closeTo(result.remaining, -90, 1); - db.take({ type: 'ip', key: '8.8.8.1' }, (err, result) => { + it('should work with negative values', (done) => { + db.put({ type: 'ip', key: '8.8.8.1', count: -100 }, (err, result) => { if (err) { return done(err); } - assert.equal(result.conformant, false); - assert.closeTo(result.remaining, -89, 1); - done(); - }); - }); - }); + assert.closeTo(result.remaining, -90, 1); - it('should use size config override when provided', (done) => { - const configOverride = { size: 4 }; - const bucketKey = { type: 'ip', key: '7.7.7.9', configOverride }; - db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { - if (err) return done(err); - db.put(bucketKey, (err) => { // restores all 4 - if (err) return done(err); - db.take(bucketKey, (err, response) => { // takes 1, 3 remain - if (err) return done(err); - assert.equal(response.remaining, 3); + db.take({ type: 'ip', key: '8.8.8.1' }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.conformant, false); + assert.closeTo(result.remaining, -89, 1); done(); }); }); }); - }); - it('should use per interval config override when provided', (done) => { - const oneDayInMs = ms('24h'); - const configOverride = { per_day: 1 }; - const bucketKey = { type: 'ip', key: '7.7.7.10', configOverride }; - db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { - if (err) return done(err); - db.put(bucketKey, (err) => { // restores all 4 + it('should use size config override when provided', (done) => { + const configOverride = { size: 4 }; + const bucketKey = { type: 'ip', key: '7.7.7.9', configOverride }; + db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { if (err) return done(err); - db.take(bucketKey, (err, response) => { // takes 1, 3 remain + db.put(bucketKey, (err) => { // restores all 4 if (err) return done(err); - const dayFromNow = Date.now() + oneDayInMs; - assert.closeTo(response.reset, dayFromNow / 1000, 3); - done(); + db.take(bucketKey, (err, response) => { // takes 1, 3 remain + if (err) return done(err); + assert.equal(response.remaining, 3); + done(); + }); }); }); }); - }); - it('should use size AND per interval config override when provided', (done) => { - const oneDayInMs = ms('24h'); - const configOverride = { size: 4, per_day: 1 }; - const bucketKey = { type: 'ip', key: '7.7.7.11', configOverride }; - db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { - if (err) return done(err); - db.put(bucketKey, (err) => { // restores all 4 + it('should use per interval config override when provided', (done) => { + const oneDayInMs = ms('24h'); + const configOverride = { per_day: 1 }; + const bucketKey = { type: 'ip', key: '7.7.7.10', configOverride }; + db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { if (err) return done(err); - db.take(bucketKey, (err, response) => { // takes 1, 3 remain + db.put(bucketKey, (err) => { // restores all 4 if (err) return done(err); - assert.equal(response.remaining, 3); - const dayFromNow = Date.now() + oneDayInMs; - assert.closeTo(response.reset, dayFromNow / 1000, 3); - done(); + db.take(bucketKey, (err, response) => { // takes 1, 3 remain + if (err) return done(err); + const dayFromNow = Date.now() + oneDayInMs; + assert.closeTo(response.reset, dayFromNow / 1000, 3); + done(); + }); }); }); }); - }); - it('should set ttl to reflect config override', (done) => { - const configOverride = { per_day: 5 }; - const bucketKey = { type: 'ip', key: '7.7.7.12', configOverride }; - db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { - if (err) return done(err); - db.put(bucketKey, (err) => { // restores all 4 + it('should use size AND per interval config override when provided', (done) => { + const oneDayInMs = ms('24h'); + const configOverride = { size: 4, per_day: 1 }; + const bucketKey = { type: 'ip', key: '7.7.7.11', configOverride }; + db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { if (err) return done(err); - db.take(bucketKey, (err) => { // takes 1, 3 remain + db.put(bucketKey, (err) => { // restores all 4 if (err) return done(err); - db.redis.ttl(`${bucketKey.type}:${bucketKey.key}`, (err, ttl) => { - if (err) { - return done(err); - } - assert.equal(ttl, 86400); + db.take(bucketKey, (err, response) => { // takes 1, 3 remain + if (err) return done(err); + assert.equal(response.remaining, 3); + const dayFromNow = Date.now() + oneDayInMs; + assert.closeTo(response.reset, dayFromNow / 1000, 3); done(); }); }); }); }); - }); - }); - describe('GET', () => { - it('should fail on validation', (done) => { - db.get({}, (err) => { - assert.match(err.message, /type is required/); - done(); + it('should set ttl to reflect config override', (done) => { + const configOverride = { per_day: 5 }; + const bucketKey = { type: 'ip', key: '7.7.7.12', configOverride }; + db.take(Object.assign({ count: 'all' }, bucketKey), (err) => { + if (err) return done(err); + db.put(bucketKey, (err) => { // restores all 4 + if (err) return done(err); + db.take(bucketKey, (err) => { // takes 1, 3 remain + if (err) return done(err); + db.redis.ttl(`${bucketKey.type}:${bucketKey.key}`, (err, ttl) => { + if (err) { + return done(err); + } + assert.equal(ttl, 86400); + done(); + }); + }); + }); + }); }); }); - it('should return the bucket default for remaining when key does not exist', (done) => { - db.get({ type: 'ip', key: '8.8.8.8' }, (err, result) => { - if (err) { - return done(err); - } - assert.equal(result.remaining, 10); - done(); + describe('GET', () => { + it('should fail on validation', (done) => { + db.get({}, (err) => { + assert.match(err.message, /type is required/); + done(); + }); }); - }); - it('should retrieve the bucket for an existing key', (done) => { - db.take({ type: 'ip', key: '8.8.8.8', count: 1 }, (err) => { - if (err) { - return done(err); - } + it('should return the bucket default for remaining when key does not exist', (done) => { db.get({ type: 'ip', key: '8.8.8.8' }, (err, result) => { if (err) { return done(err); } - assert.equal(result.remaining, 9); + assert.equal(result.remaining, 10); + done(); + }); + }); + it('should retrieve the bucket for an existing key', (done) => { + db.take({ type: 'ip', key: '8.8.8.8', count: 1 }, (err) => { + if (err) { + return done(err); + } db.get({ type: 'ip', key: '8.8.8.8' }, (err, result) => { if (err) { return done(err); } assert.equal(result.remaining, 9); - done(); + + db.get({ type: 'ip', key: '8.8.8.8' }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 9); + done(); + }); }); }); }); - }); - - it('should return the bucket for an unlimited key', (done) => { - db.get({ type: 'ip', key: '0.0.0.0' }, (err, result) => { - if (err) { - return done(err); - } - assert.equal(result.remaining, 100); - db.take({ type: 'ip', key: '0.0.0.0', count: 1 }, (err) => { + it('should return the bucket for an unlimited key', (done) => { + db.get({ type: 'ip', key: '0.0.0.0' }, (err, result) => { if (err) { return done(err); } - db.get({ type: 'ip', key: '0.0.0.0' }, (err, result) => { + assert.equal(result.remaining, 100); + + db.take({ type: 'ip', key: '0.0.0.0', count: 1 }, (err) => { if (err) { return done(err); } - assert.equal(result.remaining, 100); - assert.equal(result.limit, 100); - assert.exists(result.reset); - done(); + db.get({ type: 'ip', key: '0.0.0.0' }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 100); + assert.equal(result.limit, 100); + assert.exists(result.reset); + done(); + }); }); }); }); - }); - it('should use size config override when provided', (done) => { - const configOverride = { size: 7 }; - db.get({ type: 'ip', key: '7.7.7.13', configOverride }, (err, result) => { - if (err) { - return done(err); - } - assert.equal(result.remaining, 7); - assert.equal(result.limit, 7); - done(); + it('should use size config override when provided', (done) => { + const configOverride = { size: 7 }; + db.get({ type: 'ip', key: '7.7.7.13', configOverride }, (err, result) => { + if (err) { + return done(err); + } + assert.equal(result.remaining, 7); + assert.equal(result.limit, 7); + done(); + }); }); - }); - it('should use per interval config override when provided', (done) => { - const oneDayInMs = ms('24h'); - const configOverride = { per_day: 1 }; - db.take({ type: 'ip', key: '7.7.7.14', configOverride }, (err) => { - if (err) { - return done(err); - } - db.get({ type: 'ip', key: '7.7.7.14', configOverride }, (err, result) => { + it('should use per interval config override when provided', (done) => { + const oneDayInMs = ms('24h'); + const configOverride = { per_day: 1 }; + db.take({ type: 'ip', key: '7.7.7.14', configOverride }, (err) => { if (err) { return done(err); } - const dayFromNow = Date.now() + oneDayInMs; - assert.closeTo(result.reset, dayFromNow / 1000, 3); - done(); + db.get({ type: 'ip', key: '7.7.7.14', configOverride }, (err, result) => { + if (err) { + return done(err); + } + const dayFromNow = Date.now() + oneDayInMs; + assert.closeTo(result.reset, dayFromNow / 1000, 3); + done(); + }); }); }); }); - }); - describe('WAIT', () => { - it('should work with a simple request', (done) => { - const now = Date.now(); - db.wait({ type: 'ip', key: '211.76.23.4' }, (err, response) => { - if (err) return done(err); - assert.ok(response.conformant); - assert.notOk(response.delayed); - assert.equal(response.remaining, 9); - assert.closeTo(response.reset, now / 1000, 3); - done(); + describe('WAIT', () => { + it('should work with a simple request', (done) => { + const now = Date.now(); + db.wait({ type: 'ip', key: '211.76.23.4' }, (err, response) => { + if (err) return done(err); + assert.ok(response.conformant); + assert.notOk(response.delayed); + assert.equal(response.remaining, 9); + assert.closeTo(response.reset, now / 1000, 3); + done(); + }); }); - }); - it('should be delayed when traffic is non conformant', (done) => { - db.take({ - type: 'ip', - key: '211.76.23.5', - count: 10 - }, (err) => { - if (err) return done(err); - const waitingSince = Date.now(); - db.wait({ + it('should be delayed when traffic is non conformant', (done) => { + db.take({ type: 'ip', key: '211.76.23.5', - count: 3 - }, (err, response) => { - if (err) { - return done(err); - } - var waited = Date.now() - waitingSince; - assert.ok(response.conformant); - assert.ok(response.delayed); - assert.closeTo(waited, 600, 20); - done(); + count: 10 + }, (err) => { + if (err) return done(err); + const waitingSince = Date.now(); + db.wait({ + type: 'ip', + key: '211.76.23.5', + count: 3 + }, (err, response) => { + if (err) { + return done(err); + } + var waited = Date.now() - waitingSince; + assert.ok(response.conformant); + assert.ok(response.delayed); + assert.closeTo(waited, 600, 20); + done(); + }); }); }); - }); - it('should not be delayed when traffic is non conformant and count=0', (done) => { - db.take({ - type: 'ip', - key: '211.76.23.5', - count: 10 - }, (err) => { - if (err) return done(err); - const waitingSince = Date.now(); - db.wait({ + it('should not be delayed when traffic is non conformant and count=0', (done) => { + db.take({ type: 'ip', key: '211.76.23.5', - count: 0 - }, (err, response) => { - if (err) { - return done(err); - } - var waited = Date.now() - waitingSince; - assert.ok(response.conformant); - assert.notOk(response.delayed); - assert.closeTo(waited, 0, 20); - done(); + count: 10 + }, (err) => { + if (err) return done(err); + const waitingSince = Date.now(); + db.wait({ + type: 'ip', + key: '211.76.23.5', + count: 0 + }, (err, response) => { + if (err) { + return done(err); + } + var waited = Date.now() - waitingSince; + assert.ok(response.conformant); + assert.notOk(response.delayed); + assert.closeTo(waited, 0, 20); + done(); + }); }); }); - }); - it('should use per interval config override when provided', (done) => { - const oneSecondInMs = ms('1s') / 3; - const configOverride = { per_second: 3, size: 10 }; - db.take({ - type: 'ip', - key: '211.76.23.6', - count: 10, - configOverride - }, (err) => { - if (err) return done(err); - const waitingSince = Date.now(); - db.wait({ + it('should use per interval config override when provided', (done) => { + const oneSecondInMs = ms('1s') / 3; + const configOverride = { per_second: 3, size: 10 }; + db.take({ type: 'ip', key: '211.76.23.6', - count: 1, + count: 10, configOverride - }, (err, response) => { - if (err) { - return done(err); - } - var waited = Date.now() - waitingSince; - assert.ok(response.conformant); - assert.ok(response.delayed); - assert.closeTo(waited, oneSecondInMs, 20); - done(); + }, (err) => { + if (err) return done(err); + const waitingSince = Date.now(); + db.wait({ + type: 'ip', + key: '211.76.23.6', + count: 1, + configOverride + }, (err, response) => { + if (err) { + return done(err); + } + var waited = Date.now() - waitingSince; + assert.ok(response.conformant); + assert.ok(response.delayed); + assert.closeTo(waited, oneSecondInMs, 20); + done(); + }); }); }); }); - }); - describe('#resetAll', () => { - it('should reset all keys of all buckets', (done) => { - async.parallel([ - // Empty those buckets... - (cb) => db.take({ type: 'ip', key: '1.1.1.1', count: buckets.ip.size }, cb), - (cb) => db.take({ type: 'ip', key: '2.2.2.2', count: buckets.ip.size }, cb), - (cb) => db.take({ type: 'user', key: 'some_user', count: buckets.user.size }, cb) - ], (err) => { - if (err) { - return done(err); - } - - db.resetAll((err) => { + describe('#resetAll', () => { + it('should reset all keys of all buckets', (done) => { + async.parallel([ + // Empty those buckets... + (cb) => db.take({ type: 'ip', key: '1.1.1.1', count: buckets.ip.size }, cb), + (cb) => db.take({ type: 'ip', key: '2.2.2.2', count: buckets.ip.size }, cb), + (cb) => db.take({ type: 'user', key: 'some_user', count: buckets.user.size }, cb) + ], (err) => { if (err) { return done(err); } - async.parallel([ - (cb) => db.take({ type: 'ip', key: '1.1.1.1' }, cb), - (cb) => db.take({ type: 'ip', key: '2.2.2.2' }, cb), - (cb) => db.take({ type: 'user', key: 'some_user' }, cb) - ], (err, results) => { + + db.resetAll((err) => { if (err) { return done(err); } + async.parallel([ + (cb) => db.take({ type: 'ip', key: '1.1.1.1' }, cb), + (cb) => db.take({ type: 'ip', key: '2.2.2.2' }, cb), + (cb) => db.take({ type: 'user', key: 'some_user' }, cb) + ], (err, results) => { + if (err) { + return done(err); + } - assert.equal(results[0].remaining, buckets.ip.size - 1); - assert.equal(results[0].conformant, true); - assert.equal(results[1].remaining, buckets.ip.size - 1); - assert.equal(results[0].conformant, true); - assert.equal(results[2].remaining, buckets.user.size - 1); - assert.equal(results[2].conformant, true); - done(); + assert.equal(results[0].remaining, buckets.ip.size - 1); + assert.equal(results[0].conformant, true); + assert.equal(results[1].remaining, buckets.ip.size - 1); + assert.equal(results[0].conformant, true); + assert.equal(results[2].remaining, buckets.user.size - 1); + assert.equal(results[2].conformant, true); + done(); + }); }); }); }); }); }); -}); - -describe('LimitDBRedis Ping', () => { - let ping = { - enabled: () => true, - interval: 10, - maxFailedAttempts: 3, - reconnectIfFailed: () => true, - maxFailedAttemptsToRetryReconnect: 10 - }; - - let config = { - uri: 'localhost:22222', - buckets, - prefix: 'tests:', - ping, - }; - - let redisProxy; - let toxiproxy; - let db; - - beforeEach((done) => { - toxiproxy = new Toxiproxy('http://localhost:8474'); - proxyBody = { - listen: '0.0.0.0:22222', - name: crypto.randomUUID(), //randomize name to avoid concurrency issues - upstream: 'redis:6379' - }; - toxiproxy.createProxy(proxyBody) - .then((proxy) => { - redisProxy = proxy; - done(); - }); - - }); - - afterEach((done) => { - redisProxy.remove().then(() => - db.close((err) => { - // Can't close DB if it was never open - if (err?.message.indexOf('enableOfflineQueue') > 0 || err?.message.indexOf('Connection is closed') >= 0) { - err = undefined; - } - done(err); - }) - ); - }); - - it('should emit ping success', (done) => { - db = createDB({ uri: 'localhost:22222', buckets, prefix: 'tests:', ping }, done); - db.once(('ping'), (result) => { - if (result.status === LimitDB.PING_SUCCESS) { - done(); - } - }); - }); - - it('should emit "ping - error" when redis stops responding pings', (done) => { - let called = false; - - db = createDB(config, done); - db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); - db.on(('ping'), (result) => { - if (result.status === LimitDB.PING_ERROR && !called) { - called = true; - db.removeAllListeners('ping'); - done(); - } - }); - }); - - it('should emit "ping - reconnect" when redis stops responding pings and client is configured to reconnect', (done) => { - let called = false; - db = createDB(config, done); - db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); - db.on(('ping'), (result) => { - if (result.status === LimitDB.PING_RECONNECT && !called) { - called = true; - db.removeAllListeners('ping'); - done(); - } - }); - }); - - it('should emit "ping - reconnect dry run" when redis stops responding pings and client is NOT configured to reconnect', (done) => { - let called = false; - db = createDB({ ...config, ping: { ...ping, reconnectIfFailed: () => false } }, done); - db.once(('ready'), () => addLatencyToxic(redisProxy, 20000, noop)); - db.on(('ping'), (result) => { - if (result.status === LimitDB.PING_RECONNECT_DRY_RUN && !called) { - called = true; - db.removeAllListeners('ping'); - done(); - } - }); - }); - - it(`should NOT emit ping events when config.ping is not set`, (done) => { - db = createDB({ ...config, ping: undefined }, done); - - db.once(('ping'), (result) => { - done(new Error(`unexpected ping event emitted ${result}`)); - }); - - //If after 100ms there are no interactions, we mark the test as passed. - setTimeout(done, 100); - }); - - it('should recover from a connection loss', (done) => { - let pingResponded = false; - let reconnected = false; - let toxic = undefined; - let timeoutId; - db = createDB({ ...config, ping: { ...ping, interval: 50 } }, done); - - db.on(('ping'), (result) => { - if (result.status === LimitDB.PING_SUCCESS) { - if (!pingResponded) { - pingResponded = true; - toxic = addLatencyToxic(redisProxy, 20000, (t) => toxic = t); - } else if (reconnected) { - clearTimeout(timeoutId); - db.removeAllListeners('ping'); - done(); - } - } else if (result.status === LimitDB.PING_RECONNECT) { - if (pingResponded && !reconnected) { - reconnected = true; - toxic.remove(); - } - } - }); - - timeoutId = setTimeout(() => done(new Error('Not reconnected')), 1800); - }); - - const createDB = (config, done) => { - let tmpDB = new LimitDB(config); - - tmpDB.on(('error'), (err) => { - //As we actively close the connection, there might be network-related errors while attempting to reconnect - if (err?.message.indexOf('enableOfflineQueue') > 0 || err?.message.indexOf('Command timed out') >= 0) { - err = undefined; - } - - if (err) { - console.log(err, err.message); - done(err); - } - }); - - return tmpDB; - }; - - const addLatencyToxic = (proxy, latency, callback) => { - let toxic = new Toxic( - proxy, - { type: 'latency', attributes: { latency: latency } } - ); - proxy.addToxic(toxic).then(callback); - }; - - - const noop = () => { - }; -}); +} \ No newline at end of file