diff --git a/constants.js b/constants.js index 3d83d33f22..70bf18190d 100644 --- a/constants.js +++ b/constants.js @@ -250,6 +250,9 @@ const constants = { // if requester is not bucket owner, bucket policy actions should be denied with // MethodNotAllowed error onlyOwnerAllowed: ['bucketDeletePolicy', 'bucketGetPolicy', 'bucketPutPolicy'], + // Default value for rate limiting config cache TTL + rateLimitDefaultConfigCacheTTL: 30000, + rateLimitDefaultBurstCapacity: 1, }; module.exports = constants; diff --git a/lib/Config.js b/lib/Config.js index 5ca08c35f1..1c85e97f6e 100644 --- a/lib/Config.js +++ b/lib/Config.js @@ -8,7 +8,7 @@ const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); const cronParser = require('cron-parser'); const joi = require('@hapi/joi'); -const { s3routes, auth: arsenalAuth, s3middleware } = require('arsenal'); +const { s3routes, auth: arsenalAuth, s3middleware, errors, ArsenalError } = require('arsenal'); const { isValidBucketName } = s3routes.routesUtils; const validateAuthConfig = arsenalAuth.inMemory.validateAuthConfig; const { buildAuthDataAccount } = require('./auth/in_memory/builder'); @@ -32,6 +32,7 @@ const { isValidType, isValidProtocol, } = require('arsenal/build/lib/network/KMSInterface'); +const { parseRateLimitConfig } = require('./api/apiUtils/rateLimit/config'); // config paths const configSearchPaths = [ @@ -1803,6 +1804,91 @@ class Config extends EventEmitter { if (config.enableVeeamRoute !== undefined && config.enableVeeamRoute !== null) { this.enableVeeamRoute = config.enableVeeamRoute; } + + this.rateLimiting = { + enabled: false, + bucket: { + configCacheTTL: constants.rateLimitDefaultConfigCacheTTL, + }, + }; + + // { + // "enabled": true, + // "bucket": { + // "defaultConfig": { + // "requestsPerSecond": { + // "limit": 1000, + // "burstCapacity": 2 + // } + // }, + // "configCacheTTL": 30000, + // }, + // "account": { // not in first iteration + // "requestsPerSecond": { + // "limit": 1000, + // "burstCapacity": 2 + // } + // }, + // "configCacheTTL": 30000, + // }, + // "error": { + // "code": 429, + // "description": "Too Many Requests" + // } + // } + + + if (config.rateLimiting?.enabled) { + // rate limiting uses the same localCache config defined for S3 to avoid + // config duplication. + assert(this.localCache, 'missing required property of rateLimit ' + + 'configuration: localCache'); + + this.rateLimiting.enabled = true; + + assert.strictEqual(typeof config.rateLimiting.serviceUserArn, 'string'); + this.rateLimiting.serviceUserArn = config.rateLimiting.serviceUserArn; + + // this.rateLimiting.default = {}; + if (config.rateLimiting.bucket) { + assert.strictEqual( + typeof config.rateLimiting.bucket, 'object', + 'rateLimiting.bucket must be an object' + ); + + let defaultConfig = undefined; + if (config.rateLimiting.bucket.defaultConfig) { + assert.strictEqual( + typeof config.rateLimiting.bucket.defaultConfig, 'object', + 'rateLimiting.bucket.defaultConfig must be an object' + ); + defaultConfig = parseRateLimitConfig(config.rateLimiting.bucket.defaultConfig); + } + + let configCacheTTL = constants.rateLimitDefaultConfigCacheTTL; + if (config.rateLimiting.bucket.configCacheTTL) { + configCacheTTL = config.rateLimiting.bucket.configCacheTTL; + assert( + typeof configCacheTTL === 'number' && Number.isInteger(configCacheTTL) && configCacheTTL > 0, + 'ratelimiting.bucket.configCacheTTL must be a postive integer' + ); + } + + this.rateLimiting.bucket = { + defaultConfig, + configCacheTTL, + }; + } + + if (config.error) { + assert.strictEqual(typeof config.rateLimiting.error, 'object', 'rateLimiting.error must be an object'); + this.rateLimiting.error = new ArsenalError('SlowDown', + config.throttling.errorResponse.code, config.throttling.errorResponse.message); + } else { + this.rateLimiting.error = errors.SlowDown; + } + } + return config; } diff --git a/lib/api/apiUtils/authorization/serviceUser.js b/lib/api/apiUtils/authorization/serviceUser.js new file mode 100644 index 0000000000..ca4c6cbf07 --- /dev/null +++ b/lib/api/apiUtils/authorization/serviceUser.js @@ -0,0 +1,39 @@ +const { timingSafeEqual } = require('crypto'); + +const { config } = require('../../../Config'); + +function isRateLimitServiceUser(authInfo, log) { + try { + const requestArn = authInfo.getArn(); + const configuredArn = config.rateLimiting?.serviceUserArn; + + if (!configuredArn) { + log.warn('Access denied - no configured ARN under rateLimiting.serviceUserArn', { requestArn }); + return false; + } + + // Support partial ARN matching (e.g., match by account only) + // If configuredArn is shorter, do prefix match; otherwise exact match + let match; + if (requestArn.length >= configuredArn.length) { + // Extract prefix from requestArn to match configuredArn length + const requestPrefix = requestArn.substring(0, configuredArn.length); + match = timingSafeEqual( + Buffer.from(requestPrefix), + Buffer.from(configuredArn) + ); + } else { + // requestArn is shorter than configured - no match + log.warn('Access denied - request ARN is shorter than configured ARN', { requestArn, configuredArn }); + match = false; + } + return match; + } catch (err) { + log.error('Error checking if request is rate limit service user', { error: err }); + return false; + } +} + +module.exports = { + isRateLimitServiceUser +}; diff --git a/lib/api/apiUtils/rateLimit/cache.js b/lib/api/apiUtils/rateLimit/cache.js new file mode 100644 index 0000000000..23edb2f66c --- /dev/null +++ b/lib/api/apiUtils/rateLimit/cache.js @@ -0,0 +1,74 @@ +const counters = new Map(); + +const configCache = new Map(); + +function setCounter(key, value) { + // Make sure that the Map remains in order + // Counters expiring soonest will be first during iteration. + counters.delete(key); + counters.set(key, value); +} + +function getCounter(key) { + return counters.get(key); +} + +function expireCounters(now) { + const toRemove = []; + for (const [key, value] of counters.entries()) { + if (value <= now) { + toRemove.push(key); + } + } + + for (const key of toRemove) { + counters.delete(key); + } +} + +function setCachedConfig(key, limitConfig, ttl) { + const expiry = Date.now() + ttl; + configCache.set(key, { expiry, config: limitConfig }); +} + +function getCachedConfig(key) { + const value = configCache.get(key); + if (value === undefined) { + return undefined; + } + + const { expiry, config } = value; + if (expiry <= Date.now()) { + configCache.delete(key); + return undefined; + } + + return config; +} + +function expireCachedConfigs(now) { + const toRemove = []; + for (const [key, { expiry }] of configCache.entries()) { + if (expiry <= now) { + toRemove.push(key); + } + } + + for (const key of toRemove) { + configCache.delete(key); + } +} + +module.exports = { + setCounter, + getCounter, + expireCounters, + setCachedConfig, + getCachedConfig, + expireCachedConfigs, + + // Do not access directly + // Used only for tests + counters, + configCache, +}; diff --git a/lib/api/apiUtils/rateLimit/client.js b/lib/api/apiUtils/rateLimit/client.js new file mode 100644 index 0000000000..26332f0c63 --- /dev/null +++ b/lib/api/apiUtils/rateLimit/client.js @@ -0,0 +1,78 @@ +const fs = require('fs'); + +const Redis = require('ioredis'); + +const { config } = require('../../../Config'); + +const updateCounterScript = fs.readFileSync(`${__dirname }/updateCounter.lua`).toString(); + +const SCRIPTS = { + updateCounter: { + numberOfKeys: 1, + lua: updateCounterScript, + }, +}; + +class RateLimitClient { + constructor(redisConfig) { + this.redis = new Redis({ + ...redisConfig, + scripts: SCRIPTS, + lazyConnect: true, + }); + } + + /** + * @typedef {Object} CounterUpdateBatch + * @property {string} key - counter key + * @property {number} cost - cost to add to counter + */ + + /** + * @typedef {Object} CounterUpdateBatchResult + * @property {string} key - counter key + * @property {number} value - current value of counter + */ + + /** + * @callback RateLimitClient~batchUpdate + * @param {Error|null} err + * @param {CounterUpdateBatchResult[]|undefined} + */ + + /** + * Add cost to the counter at key. + * Returns the new value for the counter + * + * @param {CounterUpdateBatch[]} batch - batch of counter updates + * @param {RateLimitClient~batchUpdate} cb + */ + updateLocalCounters(batch, cb) { + const pipeline = this.redis.pipeline(); + for (const { key, cost } of batch) { + pipeline.updateCounter(key, cost); + } + + pipeline.exec((err, results) => { + if (err) { + cb(err); + return; + } + + cb(null, results.map((res, i) => ({ + key: batch[i].key, + value: res[1], + }))); + }); + } +} + +let instance; +if (config.rateLimiting.enabled) { + instance = new RateLimitClient(config.localCache); +} + +module.exports = { + instance, + RateLimitClient +}; diff --git a/lib/api/apiUtils/rateLimit/config.js b/lib/api/apiUtils/rateLimit/config.js new file mode 100644 index 0000000000..fbc5da788b --- /dev/null +++ b/lib/api/apiUtils/rateLimit/config.js @@ -0,0 +1,32 @@ +const assert = require('assert'); + +const { rateLimitDefaultBurstCapacity } = require('../../../../constants'); + +function parseRateLimitConfig(config) { + const limitConfig = {}; + + if (config.requestsPerSecond) { + assert.strictEqual(typeof config.requestsPerSecond, 'object'); + + const { limit } = config.requestsPerSecond; + assert(typeof limit === 'number' && Number.isInteger(limit) && limit > 0); + + let { burstCapacity } = config.requestsPerSecond; + if (burstCapacity !== undefined) { + assert(typeof burstCapacity === 'number' && Number.isInteger(burstCapacity) && burstCapacity > 0); + } else { + burstCapacity = rateLimitDefaultBurstCapacity; + } + + limitConfig.requestsPerSecond = { + interval: 1000 / limit, + bucketSize: burstCapacity * 1000, + }; + } + + return limitConfig; +} + +module.exports = { + parseRateLimitConfig, +}; diff --git a/lib/api/apiUtils/rateLimit/updateCounter.lua b/lib/api/apiUtils/rateLimit/updateCounter.lua new file mode 100644 index 0000000000..b12b33ad59 --- /dev/null +++ b/lib/api/apiUtils/rateLimit/updateCounter.lua @@ -0,0 +1,27 @@ +-- updateCounter +-- +-- Adds the passed COST to the GCRA counter at KEY. +-- If no counter currently exists a new one is created from the current time. +-- The key expiration is set to the updated value. +-- Returns the value of the updated key. + +local ts = redis.call('TIME') +local currentTime = ts[1] * 1000 +currentTime = currentTime + math.floor(ts[2] / 1000) + +local newValue = currentTime + tonumber(ARGV[1]) + +local counterExists = redis.call('EXISTS', KEYS[1]) +if counterExists == 1 then + local currentValue = tonumber(redis.call('GET', KEYS[1])) + if currentValue > currentTime then + newValue = currentValue + tonumber(ARGV[1]) + end +end + +redis.call('SET', KEYS[1], newValue) + +local expiry = math.ceil(newValue / 1000) +redis.call('EXPIREAT', KEYS[1], expiry) + +return newValue diff --git a/lib/api/bucketDeleteRateLimit.js b/lib/api/bucketDeleteRateLimit.js new file mode 100644 index 0000000000..4fadb546c1 --- /dev/null +++ b/lib/api/bucketDeleteRateLimit.js @@ -0,0 +1,57 @@ +const { errors } = require('arsenal'); + +const metadata = require('../metadata/wrapper'); +const collectCorsHeaders = require('../utilities/collectCorsHeaders'); +const { isRateLimitServiceUser } = require('./apiUtils/authorization/serviceUser'); + +/** + * bucketDeleteRateLimit - Delete the bucket rate limit configuration + * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info + * @param {object} request - http request object + * @param {object} log - Werelogs logger + * @param {function} callback - callback to server + * @return {undefined} + */ +function bucketDeleteRateLimit(authInfo, request, log, callback) { + log.debug('processing request', { method: 'bucketDeleteRateLimit' }); + + if (!isRateLimitServiceUser(authInfo, log)) { + return callback(errors.AccessDenied); + } + + const { bucketName, headers, method } = request; + const metadataValParams = { + authInfo, + bucketName, + requestType: request.apiMethods || 'bucketDeleteRateLimit', + request, + }; + return standardMetadataValidateBucket(metadataValParams, request.actionImplicitDenies, log, (err, bucket) => { + const corsHeaders = collectCorsHeaders(headers.origin, method, bucket); + if (err) { + log.debug('error processing request', { + error: err, + method: 'bucketDeleteRateLimit', + }); + return callback(err, corsHeaders); + } + if (!bucket.getRateLimitConfiguration()) { + log.trace('no existing bucket rate limit configuration', { + method: 'bucketDeleteRateLimit', + }); + // TODO: implement Utapi metric support + return callback(null, corsHeaders); + } + log.trace('deleting bucket rate limit configuration in metadata'); + bucket.setRateLimitConfiguration(null); + return metadata.updateBucket(bucketName, bucket, log, err => { + if (err) { + return callback(err, corsHeaders); + } + // TODO: implement Utapi metric support + return callback(null, corsHeaders); + }); + }); +} + +module.exports = bucketDeleteRateLimit; diff --git a/lib/api/bucketGetRateLimit.js b/lib/api/bucketGetRateLimit.js new file mode 100644 index 0000000000..c1d84c6e04 --- /dev/null +++ b/lib/api/bucketGetRateLimit.js @@ -0,0 +1,55 @@ +const { errors } = require('arsenal'); + +const { standardMetadataValidateBucket } = require('../metadata/metadataUtils'); +const collectCorsHeaders = require('../utilities/collectCorsHeaders'); +const { isRateLimitServiceUser } = require('./apiUtils/authorization/serviceUser'); + +/** + * bucketGetRateLimit - Get the bucket rate limit config + * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info + * @param {object} request - http request object + * @param {object} log - Werelogs logger + * @param {function} callback - callback to server + * @return {undefined} + */ +function bucketGetRateLimit(authInfo, request, log, callback) { + log.debug('processing request', { method: 'bucketGetRateLimit' }); + + if (!isRateLimitServiceUser(authInfo, log)) { + return callback(errors.AccessDenied); + } + + const { bucketName, headers, method } = request; + + return standardMetadataValidateBucket(metadataValParams, request.actionImplicitDenies, log, (err, bucket) => { + const corsHeaders = collectCorsHeaders(headers.origin, method, bucket); + if (err) { + log.debug('error processing request', { + error: err, + method: 'bucketGetRateLimit', + }); + return callback(err, null, corsHeaders); + } + + const rateLimitConfig = bucket.getRateLimitConfiguration(); + const limit = rateLimitConfig?.getRequestsPerSecondLimit(); + + if (!rateLimitConfig || limit === undefined) { + log.debug('error processing request', { + error: errors.NoSuchRateLimitConfiguration, + method: 'bucketGetRateLimit', + }); + return callback(errors.NoSuchRateLimitConfiguration, null, + corsHeaders); + } + + // Return flattened structure matching API spec: {"RequestsPerSecond": 1000} + const response = { + RequestsPerSecond: limit, + }; + + return callback(null, JSON.stringify(response), corsHeaders); + }); +} + +module.exports = bucketGetRateLimit; diff --git a/lib/api/bucketPutRateLimit.js b/lib/api/bucketPutRateLimit.js new file mode 100644 index 0000000000..0adfc572bd --- /dev/null +++ b/lib/api/bucketPutRateLimit.js @@ -0,0 +1,118 @@ +const async = require('async'); +const { parseString } = require('xml2js'); +const { errorInstances, errors, models } = require('arsenal'); + +const collectCorsHeaders = require('../utilities/collectCorsHeaders'); +const metadata = require('../metadata/wrapper'); +const { isRateLimitServiceUser } = require('./apiUtils/authorization/serviceUser'); +const { config } = require('../Config'); + +const RateLimitConfiguration = models.RateLimitConfiguration; + +function parseRequestBody(requestBody, callback) { + // Try JSON first + let jsonData; + try { + jsonData = JSON.parse(requestBody); + if (typeof jsonData !== 'object') { + throw new Error('Invalid JSON - not an object'); + } + // JSON succeeded - return immediately, do NOT try XML + return callback(null, jsonData); + } catch (jsonError) { + // JSON failed - try XML + parseString(requestBody, (xmlError, xmlData) => { + if (xmlError) { + return callback(errorInstances.InvalidArgument + .customizeDescription('Request body must be a JSON object')); + } + return callback(null, xmlData); + }); + } +} + +function validateRateLimitConfig(requestConfig, callback) { + const limit = parseInt(requestConfig.RequestsPerSecond, 10); + + // Validate positive integer + if (Number.isNaN(limit) || !Number.isInteger(limit) || limit <= 0) { + return callback(errorInstances.InvalidArgument + .customizeDescription('RequestsPerSecond must be a positive integer')); + } + + // Validate minimum rate limit (must be >= number of nodes) + const nodeCount = config.rateLimiting?.nodeCount || 1; + if (limit < nodeCount) { + return callback(errorInstances.InvalidArgument + .customizeDescription( + `RequestsPerSecond must be >= ${nodeCount} (number of CloudServer nodes)` + )); + } + + // Create RateLimitConfiguration model with flattened structure + const rateLimitConfig = new RateLimitConfiguration({ + RequestsPerSecond: limit, + }); + + return callback(null, rateLimitConfig); +} + +/** + * bucketPutRateLimit - create or update a bucket policy + * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info + * @param {object} request - http request object + * @param {object} log - Werelogs logger + * @param {function} callback - callback to server + * @return {undefined} + */ +function bucketPutRateLimit(authInfo, request, log, callback) { + log.debug('processing request', { method: 'bucketPutRateLimit' }); + + const requestArn = authInfo.getArn(); + const configArn = config.rateLimiting?.serviceUserArn; + log.debug('ARN comparison for rate limit authorization', { + requestArn, + configArn, + match: requestArn === configArn, + }); + + if (!isRateLimitServiceUser(authInfo, log)) { + log.warn('Access denied - ARN mismatch', { requestArn, configArn }); + return callback(errors.AccessDenied); + } + + const { bucketName } = request; + const metadataValParams = { + authInfo, + bucketName, + requestType: request.apiMethods || 'bucketPutRateLimit', + request, + }; + + return async.waterfall([ + next => parseRequestBody(request.post, next), + (requestBody, next) => validateRateLimitConfig(requestBody, next), + (limitConfig, next) => standardMetadataValidateBucket(metadataValParams, request.actionImplicitDenies, log, + (err, bucket) => { + if (err) { + return next(err, bucket); + } + return next(null, bucket, limitConfig); + }), + (bucket, limitConfig, next) => bucket.setRateLimitConfiguration(limitConfig) + .then(() => metadata.updateBucket(bucket.getName(), bucket, log, + err => next(err, bucket))), + ], (err, bucket) => { + const corsHeaders = collectCorsHeaders(request.headers.origin, + request.method, bucket); + if (err) { + log.trace('error processing request', + { error: err, method: 'bucketPutRateLimit' }); + return callback(err, corsHeaders); + } + // TODO: implement Utapi metric support + return callback(null, corsHeaders); + }); +} + +module.exports = bucketPutRateLimit; diff --git a/tests/functional/aws-node-sdk/test/rateLimit/client.js b/tests/functional/aws-node-sdk/test/rateLimit/client.js new file mode 100644 index 0000000000..139e88e469 --- /dev/null +++ b/tests/functional/aws-node-sdk/test/rateLimit/client.js @@ -0,0 +1,44 @@ +const assert = require('assert'); + +const { config } = require('../../../../../lib/Config'); +const { RateLimitClient } = require('../../../../../lib/api/apiUtils/rateLimit/client'); + + +const counterKey = 'foo'; + +describe('Test RateLimitClient', () => { + let client; + + before(done => { + client = new RateLimitClient(config.localCache); + client.redis.connect(done); + }); + + beforeEach(done => { + client.redis.del(counterKey, err => done(err)); + }); + + it('should set the value of an empty counter', done => { + const batch = [{ key: counterKey, cost: 10000 }]; + client.updateLocalCounters(batch, (err, res) => { + assert.ifError(err); + assert.strictEqual(res.length, 1); + assert.strictEqual(res[0].key, counterKey); + done(); + }); + }); + + it('should increment the value of an existing counter', done => { + const batch = [{ key: counterKey, cost: 10000 }]; + client.updateLocalCounters(batch, (err, res) => { + assert.ifError(err); + const { value: existingValue } = res[0]; + client.updateLocalCounters(batch, (err, res) => { + assert.ifError(err); + const { value: newValue } = res[0]; + assert(newValue > existingValue, `${newValue} is not greater than ${existingValue}`); + done(); + }); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/rateLimit/cache.js b/tests/unit/api/apiUtils/rateLimit/cache.js new file mode 100644 index 0000000000..5b97c7d01c --- /dev/null +++ b/tests/unit/api/apiUtils/rateLimit/cache.js @@ -0,0 +1,110 @@ +const assert = require('assert'); +const sinon = require('sinon'); + +const constants = require('../../../../../constants'); +const { + counters, + configCache, + getCounter, + setCounter, + expireCounters, + getCachedConfig, + setCachedConfig, + expireCachedConfigs, +} = require('../../../../../lib/api/apiUtils/rateLimit/cache'); + +describe('test counter storage', () => { + it('setCounter() should set a counter', () => { + setCounter('foo', 10); + assert.strictEqual(counters.get('foo'), 10); + }); + + it('getCounter() should get a counter', () => { + setCounter('foo', 10); + assert.strictEqual(getCounter('foo'), 10); + }); + + it('should maintain order when updating a counter', () => { + setCounter('foo', 10); + setCounter('bar', 20); + setCounter('foo', 30); + + const items = Array.from(counters.entries()); + assert.deepStrictEqual(items, [ + ['bar', 20], + ['foo', 30], + ]); + }); + + it('should expire counters less than or equal to the given timestamp', () => { + const now = Date.now(); + const past = now - 100; + const future = now + 100; + setCounter('past', past); + setCounter('present', now); + setCounter('future', future); + expireCounters(now); + assert.strictEqual(getCounter('past'), undefined); + assert.strictEqual(getCounter('present'), undefined); + assert.strictEqual(getCounter('future'), future); + }); +}); + +describe('test limit config cache storage', () => { + const now = Date.now(); + + let clock; + before(() => { + clock = sinon.useFakeTimers(now); + }); + + after(() => { + clock.restore(); + }); + + it('should add config to cache', () => { + setCachedConfig('foo', 10, constants.rateLimitDefaultConfigCacheTTL); + assert.deepStrictEqual( + configCache.get('foo'), + { + expiry: now + constants.rateLimitDefaultConfigCacheTTL, + config: 10, + } + ); + }); + + it('should get a non expired config', () => { + setCachedConfig('foo', 10, constants.rateLimitDefaultConfigCacheTTL); + assert.strictEqual(getCachedConfig('foo'), 10); + }); + + it('should return undefined and delete the key for an expired config', () => { + configCache.set('foo', { + expiry: now - 10000, + config: 10, + }); + assert.strictEqual(getCachedConfig('foo'), undefined); + }); + + it('should expire configs less than or equal to the given timestamp', () => { + configCache.set('past', { + expiry: now - 10000, + config: 10, + }); + configCache.set('present', { + expiry: now, + config: 10, + }); + configCache.set('future', { + expiry: now + 10000, + config: 10, + }); + expireCachedConfigs(now); + assert.strictEqual(configCache.get('past'), undefined); + assert.strictEqual(configCache.get('present'), undefined); + assert.deepStrictEqual(configCache.get('future'), { + expiry: now + 10000, + config: 10, + }); + }); +}); diff --git a/tests/unit/api/apiUtils/rateLimit/client.js b/tests/unit/api/apiUtils/rateLimit/client.js new file mode 100644 index 0000000000..487322f8c0 --- /dev/null +++ b/tests/unit/api/apiUtils/rateLimit/client.js @@ -0,0 +1,83 @@ +const assert = require('assert'); + +const { RateLimitClient } = require('../../../../../lib/api/apiUtils/rateLimit/client'); + +class RedisStub { + constructor() { + this.data = {}; + this.execErr = null; + } + + pipeline() { + return new PipelineStub(this.execErr); + } + + setExecErr(err) { + this.execErr = err; + } +} + +class PipelineStub { + constructor(execErr) { + this.ops = []; + this.execErr = execErr; + } + + updateCounter(key, cost) { + this.ops.push([key, cost]); + } + + exec(cb) { + if (this.execErr) { + cb(this.execErr); + } else { + cb(null, this.ops.map(v => [1, v[1]])); + } + } +} + +describe('test RateLimitClient', () => { + let client; + + before(() => { + client = new RateLimitClient({}); + }); + + beforeEach(() => { + client.redis = new RedisStub(); + }); + + it('should update a batch of counters', done => { + const batch = [ + { key: 'foo', cost: 100 }, + { key: 'bar', cost: 200 }, + { key: 'qux', cost: 300 }, + ]; + + client.updateLocalCounters(batch, (err, results) => { + assert.ifError(err); + assert.deepStrictEqual(results, [ + { key: 'foo', value: 100 }, + { key: 'bar', value: 200 }, + { key: 'qux', value: 300 }, + ]); + done(); + }); + }); + + it('should pass through errors', done => { + const execErr = new Error('bad stuff'); + client.redis.setExecErr(execErr); + const batch = [ + { key: 'foo', cost: 100 }, + { key: 'bar', cost: 200 }, + { key: 'qux', cost: 300 }, + ]; + + client.updateLocalCounters(batch, (err, results) => { + assert.strictEqual(err, execErr); + assert.strictEqual(results, undefined); + done(); + }); + }); +}); diff --git a/tests/unit/api/apiUtils/rateLimit/config.js b/tests/unit/api/apiUtils/rateLimit/config.js new file mode 100644 index 0000000000..fb0f05dd64 --- /dev/null +++ b/tests/unit/api/apiUtils/rateLimit/config.js @@ -0,0 +1,84 @@ +const assert = require('assert'); + +const { parseRateLimitConfig } = require('../../../../../lib/api/apiUtils/rateLimit/config'); + +describe('test parseRateLimitConfig', () => { + const testCases = [ + { + desc: 'should return an empty config if given one', + input: {}, + expected: {}, + }, + { + desc: '[rps] should calculate the request interval', + input: { + requestsPerSecond: { + limit: 500 + }, + }, + expected: { + requestsPerSecond: { + interval: 2, + bucketSize: 1000, + } + }, + }, + { + desc: '[rps] should calculate bucket size', + input: { + requestsPerSecond: { + limit: 500, + burstCapacity: 5, + }, + }, + expected: { + requestsPerSecond: { + interval: 2, + bucketSize: 5000, + } + }, + }, + { + desc: '[rps] should throw an error if requestsPerSecond isn\'t an object', + input: { + requestsPerSecond: 'foo' + }, + throws: true, + }, + { + desc: '[rps] should throw an error if requestsPerSecond.limit isn\'t a number', + input: { + requestsPerSecond: { + limit: 'foo', + }, + }, + throws: true, + }, + { + desc: '[rps] should throw an error if requestsPerSecond.burstCapacity isn\'t a number', + input: { + requestsPerSecond: { + limit: 500, + burstCapacity: '5', + }, + }, + throws: true, + }, + ]; + + testCases.forEach(testCase => { + it(testCase.desc, () => { + if (testCase.throws) { + let err; + try { + parseRateLimitConfig(testCase.input); + } catch (e) { + err = e; + } + assert(err instanceof Error); + } else { + assert.deepStrictEqual(parseRateLimitConfig(testCase.input), testCase.expected); + } + }); + }); +});