diff --git a/src/measurement/schema/global-schema.ts b/src/measurement/schema/global-schema.ts index 760f1fe0..729dce08 100644 --- a/src/measurement/schema/global-schema.ts +++ b/src/measurement/schema/global-schema.ts @@ -13,7 +13,7 @@ export const schema = Joi.object({ type: Joi.string().valid('ping', 'traceroute', 'dns', 'mtr', 'http').insensitive().required(), target: targetSchema, measurementOptions: measurementSchema, - locations: Joi.alternatives().try(locationSchema, Joi.string()), + locations: locationSchema, limit: Joi.number().min(1).max(measurementConfig.limits.global).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 d009028e..d4854f5c 100644 --- a/src/measurement/schema/location-schema.ts +++ b/src/measurement/schema/location-schema.ts @@ -12,22 +12,25 @@ const measurementConfig = config.get<{limits: {global: number; location: number} const normalizeValue = (value: string): string => anyAscii(value); -export const schema = Joi.array().items(Joi.object().keys({ - continent: Joi.string().valid(...Object.keys(continents)).insensitive() - .messages({ 'any.only': 'The continent must be a valid two-letter continent code' }), - region: Joi.string().valid(...regionNames).insensitive(), - country: Joi.string().valid(...Object.keys(countries)).insensitive() - .messages({ 'any.only': 'The country must be a valid two-letter ISO code' }), - state: Joi.string().valid(...Object.values(states)).insensitive() - .messages({ 'any.only': 'The US state must be a valid two-letter code, e.g. CA' }), - city: Joi.string().min(1).max(128).lowercase().custom(normalizeValue), - network: Joi.string().min(1).max(128).lowercase().custom(normalizeValue), - asn: Joi.number().integer().positive(), - magic: Joi.string().min(1).lowercase().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'), { - 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), - }), -}).or('continent', 'region', 'country', 'state', 'city', 'network', 'asn', 'magic', 'tags')).default(GLOBAL_DEFAULTS.locations); +export const schema = Joi.alternatives().try( + Joi.string(), + Joi.array().items(Joi.object().keys({ + continent: Joi.string().valid(...Object.keys(continents)).insensitive() + .messages({ 'any.only': 'The continent must be a valid two-letter continent code' }), + region: Joi.string().valid(...regionNames).insensitive(), + country: Joi.string().valid(...Object.keys(countries)).insensitive() + .messages({ 'any.only': 'The country must be a valid two-letter ISO code' }), + state: Joi.string().valid(...Object.values(states)).insensitive() + .messages({ 'any.only': 'The US state must be a valid two-letter code, e.g. CA' }), + city: Joi.string().min(1).max(128).lowercase().custom(normalizeValue), + network: Joi.string().min(1).max(128).lowercase().custom(normalizeValue), + asn: Joi.number().integer().positive(), + magic: Joi.string().min(1).lowercase().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'), { + 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), + }), + }).or('continent', 'region', 'country', 'state', 'city', 'network', 'asn', 'magic', 'tags')), +).default(GLOBAL_DEFAULTS.locations); diff --git a/test/tests/integration/adoption-code/adoption-code.test.ts b/test/tests/integration/adoption-code.test.ts similarity index 95% rename from test/tests/integration/adoption-code/adoption-code.test.ts rename to test/tests/integration/adoption-code.test.ts index 902b655c..13ba3c17 100644 --- a/test/tests/integration/adoption-code/adoption-code.test.ts +++ b/test/tests/integration/adoption-code.test.ts @@ -3,8 +3,8 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import type { Socket } from 'socket.io-client'; import request from 'supertest'; -import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../../utils/server.js'; -import nockGeoIpProviders from '../../../utils/nock-geo-ip.js'; +import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../utils/server.js'; +import nockGeoIpProviders from '../../utils/nock-geo-ip.js'; let probe: Socket; const app = await getTestServer(); diff --git a/test/tests/integration/health/health.test.ts b/test/tests/integration/health.test.ts similarity index 96% rename from test/tests/integration/health/health.test.ts rename to test/tests/integration/health.test.ts index dcd9b517..f6e38b11 100644 --- a/test/tests/integration/health/health.test.ts +++ b/test/tests/integration/health.test.ts @@ -3,7 +3,7 @@ import process from 'node:process'; import { expect } from 'chai'; import * as sinon from 'sinon'; import request, { type SuperTest, type Test } from 'supertest'; -import { getTestServer } from '../../../utils/server.js'; +import { getTestServer } from '../../utils/server.js'; after(() => { process.removeAllListeners('SIGTERM'); diff --git a/test/tests/integration/middleware/ratelimit.test.ts b/test/tests/integration/ratelimit.test.ts similarity index 89% rename from test/tests/integration/middleware/ratelimit.test.ts rename to test/tests/integration/ratelimit.test.ts index 3e8ec9d8..4f26c571 100644 --- a/test/tests/integration/middleware/ratelimit.test.ts +++ b/test/tests/integration/ratelimit.test.ts @@ -4,8 +4,8 @@ import requestIp from 'request-ip'; import type { RateLimiterRedis } from 'rate-limiter-flexible'; import { expect } from 'chai'; import type { Socket } from 'socket.io-client'; -import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../../utils/server.js'; -import nockGeoIpProviders from '../../../utils/nock-geo-ip.js'; +import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../utils/server.js'; +import nockGeoIpProviders from '../../utils/nock-geo-ip.js'; describe('rate limiter', () => { let app: Server; @@ -24,7 +24,7 @@ describe('rate limiter', () => { // Koa sees ipv6-ipv4 monster clientIpv6 = `::ffff:${clientIp ?? '127.0.0.1'}`; - const rateLimiter = await import('../../../../src/lib/ratelimiter.js'); + const rateLimiter = await import('../../../src/lib/ratelimiter.js'); rateLimiterInstance = rateLimiter.default; nockGeoIpProviders(); @@ -43,7 +43,7 @@ describe('rate limiter', () => { describe('headers', () => { it('should NOT include headers (GET)', async () => { - const response = await requestAgent.get('/v1/').send() as Response; + const response = await requestAgent.get('/v1/').send().expect(200) as Response; expect(response.headers['x-ratelimit-limit']).to.not.exist; expect(response.headers['x-ratelimit-remaining']).to.not.exist; @@ -51,7 +51,7 @@ describe('rate limiter', () => { }); it('should NOT include headers if body is not valid (POST)', async () => { - const response = await requestAgent.post('/v1/measurements').send() as Response; + const response = await requestAgent.post('/v1/measurements').send().expect(400) as Response; expect(response.headers['x-ratelimit-limit']).to.not.exist; expect(response.headers['x-ratelimit-remaining']).to.not.exist; @@ -62,7 +62,7 @@ describe('rate limiter', () => { const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', target: 'jsdelivr.com', - }) as Response; + }).expect(202) as Response; expect(response.headers['x-ratelimit-limit']).to.exist; expect(response.headers['x-ratelimit-remaining']).to.exist; @@ -73,7 +73,7 @@ describe('rate limiter', () => { const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', target: 'jsdelivr.com', - }) as Response; + }).expect(202) as Response; expect(response.headers['x-ratelimit-limit']).to.equal('100000'); expect(response.headers['x-ratelimit-remaining']).to.equal('99999'); @@ -82,7 +82,7 @@ describe('rate limiter', () => { const response2 = await requestAgent.post('/v1/measurements').send({ type: 'ping', target: 'jsdelivr.com', - }) as Response; + }).expect(202) as Response; expect(response2.headers['x-ratelimit-limit']).to.equal('100000'); expect(response2.headers['x-ratelimit-remaining']).to.equal('99998'); @@ -97,7 +97,7 @@ describe('rate limiter', () => { const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', target: 'jsdelivr.com', - }) as Response; + }).expect(202) as Response; expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(99999); }); @@ -108,10 +108,9 @@ describe('rate limiter', () => { const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', target: 'jsdelivr.com', - }) as Response; + }).expect(429) as Response; expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(0); - expect(response.statusCode).to.equal(429); }); }); }); diff --git a/test/tests/unit/geoip/client.test.ts b/test/tests/unit/geoip/client.test.ts index 7f9623e9..30f70061 100644 --- a/test/tests/unit/geoip/client.test.ts +++ b/test/tests/unit/geoip/client.test.ts @@ -64,7 +64,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'SA', country: 'AR', - state: undefined, + state: null, city: 'Buenos Aires', region: 'South America', normalizedCity: 'buenos aires', @@ -89,7 +89,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'SA', country: 'AR', - state: undefined, + state: null, city: 'Buenos Aires', region: 'South America', normalizedCity: 'buenos aires', @@ -98,7 +98,7 @@ describe('geoip service', () => { longitude: -58.3772, network: 'InterBS S.R.L. (BAEHOST)', normalizedNetwork: 'interbs s.r.l. (baehost)', - isHosting: undefined, + isHosting: null, }); }); @@ -119,7 +119,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -140,7 +140,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -152,7 +152,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'SA', country: 'AR', - state: undefined, + state: null, city: 'Buenos Aires', region: 'South America', normalizedCity: 'buenos aires', @@ -161,7 +161,7 @@ describe('geoip service', () => { longitude: -58.3772, network: 'InterBS S.R.L. (BAEHOST)', normalizedNetwork: 'interbs s.r.l. (baehost)', - isHosting: undefined, + isHosting: null, }); }); @@ -173,7 +173,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'SA', country: 'AR', - state: undefined, + state: null, city: 'Buenos Aires', region: 'South America', normalizedCity: 'buenos aires', @@ -182,7 +182,7 @@ describe('geoip service', () => { longitude: -58.3772, network: 'InterBS S.R.L. (BAEHOST)', normalizedNetwork: 'interbs s.r.l. (baehost)', - isHosting: undefined, + isHosting: null, }); }); @@ -207,7 +207,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'AF', country: 'EG', - state: undefined, + state: null, city: 'El-Rashda', region: 'Northern Africa', normalizedCity: 'el-rashda', @@ -216,7 +216,7 @@ describe('geoip service', () => { longitude: 26.487, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -228,7 +228,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'EU', country: 'DE', - state: undefined, + state: null, city: 'Falkenstein', region: 'Western Europe', normalizedCity: 'falkenstein', @@ -237,7 +237,7 @@ describe('geoip service', () => { longitude: 12.371563, network: 'Hetzner Online GmbH', normalizedNetwork: 'hetzner online gmbh', - isHosting: undefined, + isHosting: null, }); }); @@ -249,7 +249,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'EU', country: 'DE', - state: undefined, + state: null, city: 'Zwickau', region: 'Western Europe', normalizedCity: 'zwickau', @@ -258,7 +258,7 @@ describe('geoip service', () => { longitude: 12.371563, network: 'Hetzner Online GmbH', normalizedNetwork: 'hetzner online gmbh', - isHosting: undefined, + isHosting: null, }); }); @@ -291,7 +291,7 @@ describe('geoip service', () => { expect(info).to.deep.equal({ continent: 'SA', country: 'AR', - state: undefined, + state: null, city: 'Buenos Aires', region: 'South America', normalizedCity: 'buenos aires', @@ -300,7 +300,7 @@ describe('geoip service', () => { longitude: -58.3772, network: 'InterBS S.R.L. (BAEHOST)', normalizedNetwork: 'interbs s.r.l. (baehost)', - isHosting: undefined, + isHosting: null, }); }); @@ -342,7 +342,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -364,7 +364,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -385,7 +385,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -406,7 +406,7 @@ describe('geoip service', () => { longitude: -96.8067, network: 'The Constant Company LLC', normalizedNetwork: 'the constant company llc', - isHosting: undefined, + isHosting: null, }); }); @@ -427,7 +427,7 @@ describe('geoip service', () => { longitude: -77.039476, network: 'Psychz Networks', normalizedNetwork: 'psychz networks', - isHosting: undefined, + isHosting: null, }); nockGeoIpProviders({ ip2location: 'empty', ipmap: 'empty', maxmind: 'empty', ipinfo: 'washington', fastly: 'empty' }); diff --git a/test/tests/unit/middleware/ratelimit.test.ts b/test/tests/unit/middleware/ratelimit.test.ts deleted file mode 100644 index ff81d70e..00000000 --- a/test/tests/unit/middleware/ratelimit.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import * as sinon from 'sinon'; -import { expect } from 'chai'; -import rateLimiter from '../../../../src/lib/ratelimiter.js'; -import { rateLimitHandler } from '../../../../src/lib/http/middleware/ratelimit.js'; -import createHttpError from 'http-errors'; - -describe('rate limit middleware', () => { - const defaultCtx: any = { - set: sinon.stub(), - req: {}, - request: { - body: {}, - }, - response: {}, - }; - let ctx = { ...defaultCtx }; - - beforeEach(async () => { - defaultCtx.set.reset(); - ctx = { ...defaultCtx }; - await rateLimiter.delete(''); - }); - - it('should set rate limit headers based on the "probesCount" field value', async () => { - ctx.request.body = { - limit: 10, - locations: [], - }; - - const next: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 5, - }; - }; - - await rateLimitHandler()(ctx, next); - - expect(ctx.set.callCount).to.equal(3); - expect(ctx.set.firstCall.args[0]).to.equal('X-RateLimit-Reset'); - expect(ctx.set.secondCall.args).to.deep.equal([ 'X-RateLimit-Limit', '100000' ]); - expect(ctx.set.thirdCall.args).to.deep.equal([ 'X-RateLimit-Remaining', '99995' ]); - }); - - it('should throw an error if response body doesn\'t have "probesCount" field', async () => { - ctx.request.body = { - limit: 10, - locations: [], - }; - - const next: any = () => { - ctx.response.body = {}; - }; - - const err = await rateLimitHandler()(ctx, next).catch(err => err); - expect(err).to.deep.equal(new Error('Missing probesCount field in response object')); - }); - - it('should NOT set rate limit headers for admin', async () => { - ctx.request.body = { - limit: 10, - locations: [], - }; - - ctx.isAdmin = true; - - const next: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 10, - }; - }; - - await rateLimitHandler()(ctx, next); - expect(ctx.set.callCount).to.equal(0); - }); - - it('should validate request based on the "limit" field value', async () => { - ctx.request.body = { - limit: 60000, - locations: [], - }; - - const next: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 60000, - }; - }; - - await rateLimitHandler()(ctx, next); - expect(ctx.set.args[2]).to.deep.equal([ 'X-RateLimit-Remaining', '40000' ]); - - const err = await rateLimitHandler()(ctx, next).catch(err => err); // 60000 > 40000 so another request with the same body fails - expect(err).to.deep.equal(createHttpError(429, 'API rate limit exceeded.', { type: 'rate_limit_exceeded' })); - expect(ctx.set.args[5]).to.deep.equal([ 'X-RateLimit-Remaining', '40000' ]); - - ctx.request.body = { - limit: 40000, - locations: [], - }; - - const next2: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 40000, - }; - }; - - await rateLimitHandler()(ctx, next2); // 40000 === 40000 so request with the updated body works - expect(ctx.set.args[8]).to.deep.equal([ 'X-RateLimit-Remaining', '0' ]); - }); - - it('should validate request based on the "location.limit" field value', async () => { - ctx.request.body = { - locations: [{ - continent: 'EU', - limit: 45000, - }, { - continent: 'NA', - limit: 45000, - }], - }; - - const next: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 90000, - }; - }; - - await rateLimitHandler()(ctx, next); - expect(ctx.set.args[2]).to.deep.equal([ 'X-RateLimit-Remaining', '10000' ]); - - const err = await rateLimitHandler()(ctx, next).catch(err => err); // only 10000 points remaining so another request with the same body fails - expect(err).to.deep.equal(createHttpError(429, 'API rate limit exceeded.', { type: 'rate_limit_exceeded' })); - expect(ctx.set.args[5]).to.deep.equal([ 'X-RateLimit-Remaining', '10000' ]); - - ctx.request.body = { - locations: [{ - continent: 'EU', - limit: 5000, - }, { - continent: 'NA', - limit: 5000, - }], - }; - - const next2: any = () => { - ctx.response.body = { - id: 'id', - probesCount: 10000, - }; - }; - - await rateLimitHandler()(ctx, next2); // request with 10000 probes will work fine - expect(ctx.set.args[8]).to.deep.equal([ 'X-RateLimit-Remaining', '0' ]); - }); -}); diff --git a/test/tests/unit/probe/router.test.ts b/test/tests/unit/probe/router.test.ts index 52b351f2..7bffde5f 100644 --- a/test/tests/unit/probe/router.test.ts +++ b/test/tests/unit/probe/router.test.ts @@ -13,7 +13,7 @@ import { getRegionByCountry } from '../../../../src/lib/location/location.js'; const defaultLocation = { continent: '', country: 'PL', - state: undefined, + state: null, city: '', region: '', normalizedCity: '', @@ -436,7 +436,7 @@ describe('probe router', () => { continent: 'EU', region: getRegionByCountry('GB'), country: 'GB', - state: undefined, + state: null, city: 'London', normalizedCity: 'london', asn: 5089, @@ -777,7 +777,7 @@ describe('probe router', () => { continent: 'EU', region: getRegionByCountry('GB'), country: 'GB', - state: undefined, + state: null, city: 'london', asn: 5089, network: 'a-virgin media', diff --git a/test/tests/unit/ws/fetch-sockets.test.ts b/test/tests/unit/ws/fetch-sockets.test.ts index 6da9ccca..433a0121 100644 --- a/test/tests/unit/ws/fetch-sockets.test.ts +++ b/test/tests/unit/ws/fetch-sockets.test.ts @@ -40,7 +40,7 @@ describe('fetchSockets', () => { continent: 'EU', region: 'Western Europe', country: 'FR', - state: undefined, + state: null, city: 'Paris', normalizedCity: 'paris', asn: 12876, @@ -97,7 +97,7 @@ describe('fetchSockets', () => { continent: 'EU', region: 'Western Europe', country: 'FR', - state: undefined, + state: null, city: 'Marseille', normalizedCity: 'marseille', asn: 12876, @@ -124,7 +124,7 @@ describe('fetchSockets', () => { continent: 'EU', region: 'Western Europe', country: 'FR', - state: undefined, + state: null, city: 'Marseille', normalizedCity: 'marseille', asn: 12876, diff --git a/test/utils/server.ts b/test/utils/server.ts index 801eae97..d79597cb 100644 --- a/test/utils/server.ts +++ b/test/utils/server.ts @@ -2,11 +2,14 @@ import _ from 'lodash'; import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { io, type Socket } from 'socket.io-client'; +import { scopedLogger } from '../../src/lib/logger.js'; import { createServer } from '../../src/lib/server.js'; let app: Server; let url: string; +const logger = scopedLogger('test-server'); + export const getTestServer = async (): Promise => { if (!app) { app = await createServer(); @@ -37,6 +40,8 @@ export const addFakeProbe = async (events: object = {}, options: object = {}): P client.on(event, listener); } + client.on('connect_error', (error: Error) => logger.error(error)); + client.on('connect', () => { resolve(client); });