From e8aedc19b1a5ea4ad1f0f1bc7bc214f42f578f15 Mon Sep 17 00:00:00 2001 From: Alexey Yarmosh Date: Wed, 14 Feb 2024 11:32:32 +0100 Subject: [PATCH 1/2] feat: separate limits for authenticated and anon users --- config/default.cjs | 6 +- src/lib/http/middleware/validate.ts | 2 +- src/measurement/schema/global-schema.ts | 9 +- src/measurement/schema/location-schema.ts | 9 +- .../unit/measurement/schema/schema.test.ts | 279 +++++++++++++----- 5 files changed, 216 insertions(+), 89 deletions(-) diff --git a/config/default.cjs b/config/default.cjs index f4e82395..02620f14 100644 --- a/config/default.cjs +++ b/config/default.cjs @@ -56,8 +56,10 @@ module.exports = { // measurement result TTL in redis resultTTL: 7 * 24 * 60 * 60, // 7 days limits: { - global: 500, - location: 200, + anonymousTestsPerLocation: 200, + anonymousTestsPerMeasurement: 500, + authenticatedTestsPerLocation: 500, + authenticatedTestsPerMeasurement: 500, }, globalDistribution: { AF: 5, diff --git a/src/lib/http/middleware/validate.ts b/src/lib/http/middleware/validate.ts index 4601aab2..7ff1baaa 100644 --- a/src/lib/http/middleware/validate.ts +++ b/src/lib/http/middleware/validate.ts @@ -2,7 +2,7 @@ import type { Schema } from 'joi'; import type { ExtendedMiddleware } from '../../../types.js'; export const validate = (schema: Schema): ExtendedMiddleware => async (ctx, next) => { - const valid = schema.validate(ctx.request.body, { convert: true }); + const valid = schema.validate(ctx.request.body, { convert: true, context: ctx.state }); if (valid.error) { const fields = valid.error.details.map(field => [ field.path.join('.'), String(field?.message) ]); diff --git a/src/measurement/schema/global-schema.ts b/src/measurement/schema/global-schema.ts index 729dce08..b93b4c14 100644 --- a/src/measurement/schema/global-schema.ts +++ b/src/measurement/schema/global-schema.ts @@ -7,13 +7,18 @@ import { import { schema as locationSchema } from './location-schema.js'; import { GLOBAL_DEFAULTS } from './utils.js'; -const measurementConfig = config.get<{limits: {global: number; location: number}}>('measurement'); +const authenticatedTestsPerMeasurement = config.get('measurement.limits.authenticatedTestsPerMeasurement'); +const anonymousTestsPerMeasurement = config.get('measurement.limits.anonymousTestsPerMeasurement'); export const schema = Joi.object({ type: Joi.string().valid('ping', 'traceroute', 'dns', 'mtr', 'http').insensitive().required(), target: targetSchema, measurementOptions: measurementSchema, locations: locationSchema, - limit: Joi.number().min(1).max(measurementConfig.limits.global).default(GLOBAL_DEFAULTS.limit), + limit: Joi.number().min(1).when('$userId', { + is: Joi.exist(), + then: Joi.number().max(authenticatedTestsPerMeasurement), + otherwise: Joi.number().max(anonymousTestsPerMeasurement), + }).default(GLOBAL_DEFAULTS.limit), inProgressUpdates: Joi.bool().default(GLOBAL_DEFAULTS.inProgressUpdates), }); diff --git a/src/measurement/schema/location-schema.ts b/src/measurement/schema/location-schema.ts index 11bf8967..1340eafa 100644 --- a/src/measurement/schema/location-schema.ts +++ b/src/measurement/schema/location-schema.ts @@ -8,7 +8,8 @@ import { states } from '../../lib/location/states.js'; import { regionNames } from '../../lib/location/regions.js'; import { GLOBAL_DEFAULTS } from './utils.js'; -const measurementConfig = config.get<{limits: {global: number; location: number}}>('measurement'); +const authenticatedTestsPerLocation = config.get('measurement.limits.authenticatedTestsPerLocation'); +const anonymousTestsPerLocation = config.get('measurement.limits.anonymousTestsPerLocation'); const normalizeValue = (value: string): string => anyAscii(value); @@ -27,7 +28,11 @@ export const schema = Joi.alternatives().try( asn: Joi.number().integer().positive(), magic: Joi.string().min(1).custom(normalizeValue), tags: Joi.array().items(Joi.string().min(1).max(128).lowercase().custom(normalizeValue)), - limit: Joi.number().min(1).max(measurementConfig.limits.location).when(Joi.ref('/limit'), { + limit: Joi.number().min(1).when('$userId', { + is: Joi.exist(), + then: Joi.number().max(authenticatedTestsPerLocation), + otherwise: Joi.number().max(anonymousTestsPerLocation), + }).when(Joi.ref('/limit'), { is: Joi.exist(), then: Joi.forbidden().messages({ 'any.unknown': 'limit per location is not allowed when a global limit is set' }), otherwise: Joi.number().default(1), diff --git a/test/tests/unit/measurement/schema/schema.test.ts b/test/tests/unit/measurement/schema/schema.test.ts index 4083e1d7..5b944fdb 100644 --- a/test/tests/unit/measurement/schema/schema.test.ts +++ b/test/tests/unit/measurement/schema/schema.test.ts @@ -25,10 +25,9 @@ describe('command schema', async () => { type: 'ping', target: 'abc.com', locations: [], - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.value.limit).to.equal(1); }); @@ -40,11 +39,10 @@ describe('command schema', async () => { locations: [ { city: 'milan', limit: 1 }, ], - measurementOptions: {}, limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid?.error?.details?.[0]?.message).to.equal('limit per location is not allowed when a global limit is set'); }); @@ -57,10 +55,9 @@ describe('command schema', async () => { { city: 'milan' }, { city: 'london' }, ], - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.value.limit).to.equal(input.locations.length); expect(valid.error).to.not.exist; @@ -73,13 +70,81 @@ describe('command schema', async () => { locations: [ { city: 'milan' }, ], - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.value.limit).to.equal(1); }); + + it('should pass (valid anonymous global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + limit: 500, + }; + + const valid = globalSchema.validate(input, { convert: true }); + + expect(valid.error).to.not.exist; + }); + + it('should return an error (invalid anonymous global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + limit: 501, + }; + + const valid = globalSchema.validate(input, { convert: true }); + + expect(valid?.error?.details?.[0]?.message).to.equal('"limit" must be less than or equal to 500'); + }); + + it('should pass (valid authenticated global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + limit: 500, + }; + + const valid = globalSchema.validate(input, { convert: true, context: { userId: '1-1-1-1-1' } }); + + expect(valid.error).to.not.exist; + }); + + it('should return an error (invalid authenticated global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + limit: 501, + }; + + const valid = globalSchema.validate(input, { convert: true, context: { userId: '1-1-1-1-1' } }); + + expect(valid?.error?.details?.[0]?.message).to.equal('"limit" must be less than or equal to 500'); + }); + + it('should pass (locations limit sum is bigger than global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + locations: [{ + country: 'BR', + limit: 200, + }, { + country: 'CZ', + limit: 200, + }, { + country: 'DE', + limit: 200, + }], + }; + + const valid = globalSchema.validate(input, { convert: true }); + + expect(valid.error).to.not.exist; + }); }); describe('type matching', () => { @@ -93,7 +158,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -108,7 +173,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -128,7 +193,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -144,7 +209,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -166,7 +231,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -316,6 +381,60 @@ describe('command schema', async () => { expect(valid.error).to.not.exist; }); }); + + describe('limit', () => { + it('should pass (valid anonymous location limit)', () => { + const input = [ + { + city: 'Warsaw', + limit: 200, + }, + ]; + + const valid = locationSchema.validate(input); + + expect(valid.error).to.not.exist; + }); + + it('should return an error (invalid anonymous location limit)', () => { + const input = [ + { + city: 'Warsaw', + limit: 201, + }, + ]; + + const valid = locationSchema.validate(input); + + expect(valid?.error?.details?.[0]?.message).to.equal('"[0].limit" must be less than or equal to 200'); + }); + + it('should pass (valid authenticated location limit)', () => { + const input = [ + { + city: 'Warsaw', + limit: 500, + }, + ]; + + const valid = locationSchema.validate(input, { context: { userId: '1-1-1-1-1' } }); + + expect(valid.error).to.not.exist; + }); + + it('should return an error (invalid authenticated location limit)', () => { + const input = [ + { + city: 'Warsaw', + limit: 501, + }, + ]; + + const valid = locationSchema.validate(input, { context: { userId: '1-1-1-1-1' } }); + + expect(valid?.error?.details?.[0]?.message).to.equal('"[0].limit" must be less than or equal to 500'); + }); + }); }); describe('target validator', () => { @@ -375,10 +494,9 @@ describe('command schema', async () => { const input = { type: 'ping', target: '192.168.0.101', - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Private hostnames are not allowed.'); @@ -388,10 +506,9 @@ describe('command schema', async () => { const input = { type: 'ping', target: '0083:eec9:a0b9:bc22:a151:ad0e:a3d7:fd28', - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -402,7 +519,7 @@ describe('command schema', async () => { type: 'ping', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" is required'); @@ -414,7 +531,7 @@ describe('command schema', async () => { target: 'abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -425,7 +542,7 @@ describe('command schema', async () => { target: '_acme-challenge.abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -436,7 +553,7 @@ describe('command schema', async () => { target: '1.1.1.1', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -447,7 +564,7 @@ describe('command schema', async () => { target: '300.300.300.300', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -459,7 +576,7 @@ describe('command schema', async () => { target: '00517985.widget.windsorbongvape.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -471,7 +588,7 @@ describe('command schema', async () => { target: '100.0.41.228', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -483,7 +600,7 @@ describe('command schema', async () => { target: 'abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value.type).to.equal('ping'); @@ -505,7 +622,7 @@ describe('command schema', async () => { limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -527,7 +644,7 @@ describe('command schema', async () => { limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -540,7 +657,7 @@ describe('command schema', async () => { type: 'traceroute', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" is required'); @@ -552,7 +669,7 @@ describe('command schema', async () => { target: '0083:eec9:a0b9:bc22:a151:ad0e:a3d7:fd28', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -564,7 +681,7 @@ describe('command schema', async () => { target: '00517985.widget.windsorbongvape.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -576,7 +693,7 @@ describe('command schema', async () => { target: '100.0.41.228', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -591,7 +708,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.port" must be a valid port'); @@ -603,7 +720,7 @@ describe('command schema', async () => { target: 'abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -614,7 +731,7 @@ describe('command schema', async () => { target: '_acme-challenge.abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -625,7 +742,7 @@ describe('command schema', async () => { target: '1.1.1.1', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -636,7 +753,7 @@ describe('command schema', async () => { target: '300.300.300.300', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -651,7 +768,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value.type).to.equal('traceroute'); @@ -675,7 +792,7 @@ describe('command schema', async () => { locations: [], }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -698,7 +815,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -711,7 +828,7 @@ describe('command schema', async () => { type: 'dns', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" is required'); @@ -723,7 +840,7 @@ describe('command schema', async () => { target: '1.1.1.1', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided target is not a valid domain name'); @@ -735,7 +852,7 @@ describe('command schema', async () => { target: '00517985.widget.windsorbongvape.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -750,7 +867,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.resolver" does not match any of the allowed types'); @@ -765,7 +882,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -780,7 +897,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -795,7 +912,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.port" must be a valid port'); @@ -810,7 +927,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -819,10 +936,9 @@ describe('command schema', async () => { const input = { type: 'dns', target: '_acme-challenge.abc.com', - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -840,7 +956,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value.type).to.equal('dns'); @@ -861,7 +977,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value.type).to.equal('dns'); @@ -891,7 +1007,7 @@ describe('command schema', async () => { locations: [], }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -918,7 +1034,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -937,7 +1053,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" must be a valid ip address of one of the following versions [ipv4] with a forbidden CIDR'); @@ -954,7 +1070,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -971,7 +1087,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).not.to.exist; expect(valid.value.type).to.equal('dns'); @@ -989,7 +1105,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).not.to.exist; expect(valid.value.type).to.equal('dns'); @@ -1003,7 +1119,7 @@ describe('command schema', async () => { type: 'mtr', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" is required'); @@ -1015,7 +1131,7 @@ describe('command schema', async () => { target: '0083:eec9:a0b9:bc22:a151:ad0e:a3d7:fd28', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -1027,7 +1143,7 @@ describe('command schema', async () => { target: '00517985.widget.windsorbongvape.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -1039,7 +1155,7 @@ describe('command schema', async () => { target: '100.0.41.228', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -1054,7 +1170,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.port" must be a valid port'); @@ -1066,7 +1182,7 @@ describe('command schema', async () => { target: 'abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -1077,7 +1193,7 @@ describe('command schema', async () => { target: '_acme-challenge.abc.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -1088,7 +1204,7 @@ describe('command schema', async () => { target: '1.1.1.1', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -1099,7 +1215,7 @@ describe('command schema', async () => { target: '300.300.300.300', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"target" does not match any of the allowed types'); @@ -1114,7 +1230,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value.type).to.equal('mtr'); @@ -1139,7 +1255,7 @@ describe('command schema', async () => { locations: [], }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -1163,7 +1279,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -1189,7 +1305,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.resolver" must be a valid ip address of one of the following versions [ipv4] with a forbidden CIDR'); @@ -1213,7 +1329,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.resolver" must be a valid ip address of one of the following versions [ipv4] with a forbidden CIDR'); }); @@ -1236,7 +1352,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); }); @@ -1258,7 +1374,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.request.method" must be one of [GET, HEAD]'); @@ -1281,7 +1397,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.protocol" must be one of [HTTP, HTTPS, HTTP2]'); @@ -1293,7 +1409,7 @@ describe('command schema', async () => { target: '00517985.widget.windsorbongvape.com', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -1305,7 +1421,7 @@ describe('command schema', async () => { target: '100.0.41.228', }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('Provided address is blacklisted.'); @@ -1315,10 +1431,9 @@ describe('command schema', async () => { const input = { type: 'http', target: '_sub.domani.com', - measurementOptions: {}, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; }); @@ -1332,7 +1447,7 @@ describe('command schema', async () => { }, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.exist; expect(valid.error!.message).to.equal('"measurementOptions.port" must be a valid port'); @@ -1372,7 +1487,7 @@ describe('command schema', async () => { limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -1414,7 +1529,7 @@ describe('command schema', async () => { limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); @@ -1443,7 +1558,7 @@ describe('command schema', async () => { limit: 1, }; - const valid = globalSchema.validate(input); + const valid = globalSchema.validate(input, { convert: true }); expect(valid.error).to.not.exist; expect(valid.value).to.deep.equal(desiredOutput); From 70d813df598d5d81756c20856d134d5399357d38 Mon Sep 17 00:00:00 2001 From: Alexey Yarmosh Date: Wed, 14 Feb 2024 14:32:36 +0100 Subject: [PATCH 2/2] fix: location limits sum <= global limit --- src/lib/malware/client.ts | 14 +++-------- src/measurement/schema/command-schema.ts | 4 +-- src/measurement/schema/location-schema.ts | 25 +++++++++++++++++-- .../unit/measurement/schema/schema.test.ts | 25 +++++++++++++++++-- wallaby.js | 1 + 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/lib/malware/client.ts b/src/lib/malware/client.ts index 5f74fdec..886d3ba0 100644 --- a/src/lib/malware/client.ts +++ b/src/lib/malware/client.ts @@ -42,14 +42,8 @@ export const joiValidate = (value: string, helpers?: CustomHelpers): string | Er return String(value); }; -export const joiErrorMessage = (field = 'address'): string => `Provided ${field} is blacklisted.`; - -export const joiSchemaErrorMessage = (field?: string): Record => { - const message = joiErrorMessage(field); - - return { - 'ip.blacklisted': message, - 'domain.blacklisted': message, - 'any.blacklisted': message, - }; +export const joiSchemaErrorMessages = { + 'ip.blacklisted': `Provided address is blacklisted.`, + 'domain.blacklisted': `Provided address is blacklisted.`, + 'any.blacklisted': `Provided address is blacklisted.`, }; diff --git a/src/measurement/schema/command-schema.ts b/src/measurement/schema/command-schema.ts index b315b193..62771336 100644 --- a/src/measurement/schema/command-schema.ts +++ b/src/measurement/schema/command-schema.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; import { - joiSchemaErrorMessage as joiMalwareSchemaErrorMessage, + joiSchemaErrorMessages as joiMalwareSchemaErrorMessages, } from '../../lib/malware/client.js'; import { joiValidateDomain, @@ -11,7 +11,7 @@ import { } from './utils.js'; export const schemaErrorMessages = { - ...joiMalwareSchemaErrorMessage(), + ...joiMalwareSchemaErrorMessages, 'ip.private': 'Private hostnames are not allowed.', 'domain.invalid': 'Provided target is not a valid domain name', }; diff --git a/src/measurement/schema/location-schema.ts b/src/measurement/schema/location-schema.ts index 1340eafa..6998c906 100644 --- a/src/measurement/schema/location-schema.ts +++ b/src/measurement/schema/location-schema.ts @@ -1,18 +1,31 @@ import anyAscii from 'any-ascii'; -import Joi from 'joi'; +import Joi, { CustomHelpers, ErrorReport } from 'joi'; import config from 'config'; import { continents, countries } from 'countries-list'; import { states } from '../../lib/location/states.js'; import { regionNames } from '../../lib/location/regions.js'; import { GLOBAL_DEFAULTS } from './utils.js'; +import type { LocationWithLimit } from '../types.js'; +const authenticatedTestsPerMeasurement = config.get('measurement.limits.authenticatedTestsPerMeasurement'); +const anonymousTestsPerMeasurement = config.get('measurement.limits.anonymousTestsPerMeasurement'); const authenticatedTestsPerLocation = config.get('measurement.limits.authenticatedTestsPerLocation'); const anonymousTestsPerLocation = config.get('measurement.limits.anonymousTestsPerLocation'); const normalizeValue = (value: string): string => anyAscii(value); +export const sumOfLocationsLimits = (code: string, max: number) => (value: LocationWithLimit[], helpers: CustomHelpers): LocationWithLimit[] | ErrorReport => { + const sum = value.reduce((sum, location) => sum + (location.limit || 1), 0); + + if (sum > max) { + return helpers.error(code); + } + + return value; +}; + export const schema = Joi.alternatives().try( Joi.string(), Joi.array().items(Joi.object().keys({ @@ -37,5 +50,13 @@ export const schema = Joi.alternatives().try( then: Joi.forbidden().messages({ 'any.unknown': 'limit per location is not allowed when a global limit is set' }), otherwise: Joi.number().default(1), }), - }).or('continent', 'region', 'country', 'state', 'city', 'network', 'asn', 'magic', 'tags')), + }).or('continent', 'region', 'country', 'state', 'city', 'network', 'asn', 'magic', 'tags')) + .when('$userId', { + is: Joi.exist(), + then: Joi.custom(sumOfLocationsLimits('limits.sum.auth', authenticatedTestsPerMeasurement)), + otherwise: Joi.custom(sumOfLocationsLimits('limits.sum.anon', anonymousTestsPerMeasurement)), + }).messages({ + 'limits.sum.auth': `Sum of limits must be less than or equal to ${authenticatedTestsPerMeasurement}`, + 'limits.sum.anon': `Sum of limits must be less than or equal to ${anonymousTestsPerMeasurement}`, + }), ).default(GLOBAL_DEFAULTS.locations); diff --git a/test/tests/unit/measurement/schema/schema.test.ts b/test/tests/unit/measurement/schema/schema.test.ts index 5b944fdb..b7d08724 100644 --- a/test/tests/unit/measurement/schema/schema.test.ts +++ b/test/tests/unit/measurement/schema/schema.test.ts @@ -125,7 +125,7 @@ describe('command schema', async () => { expect(valid?.error?.details?.[0]?.message).to.equal('"limit" must be less than or equal to 500'); }); - it('should pass (locations limit sum is bigger than global limit)', () => { + it('should return an error (locations anonymous limit sum is bigger than global limit)', () => { const input = { type: 'ping', target: 'abc.com', @@ -143,7 +143,28 @@ describe('command schema', async () => { const valid = globalSchema.validate(input, { convert: true }); - expect(valid.error).to.not.exist; + expect(valid?.error?.details?.[0]?.message).to.equal('Sum of limits must be less than or equal to 500'); + }); + + it('should return an error (locations authenticated limit sum is bigger than global limit)', () => { + const input = { + type: 'ping', + target: 'abc.com', + locations: [{ + country: 'BR', + limit: 200, + }, { + country: 'CZ', + limit: 200, + }, { + country: 'DE', + limit: 200, + }], + }; + + const valid = globalSchema.validate(input, { convert: true, context: { userId: '1-1-1-1-1' } }); + + expect(valid?.error?.details?.[0]?.message).to.equal('Sum of limits must be less than or equal to 500'); }); }); diff --git a/wallaby.js b/wallaby.js index 441214f7..73f52fcb 100644 --- a/wallaby.js +++ b/wallaby.js @@ -49,5 +49,6 @@ export default function wallaby () { '**/*.ts': file => file.content.replace(/\.ts/g, '.js'), }, workers: { restart: true, initial: 1, regular: 1 }, + runMode: 'onsave', }; }