diff --git a/lib/bans/handler.js b/lib/bans/handler.js index 76adb38..d1c8463 100644 --- a/lib/bans/handler.js +++ b/lib/bans/handler.js @@ -2,74 +2,30 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -module.exports = function (LIFETIME_SEC, mc, EmailRecord, IpRecord, log) { - - function blockIp(ip, cb) { - mc.get(ip, - function (err, data) { - if (err) { - log.error({ op: 'handleBan.blockIp', ip: ip, err: err }) - return cb(err) - } - - log.info({ op: 'handleBan.blockIp', ip: ip }) - var ir = IpRecord.parse(data) - ir.block() - var lifetime = Math.max(LIFETIME_SEC, ir.getMinLifetimeMS() / 1000) - mc.set(ip, ir, lifetime, - function (err) { - if (err) { - log.error({ op: 'memcachedError', err: err }) - return cb(err) - } - mc.end() - cb(null) - } - ) - } - ) +module.exports = function (fetchRecords, setRecord, log) { + async function blockIp(ip) { + const { ipRecord } = await fetchRecords({ ip }) + log.info({ op: 'handleBan.blockIp', ip: ip }) + ipRecord.block() + return setRecord(ipRecord) } - function blockEmail(email, cb) { - mc.get(email, - function (err, data) { - if (err) { - log.error({ op: 'handleBan.blockEmail', email: email, err: err }) - return cb(err) - } - - log.info({ op: 'handleBan.blockEmail', email: email }) - var er = EmailRecord.parse(data) - er.block() - var lifetime = Math.max(LIFETIME_SEC, er.getMinLifetimeMS() / 1000) - mc.set(email, er, lifetime, - function (err) { - if (err) { - log.error({ op: 'memcachedError', err: err }) - return cb(err) - } - mc.end() - cb(null) - } - ) - } - ) + async function blockEmail(email) { + const { emailRecord } = await fetchRecords({ email }) + log.info({ op: 'handleBan.blockEmail', email: email }) + emailRecord.block() + return setRecord(emailRecord) } - function handleBan(message, cb) { - if (!cb) { - cb = function () {} - } + async function handleBan(message) { if (message.ban && message.ban.ip) { - blockIp(message.ban.ip, cb) - } - else if (message.ban && message.ban.email) { - blockEmail(message.ban.email, cb) - } - else { - log.error({ op: 'handleBan', ban: !!message.ban }) - cb('invalid message') + return blockIp(message.ban.ip) + } else if (message.ban && message.ban.email) { + return blockEmail(message.ban.email) } + + log.error({ op: 'handleBan', ban: !!message.ban }) + return Promise.reject('invalid message') } return handleBan diff --git a/lib/record.js b/lib/record.js index 36f58d9..872a12b 100644 --- a/lib/record.js +++ b/lib/record.js @@ -7,8 +7,21 @@ class Record { object = object || {} this.rl = object.rl // timestamp when the account was rate-limited this.hits = object.hits || [] // timestamps when last hit occurred - this.limits = config.limits - this.actions = config.actions + + Object.defineProperty(this, 'limits', { + // limits is not saved to memcached + enumerable: false, + get () { + return config.limits + } + }) + Object.defineProperty(this, 'actions', { + // actions is not saved to memcached + enumerable: false, + get () { + return config.actions + } + }) this.now = now } diff --git a/lib/records.js b/lib/records.js new file mode 100644 index 0000000..60f3f47 --- /dev/null +++ b/lib/records.js @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +module.exports = function (mc, reputationService, limits, recordLifetimeSeconds) { + const IpEmailRecord = require('./ip_email_record')(limits) + const EmailRecord = require('./email_record')(limits) + const IpRecord = require('./ip_record')(limits) + const UidRecord = require('./uid_record')(limits) + const SmsRecord = require('./sms_record')(limits) + + /** + * Fetch a single record keyed by `key`, parse the result using `parser`. + * + * @param {String} key + * @param {Function} parser + * @returns {Promise} resolves to a Record when complete + */ + async function fetchRecord(key, parser) { + const record = await mc.getAsync(key).then(parser, parser) + record.key = key + return record + } + + /** + * Fetch a set of records + * + * @param {Object} config + * @param {String} [object.ip] ip address to fetch + * @param {String} [object.email] email address to fetch + * @param {String} [object.phoneNumber] phone number to fetch + * @param {String} [object.uid] uid to fetch + * @returns {Promise} resolves to an object with the following keys: + * `ipRecord`, `reputation`, `emailRecord`, `ipEmailRecord`, `smsRecord`, and `uidRecord` + */ + async function fetchRecords(config) { + const records = {} + + const { ip, email, phoneNumber, uid } = config + + if (ip) { + records.ipRecord = await fetchRecord(ip, IpRecord.parse) + records.reputation = await reputationService.get(ip) + } + + // The /checkIpOnly endpoint has no email (or phoneNumber) + if (email) { + records.emailRecord = await fetchRecord(email, EmailRecord.parse) + } + + if (ip && email) { + records.ipEmailRecord = await fetchRecord(ip + email, IpEmailRecord.parse) + } + + // Check against SMS records to make sure that this request can send to this phone number + if (phoneNumber) { + records.smsRecord = await fetchRecord(phoneNumber, SmsRecord.parse) + } + + if (uid) { + records.uidRecord = await fetchRecord(uid, UidRecord.parse) + } + + return records + } + + /** + * Save a record + * + * @param {Record} record + * @returns + */ + function setRecord(record) { + const lifetime = Math.max(recordLifetimeSeconds, record.getMinLifetimeMS() / 1000) + return mc.setAsync(record.key, marshallRecordForStorage(record), lifetime) + } + + /** + * Marshall a record for persistent storage + * + * @param {Record} record + * @returns {Object} + */ + function marshallRecordForStorage (record) { + const marshalled = {} + + for (const key of Object.keys(record)) { + if (key !== 'key' && typeof record[key] !== 'function') { + marshalled[key] = record[key] + } + } + + return marshalled + } + + /** + * Save records + * + * @param {Record[]} records to save. + * @returns {Promise} Resolves when complete + */ + function setRecords(...records) { + return Promise.all(records.map(record => setRecord(record))) + } + + return { + fetchRecord, + fetchRecords, + setRecord, + setRecords + } +} diff --git a/lib/server.js b/lib/server.js index 9cbfd33..f5eb484 100755 --- a/lib/server.js +++ b/lib/server.js @@ -15,7 +15,6 @@ var blockReasons = require('./block_reasons') var P = require('bluebird') P.promisifyAll(Memcached.prototype) var Raven = require('raven') -const utils = require('./utils') const dataflow = require('./dataflow') // Create and return a restify server instance @@ -64,6 +63,10 @@ module.exports = function createServer(config, log) { const allowedPhoneNumbers = require('./settings/allowed_phone_numbers')(config, Settings, log) var requestChecks = require('./settings/requestChecks')(config, Settings, log) + const { fetchRecord, fetchRecords, setRecords } = require('./records')(mc, reputationService, limits, config.memcache.recordLifetimeSeconds) + + const checkUserDefinedRateLimitRules = require('./user_defined_rules')(config, fetchRecord, setRecords) + if (config.updatePollIntervalSeconds) { [ allowedEmailDomains, @@ -77,32 +80,7 @@ module.exports = function createServer(config, log) { }) } - var IpEmailRecord = require('./ip_email_record')(limits) - var EmailRecord = require('./email_record')(limits) - var IpRecord = require('./ip_record')(limits) - const Record = require('./record') - var UidRecord = require('./uid_record')(limits) - var smsRecord = require('./sms_record')(limits) - - var handleBan = P.promisify(require('./bans/handler')(config.memcache.recordLifetimeSeconds, mc, EmailRecord, IpRecord, log)) - - // Pre compute user defined rules into a Map - function computeUserDefinedRules() { - const result = new Map() - const rules = config.userDefinedRateLimitRules - Object.keys(config.userDefinedRateLimitRules).forEach((key) => { - rules[key].actions.forEach((action) => { - const items = result.get(action) - if (! items) { - result.set(action, [key]) - } else { - result.set(action, items.push(key)) - } - }) - }) - return result - } - const computedRules = computeUserDefinedRules() + const handleBan = require('./bans/handler')(fetchRecords, setRecords, log) var api = restify.createServer({ formatters: { @@ -138,55 +116,6 @@ module.exports = function createServer(config, log) { throw err } - function fetchRecords(ip, email, phoneNumber) { - var promises = [ - // get records and ignore errors by returning a new record - mc.getAsync(ip).then(IpRecord.parse, IpRecord.parse), - reputationService.get(ip) - ] - - // The /checkIpOnly endpoint has no email (or phoneNumber) - if (email) { - promises.push(mc.getAsync(email).then(EmailRecord.parse, EmailRecord.parse)) - promises.push(mc.getAsync(ip + email).then(IpEmailRecord.parse, IpEmailRecord.parse)) - } - - // Check against SMS records to make sure that this request - // can send to this phone number - if (phoneNumber) { - promises.push(mc.getAsync(phoneNumber).then(smsRecord.parse, smsRecord.parse)) - } - - return P.all(promises) - } - - function setRecord(key, record) { - var lifetime = Math.max(config.memcache.recordLifetimeSeconds, record.getMinLifetimeMS() / 1000) - return mc.setAsync(key, record, lifetime) - } - - function setRecords(ip, ipRecord, email, emailRecord, ipEmailRecord, phoneNumber, smsRecord) { - let promises = [ - setRecord(ip, ipRecord) - ] - - if (email) { - if (emailRecord) { - promises.push(setRecord(email, emailRecord)) - } - - if (ipEmailRecord) { - promises.push(setRecord(ip + email, ipEmailRecord)) - } - } - - if (phoneNumber && smsRecord) { - promises.push(setRecord(phoneNumber, smsRecord)) - } - - return P.all(promises) - } - function isAllowed(ip, email, phoneNumber) { return allowedIPs.isAllowed(ip) || allowedEmailDomains.isAllowed(email) || @@ -248,7 +177,7 @@ module.exports = function createServer(config, log) { phoneNumber = payload.phoneNumber } - function checkRecords(ipRecord, reputation, emailRecord, ipEmailRecord, smsRecord) { + async function checkRecords({ ipRecord, reputation, emailRecord, ipEmailRecord, smsRecord }) { if (ipRecord.isBlocked()) { // a blocked ip should just be ignored completely // it's malicious, it shouldn't penalize emails or allow @@ -307,65 +236,16 @@ module.exports = function createServer(config, log) { blockReason = blockReasons.IP_BAD_REPUTATION } - return setRecords(ip, ipRecord, email, emailRecord, ipEmailRecord, phoneNumber, smsRecord) - .then(() => { - return { - block, - blockReason, - retryAfter, - unblock: canUnblock, - suspect - } - }) - } - - function checkUserDefinedRateLimitRules(result) { - // Get all the user defined rules that might apply to this action - const rules = config.userDefinedRateLimitRules - const checkRules = computedRules.get(action) - - // No need to check if no user defined rules - if (! checkRules || checkRules.length <= 0) { - return result + // smsRecord is optional, trying to save an undefined record results in an error + const recordsToSave = [ipRecord, emailRecord, ipEmailRecord, smsRecord].filter(record => !! record) + await setRecords(...recordsToSave) + return { + block, + blockReason, + retryAfter, + unblock: canUnblock, + suspect } - - const retries = [] - return P.each(checkRules, (ruleName) => { - let retryAfter = null - const recordKey = ruleName + ':' + utils.createHashHex(email, ip) - return mc.getAsync(recordKey) - .then((object) => { - return new Record(object, rules[ruleName]) - }, () => { - return new Record({}, rules[ruleName]) - }) - .then((record) => { - retryAfter = record.update(action) - const minLifetimeMS = record.getMinLifetimeMS() - - // To save space in memcache, don't store limits and - // actions since they can be referenced from config - record.limits = null - record.actions = null - - const lifetime = Math.max(config.memcache.recordLifetimeSeconds, minLifetimeMS / 1000) - return mc.setAsync(recordKey, record, lifetime) - }) - .then(() => retries.push(retryAfter)) - }) - .then(() => { - const maxRetry = Math.max(retries) - const block = maxRetry > 0 - const retryAfter = maxRetry || 0 - - // Only update the retryAfter if it has a larger rate limit - if (retryAfter && retryAfter > result.retryAfter) { - result.retryAfter = retryAfter - result.block = block - } - - return result - }) } function createResponse(result) { @@ -395,11 +275,11 @@ module.exports = function createServer(config, log) { }) } - return fetchRecords(ip, email, phoneNumber) - .spread(checkRecords) - .then(checkUserDefinedRateLimitRules) + fetchRecords({ ip, email, phoneNumber }) + .then(checkRecords) + .then(result => checkUserDefinedRateLimitRules(result, action, email, ip)) .then(createResponse, handleError) - .done(next, next) + .then(next, next) }) api.post( @@ -416,13 +296,10 @@ module.exports = function createServer(config, log) { return next() } - mc.getAsync(uid) - .then(UidRecord.parse, UidRecord.parse) - .then( - function (uidRecord) { - var retryAfter = uidRecord.addCount(action, uid) - - return setRecord(uid, uidRecord) + fetchRecords({ uid }) + .then(({ uidRecord }) => { + var retryAfter = uidRecord.addCount(action, uid) + return setRecords(uidRecord) .then( function () { return { @@ -431,8 +308,7 @@ module.exports = function createServer(config, log) { } } ) - } - ) + }) .then( function (result) { log.info({ op: 'request.checkAuthenticated', block: result.block }) @@ -453,7 +329,7 @@ module.exports = function createServer(config, log) { reputationService.report(ip, 'fxa:request.checkAuthenticated.block.' + action) } ) - .done(next, next) + .then(next, next) } ) @@ -468,8 +344,8 @@ module.exports = function createServer(config, log) { return next() } - fetchRecords(ip) - .spread((ipRecord, reputation) => { + fetchRecords({ ip }) + .then(({ ipRecord, reputation }) => { if (ipRecord.isBlocked()) { return { block: true, retryAfter: ipRecord.retryAfter() } } @@ -495,7 +371,7 @@ module.exports = function createServer(config, log) { blockReason = blockReasons.IP_BAD_REPUTATION } - return setRecords(ip, ipRecord) + return setRecords(ipRecord) .then(() => ({ block, blockReason, retryAfter, suspect })) }) .then(result => { @@ -521,7 +397,7 @@ module.exports = function createServer(config, log) { log.error({ op: 'request.checkIpOnly', ip: ip, action: action, err: err }) res.send({ block: true, retryAfter: limits.ipRateLimitIntervalSeconds }) }) - .done(next, next) + .then(next, next) }) api.post( @@ -538,9 +414,9 @@ module.exports = function createServer(config, log) { } email = normalizedEmail(email) - fetchRecords(ip, email) - .spread( - function (ipRecord, reputation, emailRecord, ipEmailRecord) { + fetchRecords({ ip, email }) + .then( + function ({ ipRecord, emailRecord, ipEmailRecord }) { ipRecord.addBadLogin({ email: email, errno: errno }) ipEmailRecord.addBadLogin() @@ -548,7 +424,7 @@ module.exports = function createServer(config, log) { reputationService.report(ip, 'fxa:request.failedLoginAttempt.isOverBadLogins') } - return setRecords(ip, ipRecord, email, emailRecord, ipEmailRecord) + return setRecords(ipRecord, emailRecord, ipEmailRecord) .then( function () { return {} @@ -566,7 +442,7 @@ module.exports = function createServer(config, log) { res.send(500, err) } ) - .done(next, next) + .then(next, next) } ) @@ -582,14 +458,11 @@ module.exports = function createServer(config, log) { } email = normalizedEmail(email) - mc.getAsync(email) - .then(EmailRecord.parse, EmailRecord.parse) - .then( - function (emailRecord) { - emailRecord.passwordReset() - return setRecord(email, emailRecord).catch(logError) - } - ) + fetchRecords({ email }) + .then(({ emailRecord }) => { + emailRecord.passwordReset() + return setRecords(emailRecord).catch(logError) + }) .then( function () { log.info({ op: 'request.passwordReset', email: email }) @@ -600,7 +473,7 @@ module.exports = function createServer(config, log) { res.send(500, err) } ) - .done(next, next) + .then(next, next) } ) @@ -629,7 +502,7 @@ module.exports = function createServer(config, log) { res.send(500, err) } ) - .done(next, next) + .then(next, next) } ) @@ -662,7 +535,7 @@ module.exports = function createServer(config, log) { reputationService.report(ip, 'fxa:request.blockIp') } ) - .done(next, next) + .then(next, next) } ) diff --git a/lib/user_defined_rules.js b/lib/user_defined_rules.js new file mode 100644 index 0000000..a7ba17c --- /dev/null +++ b/lib/user_defined_rules.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +module.exports = function (config, fetchRecord, setRecords) { + const Record = require('./record') + const utils = require('./utils') + + const configuredRules = config.userDefinedRateLimitRules || {} + // Pre compute user defined rules into a Map + const computedRules = new Map() + + Object.keys(configuredRules).forEach((key) => { + configuredRules[key].actions.forEach((action) => { + const items = computedRules.get(action) + if (! items) { + computedRules.set(action, [key]) + } else { + computedRules.set(action, items.push(key)) + } + }) + }) + + return async function checkUserDefinedRateLimitRules(result, action, email, ip) { + // Get all the user defined rules that might apply to this action + const checkRules = computedRules.get(action) + + // No need to check if no user defined rules + if (! checkRules || checkRules.length <= 0) { + return result + } + + const retries = [] + await Promise.all(checkRules.map(async (ruleName) => { + const recordKey = ruleName + ':' + utils.createHashHex(email, ip) + const record = await fetchRecord(recordKey, object => new Record(object, configuredRules[ruleName])) + retries.push(record.update(action)) + + await setRecords(record) + })) + + const maxRetry = Math.max(retries) + const block = maxRetry > 0 + const retryAfter = maxRetry || 0 + + // Only update the retryAfter if it has a larger rate limit + if (retryAfter && retryAfter > result.retryAfter) { + result.retryAfter = retryAfter + result.block = block + } + + return result + } +} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 42181c5..b6b3b7a 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -502,6 +502,12 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz", @@ -724,6 +730,20 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, + "chai": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", + "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.0", + "type-detect": "^4.0.5" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -735,6 +755,12 @@ "supports-color": "^5.3.0" } }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "circular-json": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", @@ -1691,6 +1717,15 @@ "map-obj": "^1.0.0" } }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, "deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -2377,6 +2412,12 @@ "is-property": "^1.0.0" } }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, "get-pkg-repo": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz", @@ -5941,6 +5982,12 @@ } } }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/package.json b/package.json index b846565..963c171 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "audit-filter": "0.3.0", + "chai": "4.2.0", "eslint-config-fxa": "2.1.0", "fxa-conventional-changelog": "1.1.0", "grunt": "1.0.3", diff --git a/test/local/ban_tests.js b/test/local/ban_tests.js index f89da10..d08e0ef 100644 --- a/test/local/ban_tests.js +++ b/test/local/ban_tests.js @@ -1,149 +1,98 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ -var test = require('tap').test -var log = { +const { assert } = require('chai') +const sinon = require('sinon') +const { test } = require('tap') + +const log = { info: function () {}, error: function () {} } -var limits = { - rateLimitIntervalMs: 1000, - blockIntervalMs: 1000, - ipRateLimitIntervalMs: 1000, - ipRateLimitBanDurationMs: 1000 -} -var mcHelper = require('../memcache-helper') -var EmailRecord = require('../../lib/email_record')(limits) -var IpRecord = require('../../lib/ip_record')(limits) -var banHandler = require('../../lib/bans/handler') -var config = { - limits: { - blockIntervalSeconds: 1 +const banHandler = require('../../lib/bans/handler') + +const TEST_IP = '192.0.2.1' +const TEST_EMAIL = 'test@example.com' + +const sandbox = sinon.createSandbox() + +const records = { + emailRecord: { + block: sandbox.spy() + }, + ipRecord: { + block: sandbox.spy() } } -var TEST_IP = '192.0.2.1' -var TEST_EMAIL = 'test@example.com' +const fetchRecords = sandbox.spy(() => Promise.resolve(records)) +const setRecord = sandbox.spy(() => Promise.resolve()) test( - 'clear everything', - function (t) { - mcHelper.clearEverything( - function (err) { - t.notOk(err, 'no errors were returned') - t.end() - } - ) - } -) - -test( - 'well-formed ip blocking request', - function (t) { - var message = { + 'well-formed ip blocking request', async () => { + const message = { ban: { ip: TEST_IP } } - banHandler(10, mcHelper.mc, EmailRecord, IpRecord, log)(message, - function (err) { - t.notOk(err, 'no errors were returned') + const handleBan = banHandler(fetchRecords, setRecord, log) + await handleBan(message) + assert.isTrue(records.ipRecord.block.calledOnce) + assert.isTrue(setRecord.calledOnce) + assert.deepEqual(setRecord.args[0][0], records.ipRecord) - mcHelper.blockedIpCheck( - function (isBlocked) { - t.equal(isBlocked, true, 'ip is blocked') - t.end() - } - ) - } - ) + sandbox.reset() } ) test( - 'ip block has expired', - function (t) { - setTimeout( - function () { - mcHelper.blockedIpCheck( - function (isBlocked) { - t.equal(isBlocked, false, 'ip is not blocked') - t.end() - } - ) - }, - config.limits.blockIntervalSeconds * 1000 - ) - } -) - -test( - 'well-formed email blocking request', - function (t) { - var message = { + 'well-formed email blocking request', async () => { + const message = { ban: { email: TEST_EMAIL } } - banHandler(10, mcHelper.mc, EmailRecord, IpRecord, log)(message, - function (err) { - t.notOk(err, 'no errors were returned') + const handleBan = banHandler(fetchRecords, setRecord, log) + await handleBan(message) + assert.isTrue(records.emailRecord.block.calledOnce) + assert.isTrue(setRecord.calledOnce) + assert.deepEqual(setRecord.args[0][0], records.emailRecord) - mcHelper.blockedEmailCheck( - function (isBlocked) { - t.equal(isBlocked, true, 'email is blocked') - t.end() - } - ) - } - ) + sandbox.reset() } ) test( - 'email block has expired', - function (t) { - setTimeout( - function () { - mcHelper.blockedEmailCheck( - function (isBlocked) { - t.equal(isBlocked, false, 'email is not blocked') - t.end() - } - ) - }, - config.limits.blockIntervalSeconds * 1000 - ) - } -) - -test( - 'missing ip and email', - function (t) { - var message = { + 'missing ip and email', async () => { + const message = { ban: { } } - banHandler(10, mcHelper.mc, EmailRecord, IpRecord, log)(message, - function (err) { - t.equal(err, 'invalid message') - t.end() - } - ) + const handleBan = banHandler(fetchRecords, setRecord, log) + try { + await handleBan(message) + assert.fail() + } catch (err) { + assert.strictEqual(err, 'invalid message') + } + + sandbox.reset() } ) test( - 'missing ban', - function (t) { - var message = { + 'missing ban', async () => { + const message = {} + const handleBan = banHandler(fetchRecords, setRecord, log) + + try { + await handleBan(message) + assert.fail() + } catch (err) { + assert.strictEqual(err, 'invalid message') } - banHandler(10, mcHelper.mc, EmailRecord, IpRecord, log)(message, - function (err) { - t.equal(err, 'invalid message') - t.end() - } - ) + + sandbox.reset() } ) diff --git a/test/local/records_tests.js b/test/local/records_tests.js new file mode 100644 index 0000000..bded602 --- /dev/null +++ b/test/local/records_tests.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { assert } = require('chai') +const sinon = require('sinon') +const { test } = require('tap') + +const sandbox = sinon.createSandbox() + +const mc = { + getAsync: sandbox.spy(() => Promise.resolve({})), + setAsync: sandbox.spy(() => Promise.resolve()) +} + +const reputationService = { + get: sandbox.spy(() => Promise.resolve({})) +} + +const limits = {} + +const recordLifetimeSeconds = 1 + +var { fetchRecords, setRecords, setRecord } = require('../../lib/records')(mc, reputationService, limits, recordLifetimeSeconds) + +test( + 'fetchRecords', + function (t) { + return fetchRecords({ + ip: 'ip address', + email: 'email address', + phoneNumber: 'phone number', + uid: 'uid' + }) + .then(records => { + assert.strictEqual(mc.getAsync.callCount, 5) + assert.strictEqual(mc.getAsync.args[0][0], 'ip address') + assert.strictEqual(mc.getAsync.args[1][0], 'email address') + assert.strictEqual(mc.getAsync.args[2][0], 'ip addressemail address') + assert.strictEqual(mc.getAsync.args[3][0], 'phone number') + assert.strictEqual(mc.getAsync.args[4][0], 'uid') + + assert.lengthOf(Object.keys(records), 6) + assert.isObject(records.ipRecord) + assert.deepEqual(records.ipRecord.key, 'ip address') + + assert.isObject(records.reputation) + + assert.isObject(records.emailRecord) + assert.strictEqual(records.emailRecord.key, 'email address') + + assert.isObject(records.ipEmailRecord) + assert.strictEqual(records.ipEmailRecord.key, 'ip addressemail address') + + assert.isObject(records.smsRecord) + assert.strictEqual(records.smsRecord.key, 'phone number') + + assert.isObject(records.uidRecord) + assert.strictEqual(records.uidRecord.key, 'uid') + + sandbox.reset() + }) + } +) + +test( + 'setRecord', + function (t) { + const record = { + key: 'key', + getMinLifetimeMS: () => 5000, + value: 'record' + } + + return setRecord(record) + .then(result => { + assert.strictEqual(mc.setAsync.callCount, 1) + assert.strictEqual(mc.setAsync.args[0][0], 'key') + assert.deepEqual(mc.setAsync.args[0][1], { value: 'record' }) + assert.strictEqual(mc.setAsync.args[0][2], 5) + sandbox.reset() + }) + } +) + +test( + 'setRecords', + function (t) { + const ipRecord = { + key: 'ip address', + getMinLifetimeMS: () => 5000, + value: 'ip record' + } + const emailRecord = { + key: 'email address', + getMinLifetimeMS: () => 5000, + value: 'email record' + } + const ipEmailRecord = { + key: 'ip addressemail address', + getMinLifetimeMS: () => 5000, + value: 'ip email record' + } + const smsRecord = { + key: 'phone number', + getMinLifetimeMS: () => 5000, + value: 'sms record' + } + const userDefinedRecord = { + key: 'user defined key', + getMinLifetimeMS: () => 5000, + value: 'user defined record' + } + Object.defineProperty(userDefinedRecord, 'not_saved', { + enumerable: false, + get () { + return 'not-saved-value' + } + }) + + return setRecords(ipRecord, emailRecord, ipEmailRecord, smsRecord, userDefinedRecord) + .then(records => { + assert.strictEqual(mc.setAsync.callCount, 5) + assert.strictEqual(mc.setAsync.args[0][0], 'ip address') + assert.deepEqual(mc.setAsync.args[0][1], { value: 'ip record' }) + + assert.strictEqual(mc.setAsync.args[1][0], 'email address') + assert.deepEqual(mc.setAsync.args[1][1], { value: 'email record' }) + + assert.strictEqual(mc.setAsync.args[2][0], 'ip addressemail address') + assert.deepEqual(mc.setAsync.args[2][1], { value: 'ip email record' }) + + assert.strictEqual(mc.setAsync.args[3][0], 'phone number') + assert.deepEqual(mc.setAsync.args[3][1], { value: 'sms record' }) + + assert.strictEqual(mc.setAsync.args[4][0], 'user defined key') + assert.deepEqual(mc.setAsync.args[4][1], { value: 'user defined record' }) + + sandbox.reset() + }) + } +) +