diff --git a/settings.sample.json b/settings.sample.json index 7e9c3ee..5a58f74 100644 --- a/settings.sample.json +++ b/settings.sample.json @@ -23,13 +23,53 @@ "createdAt": { "maxPositiveDelta": 900, "maxNegativeDelta": 0 - } + }, + "rateLimits": [ + { + "kinds": [[0, 5], 7, [40, 49], [10000, 19999], [30000, 39999]], + "period": 60000, + "rate": 60 + }, + { + "kinds": [[20000, 29999]], + "period": 60000, + "rate": 600 + }, + { + "period": 3600000, + "rate": 3600 + }, + { + "period": 86400000, + "rate": 86400 + } + ] }, "client": { "subscription": { "maxSubscriptions": 10, "maxFilters": 10 } + }, + "message": { + "rateLimits": [ + { + "period": 60000, + "rate": 600 + }, + { + "period": 3600000, + "rate": 3600 + }, + { + "period": 86400000, + "rate": 86400 + } + ], + "ipWhitelist": [ + "::1", + "::ffff:10.10.10.1" + ] } } } \ No newline at end of file diff --git a/src/@types/cache.ts b/src/@types/cache.ts index 644a40c..2c22454 100644 --- a/src/@types/cache.ts +++ b/src/@types/cache.ts @@ -5,4 +5,4 @@ import { RedisScripts, } from 'redis' -export type Cache = RedisClientType +export type CacheClient = RedisClientType diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 2aa9b5b..63c8dd4 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -1,4 +1,4 @@ -import { Cache } from '../@types/cache' +import { CacheClient } from '../@types/cache' import { createLogger } from '../factories/logger-factory' import { ICacheAdapter } from '../@types/adapters' @@ -7,7 +7,7 @@ const debug = createLogger('redis-adapter') export class RedisAdapter implements ICacheAdapter { private connection: Promise - public constructor(private readonly client: Cache) { + public constructor(private readonly client: CacheClient) { this.connection = client.connect() this.connection.catch((error) => this.onClientError(error)) diff --git a/src/cache/client.ts b/src/cache/client.ts index 5ce1cca..7a19fff 100644 --- a/src/cache/client.ts +++ b/src/cache/client.ts @@ -1,15 +1,19 @@ import { createClient, RedisClientOptions } from 'redis' -import { Cache } from '../@types/cache' +import { CacheClient } from '../@types/cache' export const getCacheConfig = (): RedisClientOptions => ({ url: `redis://${process.env.REDIS_USER}:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, password: process.env.REDIS_PASSWORD, }) -export const getCacheClient = (): Cache => { - const config = getCacheConfig() +let instance: CacheClient = undefined - const client = createClient(config) +export const getCacheClient = (): CacheClient => { + if (!instance) { + const config = getCacheConfig() - return client + instance = createClient(config) + } + + return instance } diff --git a/src/constants/base.ts b/src/constants/base.ts index f5cee9f..3397d25 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -6,10 +6,21 @@ export enum EventKinds { ENCRYPTED_DIRECT_MESSAGE = 4, DELETE = 5, REACTION = 7, + // Channels + CHANNEL_CREATION = 40, + CHANNEL_METADATA = 41, + CHANNEL_MESSAGE = 42, + CHANNEL_HIDE_MESSAGE = 43, + CHANNEL_MUTE_USER = 44, + CHANNEL_RESERVED_FIRST = 45, + CHANNEL_RESERVED_LAST = 49, + // Replaceable events REPLACEABLE_FIRST = 10000, REPLACEABLE_LAST = 19999, + // Ephemeral events EPHEMERAL_FIRST = 20000, EPHEMERAL_LAST = 29999, + // Parameterized replaceable events PARAMETERIZED_REPLACEABLE_FIRST = 30000, PARAMETERIZED_REPLACEABLE_LAST = 39999, } diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 9038f9a..9c43323 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -129,7 +129,7 @@ export class EventMessageHandler implements IMessageHandler { } protected async isRateLimited(event: Event): Promise { - const rateLimits = this.settings().limits.event?.rateLimits + const rateLimits = this.settings().limits?.event?.rateLimits if (!rateLimits || !rateLimits.length) { return } diff --git a/src/handlers/event-strategies/delete-event-strategy.ts b/src/handlers/event-strategies/delete-event-strategy.ts index 64d1a08..b6b5944 100644 --- a/src/handlers/event-strategies/delete-event-strategy.ts +++ b/src/handlers/event-strategies/delete-event-strategy.ts @@ -21,7 +21,7 @@ export class DeleteEventStrategy implements IEventStrategy> this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, (count) ? '' : 'duplicate:')) const ids = event.tags.reduce( - (eventIds, tag) => (tag.length >= 2 && tag[0] === EventTags.Event && tag[1].length === 64) + (eventIds, tag) => (tag.length >= 2 && tag[0] === EventTags.Event) ? [...eventIds, tag[1]] : eventIds, [] as string[] diff --git a/src/utils/messages.ts b/src/utils/messages.ts index c92c7da..6f5e8af 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -27,6 +27,6 @@ export const createEndOfStoredEventsNoticeMessage = ( } // NIP-20 -export const createCommandResult = (eventId: EventId, successful: boolean, message = '') => { +export const createCommandResult = (eventId: EventId, successful: boolean, message: string) => { return [MessageType.OK, eventId, successful, message] } diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 6d1bee3..9d609da 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -52,14 +52,20 @@ export class SettingsStatic { }, rateLimits: [ { - kinds: [EventKinds.TEXT_NOTE], + kinds: [ + [EventKinds.SET_METADATA, EventKinds.DELETE], + EventKinds.REACTION, + [EventKinds.CHANNEL_CREATION, EventKinds.CHANNEL_RESERVED_LAST], + [EventKinds.REPLACEABLE_FIRST, EventKinds.REPLACEABLE_LAST], + [EventKinds.PARAMETERIZED_REPLACEABLE_FIRST, EventKinds.PARAMETERIZED_REPLACEABLE_LAST], + ], period: 60000, rate: 60, }, { kinds: [[EventKinds.EPHEMERAL_FIRST, EventKinds.EPHEMERAL_LAST]], period: 60000, - rate: 240, + rate: 600, }, { period: 3600000, @@ -80,15 +86,15 @@ export class SettingsStatic { message: { rateLimits: [ { - period: 60000, // minute - rate: 240, + period: 60000, + rate: 600, }, { - period: 3600000, // hour + period: 3600000, rate: 3600, }, { - period: 86400000, // day + period: 86400000, rate: 86400, }, ], diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index b27b382..7f678c0 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -223,3 +223,28 @@ export async function waitForNotice(ws: WebSocket): Promise { ws.on('message', onMessage) }) } + +export async function waitForCommand(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + function cleanup() { + ws.removeListener('message', onMessage) + ws.removeListener('error', onError) + } + + function onError(error: Error) { + reject(error) + cleanup() + } + ws.once('error', onError) + + function onMessage(raw: RawData) { + const message = JSON.parse(raw.toString('utf8')) + if (message[0] === MessageType.OK) { + resolve(message) + cleanup() + } + } + + ws.on('message', onMessage) + }) +} diff --git a/test/integration/features/nip-01/nip-01.feature b/test/integration/features/nip-01/nip-01.feature index 1bf3074..b58006a 100644 --- a/test/integration/features/nip-01/nip-01.feature +++ b/test/integration/features/nip-01/nip-01.feature @@ -27,7 +27,7 @@ Feature: NIP-01 Scenario: Alice can't post a text_note event with an invalid signature Given someone called Alice When Alice sends a text_note event with invalid signature - Then Alice receives a notice with invalid signature + Then Alice receives an unsuccessful result Scenario: Alice and Bob exchange text_note events Given someone called Alice diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index 4c07e77..5f71d59 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -11,6 +11,7 @@ import { createEvent, createSubscription, sendEvent, + waitForCommand, waitForEOSE, waitForEventCount, waitForNextEvent, @@ -237,3 +238,10 @@ Then(/(\w+) receives a notice with (.*)/, async function(name: string, pattern: expect(actualNotice).to.contain(pattern) }) + +Then(/(\w+) receives an? (\w+) result/, async function(name: string, successful: string) { + const ws = this.parameters.clients[name] as WebSocket + const command = await waitForCommand(ws) + + expect(command[2]).to.equal(successful === 'successful') +}) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 82a3fe0..6e2115e 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -12,7 +12,9 @@ import WebSocket from 'ws' import { connect, createIdentity, createSubscription } from './helpers' import { AppWorker } from '../../../src/app/worker' +import { CacheClient } from '../../../src/@types/cache' import { DatabaseClient } from '../../../src/@types/base' +import { getCacheClient } from '../../../src/cache/client' import { getDbClient } from '../../../src/database/client' import { SettingsStatic } from '../../../src/utils/settings' import { workerFactory } from '../../../src/factories/worker-factory' @@ -20,9 +22,11 @@ import { workerFactory } from '../../../src/factories/worker-factory' let worker: AppWorker let dbClient: DatabaseClient +let cacheClient: CacheClient BeforeAll({ timeout: 6000 }, async function () { process.env.PORT = '18808' + cacheClient = getCacheClient() dbClient = getDbClient() await dbClient.raw('SELECT 1=1') @@ -35,7 +39,10 @@ BeforeAll({ timeout: 6000 }, async function () { AfterAll(async function() { worker.close(async () => { - await dbClient.destroy() + await Promise.all([ + cacheClient.disconnect(), + dbClient.destroy(), + ]) }) }) diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index c654902..7f0ada3 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -30,6 +30,9 @@ describe('webSocketAdapterFactory', () => { headers: { 'sec-websocket-key': Buffer.from('key', 'utf8').toString('base64'), }, + socket: { + remoteAddress: '::1', + }, } as any const webSocketServerAdapter: IWebSocketServerAdapter = {} as any diff --git a/test/unit/handlers/delegated-event-message-handler.spec.ts b/test/unit/handlers/delegated-event-message-handler.spec.ts index 1db7fe3..a5b9bf4 100644 --- a/test/unit/handlers/delegated-event-message-handler.spec.ts +++ b/test/unit/handlers/delegated-event-message-handler.spec.ts @@ -50,6 +50,7 @@ describe('DelegatedEventMessageHandler', () => { let strategyFactoryStub: Sinon.SinonStub let onMessageSpy: Sinon.SinonSpy let strategyExecuteStub: Sinon.SinonStub + let isRateLimitedStub: Sinon.SinonStub beforeEach(() => { canAcceptEventStub = sandbox.stub(DelegatedEventMessageHandler.prototype, 'canAcceptEvent' as any) @@ -62,10 +63,12 @@ describe('DelegatedEventMessageHandler', () => { webSocket = new EventEmitter() webSocket.on(WebSocketAdapterEvent.Message, onMessageSpy) message = [MessageType.EVENT, event] + isRateLimitedStub = sandbox.stub(EventMessageHandler.prototype, 'isRateLimited' as any) handler = new DelegatedEventMessageHandler( webSocket as any, strategyFactoryStub, () => ({}) as any, + () => ({ hit: async () => false }), ) }) @@ -81,7 +84,14 @@ describe('DelegatedEventMessageHandler', () => { await handler.handleMessage(message) expect(canAcceptEventStub).to.have.been.calledOnceWithExactly(event) - expect(onMessageSpy).to.have.been.calledOnceWithExactly(['NOTICE', 'Event rejected: reason']) + expect(onMessageSpy).to.have.been.calledOnceWithExactly( + [ + MessageType.OK, + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + false, + 'reason', + ], + ) expect(strategyFactoryStub).not.to.have.been.called }) @@ -95,6 +105,18 @@ describe('DelegatedEventMessageHandler', () => { expect(strategyFactoryStub).not.to.have.been.called }) + it('rejects event if rate-limited', async () => { + isRateLimitedStub.resolves(true) + + await handler.handleMessage(message) + + expect(isRateLimitedStub).to.have.been.calledOnceWithExactly(event) + expect(onMessageSpy).to.have.been.calledOnceWithExactly( + [MessageType.OK, event.id, false, 'rate-limited: slow down'], + ) + expect(strategyFactoryStub).not.to.have.been.called + }) + it('does not call strategy if none given', async () => { isEventValidStub.returns(undefined) canAcceptEventStub.returns(undefined) @@ -177,7 +199,7 @@ describe('DelegatedEventMessageHandler', () => { parentIsEventValidStub.resolves(undefined) event.tags[0][3] = 'wrong sig' - return expect((handler as any).isEventValid(event)).to.eventually.equal('Event with id a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc from 62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49 is invalid delegated event') + return expect((handler as any).isEventValid(event)).to.eventually.equal('invalid: delegation verification failed') }) }) }) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 8a0e7c3..6bdb691 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -1,6 +1,6 @@ import EventEmitter from 'events' -import Sinon, { SinonFakeTimers } from 'sinon' +import Sinon, { SinonFakeTimers, SinonStub } from 'sinon' import chai from 'chai' import chaiAsPromised from 'chai-as-promised' @@ -49,6 +49,7 @@ describe('EventMessageHandler', () => { let strategyFactoryStub: Sinon.SinonStub let onMessageSpy: Sinon.SinonSpy let strategyExecuteStub: Sinon.SinonStub + let isRateLimitedStub: Sinon.SinonStub beforeEach(() => { canAcceptEventStub = sandbox.stub(EventMessageHandler.prototype, 'canAcceptEvent' as any) @@ -61,10 +62,12 @@ describe('EventMessageHandler', () => { webSocket = new EventEmitter() webSocket.on(WebSocketAdapterEvent.Message, onMessageSpy) message = [MessageType.EVENT, event] + isRateLimitedStub = sandbox.stub(EventMessageHandler.prototype, 'isRateLimited' as any) handler = new EventMessageHandler( webSocket as any, strategyFactoryStub, () => ({}) as any, + () => ({ hit: async () => false }) ) }) @@ -80,7 +83,21 @@ describe('EventMessageHandler', () => { await handler.handleMessage(message) expect(canAcceptEventStub).to.have.been.calledOnceWithExactly(event) - expect(onMessageSpy).to.have.been.calledOnceWithExactly(['NOTICE', 'Event rejected: reason']) + expect(onMessageSpy).to.have.been.calledOnceWithExactly( + [MessageType.OK, event.id, false, 'reason'], + ) + expect(strategyFactoryStub).not.to.have.been.called + }) + + it('rejects event if rate-limited', async () => { + isRateLimitedStub.resolves(true) + + await handler.handleMessage(message) + + expect(isRateLimitedStub).to.have.been.calledOnceWithExactly(event) + expect(onMessageSpy).to.have.been.calledOnceWithExactly( + [MessageType.OK, event.id, false, 'rate-limited: slow down'], + ) expect(strategyFactoryStub).not.to.have.been.called }) @@ -128,6 +145,7 @@ describe('EventMessageHandler', () => { it('does not reject if strategy rejects', async () => { isEventValidStub.returns(undefined) canAcceptEventStub.returns(undefined) + strategyExecuteStub.rejects() return expect(handler.handleMessage(message)).to.eventually.be.fulfilled @@ -169,6 +187,7 @@ describe('EventMessageHandler', () => { {} as any, () => null, () => settings, + () => ({ hit: async () => false }) ) }) @@ -192,7 +211,7 @@ describe('EventMessageHandler', () => { expect( (handler as any).canAcceptEvent(event) - ).to.equal('created_at is more than 100 seconds in the future') + ).to.equal('rejected: created_at is more than 100 seconds in the future') }) }) @@ -211,7 +230,7 @@ describe('EventMessageHandler', () => { expect( (handler as any).canAcceptEvent(event) - ).to.equal('created_at is more than 100 seconds in the past') + ).to.equal('rejected: created_at is more than 100 seconds in the past') }) }) }) @@ -237,7 +256,7 @@ describe('EventMessageHandler', () => { event.id = '00' + 'f'.repeat(62) expect( (handler as any).canAcceptEvent(event) - ).to.equal('insufficient proof of work: event Id has less than 16 leading zero bits') + ).to.equal('pow: difficulty 8<16') }) }) }) @@ -263,7 +282,7 @@ describe('EventMessageHandler', () => { event.pubkey = '0'.repeat(2) + 'f'.repeat(62) expect( (handler as any).canAcceptEvent(event) - ).to.equal('insufficient proof of work: pubkey has less than 16 leading zero bits') + ).to.equal('pow: pubkey difficulty 8<16') }) }) @@ -296,7 +315,7 @@ describe('EventMessageHandler', () => { event.pubkey = 'aabbcc' expect( (handler as any).canAcceptEvent(event) - ).to.equal('pubkey aabbcc is not allowed') + ).to.equal('blocked: pubkey not allowed') }) it('returns reason if pubkey is blacklisted by prefix', () => { @@ -304,7 +323,7 @@ describe('EventMessageHandler', () => { event.pubkey = 'aa55ccddeeff' expect( (handler as any).canAcceptEvent(event) - ).to.equal('pubkey aa55ccddeeff is not allowed') + ).to.equal('blocked: pubkey not allowed') }) }) @@ -337,7 +356,7 @@ describe('EventMessageHandler', () => { event.pubkey = 'aabbcc' expect( (handler as any).canAcceptEvent(event) - ).to.equal('pubkey aabbcc is not allowed') + ).to.equal('blocked: pubkey not allowed') }) it('returns reason if pubkey is not whitelisted by prefix', () => { @@ -345,7 +364,7 @@ describe('EventMessageHandler', () => { event.pubkey = 'aabbccddeeff' expect( (handler as any).canAcceptEvent(event) - ).to.equal('pubkey aabbccddeeff is not allowed') + ).to.equal('blocked: pubkey not allowed') }) }) }) @@ -380,7 +399,7 @@ describe('EventMessageHandler', () => { event.kind = 4 expect( (handler as any).canAcceptEvent(event) - ).to.equal('event kind 4 is not allowed') + ).to.equal('blocked: event kind 4 not allowed') }) }) @@ -414,7 +433,7 @@ describe('EventMessageHandler', () => { event.kind = 3 expect( (handler as any).canAcceptEvent(event) - ).to.equal('event kind 3 is not allowed') + ).to.equal('blocked: event kind 3 not allowed') }) it('returns reason if kind is blacklisted and whitelisted', () => { @@ -423,7 +442,7 @@ describe('EventMessageHandler', () => { event.kind = 3 expect( (handler as any).canAcceptEvent(event) - ).to.equal('event kind 3 is not allowed') + ).to.equal('blocked: event kind 3 not allowed') }) it('returns reason if kind is not whitelisted', () => { @@ -431,7 +450,7 @@ describe('EventMessageHandler', () => { event.kind = 4 expect( (handler as any).canAcceptEvent(event) - ).to.equal('event kind 4 is not allowed') + ).to.equal('blocked: event kind 4 not allowed') }) it('returns reason if kind is not whitelisted in range', () => { @@ -439,7 +458,7 @@ describe('EventMessageHandler', () => { event.kind = 6 expect( (handler as any).canAcceptEvent(event) - ).to.equal('event kind 6 is not allowed') + ).to.equal('blocked: event kind 6 not allowed') }) }) }) @@ -464,12 +483,148 @@ describe('EventMessageHandler', () => { it('returns reason if event id is not valid', () => { event.id = 'wrong' - return expect((handler as any).isEventValid(event)).to.eventually.equal('Event with id wrong from 55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503 is not valid') + return expect((handler as any).isEventValid(event)).to.eventually.equal('invalid: event id does not match') }) it('returns reason if event signature is not valid', () => { event.sig = 'wrong' - return expect((handler as any).isEventValid(event)).to.eventually.equal('Event with id e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a from 55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503 has invalid signature') + return expect((handler as any).isEventValid(event)).to.eventually.equal('invalid: event signature verification failed') + }) + }) + + describe('isRateLimited', () => { + let eventLimits: EventLimits + let settings: ISettings + let rateLimiterHitStub: SinonStub + + beforeEach(() => { + eventLimits = { + rateLimits: [], + } + settings = { + limits: { + event: eventLimits, + }, + } as any + rateLimiterHitStub = sandbox.stub() + handler = new EventMessageHandler( + {} as any, + () => null, + () => settings, + () => ({ hit: rateLimiterHitStub }) + ) + }) + + it('returns undefined if rate limits setting is not set', async () => { + eventLimits.rateLimits = undefined + return expect((handler as any).isRateLimited(event)).to.eventually.be.undefined + }) + + it('returns undefined if rate limits setting is empty', async () => { + eventLimits.rateLimits = [] + return expect((handler as any).isRateLimited(event)).to.eventually.be.undefined + }) + + it('calls hit with given rate limit settings', async () => { + eventLimits.rateLimits = [ + { + period: 60000, + rate: 1, + }, + { + kinds: [0], + period: 60000, + rate: 2, + }, + { + kinds: [[10, 20]], + period: 86400000, + rate: 3, + }, + ] + + await (handler as any).isRateLimited(event) + + expect(rateLimiterHitStub).to.have.been.calledThrice + expect(rateLimiterHitStub.firstCall).to.have.been.calledWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000', + 1, + { + period: 60000, + rate: 1, + } + ) + expect(rateLimiterHitStub.secondCall).to.have.been.calledWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:60000:[0]', + 1, + { + period: 60000, + rate: 2, + } + ) + expect(rateLimiterHitStub.thirdCall).to.have.been.calledWithExactly( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:events:86400000:[[10,20]]', + 1, + { + period: 86400000, + rate: 3, + } + ) + }) + + + it('fulfills with false if not rate limited', async () => { + eventLimits.rateLimits = [ + { + period: 60000, + rate: 1, + }, + { + kinds: [0], + period: 60000, + rate: 2, + }, + { + kinds: [[10, 20]], + period: 86400000, + rate: 3, + }, + ] + + rateLimiterHitStub.resolves(false) + + const actualResult = await (handler as any).isRateLimited(event) + + expect(rateLimiterHitStub).to.have.been.calledThrice + expect(actualResult).to.be.false + }) + + it('fulfills with true if rate limited by second rate limit setting', async () => { + eventLimits.rateLimits = [ + { + period: 60000, + rate: 1, + }, + { + kinds: [0], + period: 60000, + rate: 2, + }, + { + kinds: [[10, 20]], + period: 86400000, + rate: 3, + }, + ] + + rateLimiterHitStub.onFirstCall().resolves(false) + rateLimiterHitStub.onSecondCall().resolves(true) + rateLimiterHitStub.onThirdCall().resolves(false) + + const actualResult = await (handler as any).isRateLimited(event) + + expect(rateLimiterHitStub).to.have.been.calledThrice + expect(actualResult).to.be.true }) }) }) diff --git a/test/unit/handlers/event-strategies/default-event-strategy.spec.ts b/test/unit/handlers/event-strategies/default-event-strategy.spec.ts index c232870..cd88258 100644 --- a/test/unit/handlers/event-strategies/default-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/default-event-strategy.spec.ts @@ -11,12 +11,15 @@ import { EventRepository } from '../../../../src/repositories/event-repository' import { IEventRepository } from '../../../../src/@types/repositories' import { IEventStrategy } from '../../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' const { expect } = chai describe('DefaultEventStrategy', () => { - const event: Event = {} as any + const event: Event = { + id: 'id', + } as any let webSocket: IWebSocketAdapter let eventRepository: IEventRepository @@ -59,12 +62,29 @@ describe('DefaultEventStrategy', () => { await strategy.execute(event) expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) - expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, ''] + ) + expect(webSocketEmitStub).to.have.been.calledWithExactly( WebSocketAdapterEvent.Broadcast, event ) }) + it('does not broadcast event if event is duplicate', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + ['OK', 'id', true, 'duplicate:'] + ) + }) + it('rejects if unable to create event', async () => { const error = new Error() eventRepositoryCreateStub.rejects(error) diff --git a/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts index 4cb3133..636f687 100644 --- a/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts @@ -12,12 +12,14 @@ import { EventTags } from '../../../../src/constants/base' import { IEventRepository } from '../../../../src/@types/repositories' import { IEventStrategy } from '../../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' const { expect } = chai describe('DeleteEventStrategy', () => { const event: Event = { + id: 'id', pubkey: 'pubkey', tags: [ [EventTags.Event, 'event id 1'], @@ -79,18 +81,34 @@ describe('DeleteEventStrategy', () => { expect(eventRepositoryDeleteByPubkeyAndIdsStub).not.to.have.been.called }) - it('broadcast event', async () => { - eventRepositoryCreateStub.resolves() + it('broadcast event if created', async () => { + eventRepositoryCreateStub.resolves(1) await strategy.execute(event) expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) - expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, ''] + ) + expect(webSocketEmitStub).to.have.been.calledWithExactly( WebSocketAdapterEvent.Broadcast, event ) }) + it('does not broadcast event if duplicate', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, 'duplicate:'] + ) + }) + it('rejects if unable to create event', async () => { const error = new Error() eventRepositoryCreateStub.rejects(error) diff --git a/test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts b/test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts index 0405574..5a73351 100644 --- a/test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/parameterized-replaceable-event-strategy.spec.ts @@ -11,6 +11,7 @@ import { EventRepository } from '../../../../src/repositories/event-repository' import { IEventRepository } from '../../../../src/@types/repositories' import { IEventStrategy } from '../../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' import { ParameterizedReplaceableEventStrategy } from '../../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' @@ -18,6 +19,7 @@ const { expect } = chai describe('ParameterizedReplaceableEventStrategy', () => { const event: Event = { + id: 'id', tags: [ [EventTags.Deduplication, 'dedup'], ], @@ -82,12 +84,28 @@ describe('ParameterizedReplaceableEventStrategy', () => { await strategy.execute(event) expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) - expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, ''] + ) + expect(webSocketEmitStub).to.have.been.calledWithExactly( WebSocketAdapterEvent.Broadcast, event ) }) + it('does not broadcast event if event is duplicate', async () => { + eventRepositoryUpsertStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, 'duplicate:'] + ) + }) + it('rejects if unable to upsert event', async () => { const error = new Error() eventRepositoryUpsertStub.rejects(error) diff --git a/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts index af1ef27..c373197 100644 --- a/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts @@ -10,13 +10,16 @@ import { EventRepository } from '../../../../src/repositories/event-repository' import { IEventRepository } from '../../../../src/@types/repositories' import { IEventStrategy } from '../../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' import { ReplaceableEventStrategy } from '../../../../src/handlers/event-strategies/replaceable-event-strategy' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' const { expect } = chai describe('ReplaceableEventStrategy', () => { - const event: Event = {} as any + const event: Event = { + id: 'id', + } as any let webSocket: IWebSocketAdapter let eventRepository: IEventRepository @@ -59,12 +62,29 @@ describe('ReplaceableEventStrategy', () => { await strategy.execute(event) expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) - expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, 'id', true, ''] + ) + expect(webSocketEmitStub).to.have.been.calledWithExactly( WebSocketAdapterEvent.Broadcast, event ) }) + it('does not broadcast event if event is duplicate', async () => { + eventRepositoryUpsertStub.resolves(0) + + await strategy.execute(event) + + expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + ['OK', 'id', true, 'duplicate:'] + ) + }) + it('rejects if unable to upsert event', async () => { const error = new Error() eventRepositoryUpsertStub.rejects(error) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index 688e062..481c167 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -72,6 +72,26 @@ describe('SettingsStatic', () => { maxPositiveDelta: 900, // +15 min maxNegativeDelta: 0, // disabled }, + 'rateLimits': [ + { + 'kinds': [[0, 5], 7, [40, 49], [10000, 19999], [30000, 39999]], + 'period': 60000, + 'rate': 60, + }, + { + 'kinds': [[20000, 29999]], + 'period': 60000, + 'rate': 600, + }, + { + 'period': 3600000, + 'rate': 3600, + }, + { + 'period': 86400000, + 'rate': 86400, + }, + ], }, client: { subscription: { @@ -80,9 +100,20 @@ describe('SettingsStatic', () => { }, }, message: { - dailyRate: 86400, - hourlyRate: 3600, - minutelyRate: 240, + 'rateLimits': [ + { + 'period': 60000, + 'rate': 600, + }, + { + 'period': 3600000, + 'rate': 3600, + }, + { + 'period': 86400000, + 'rate': 86400, + }, + ], ipWhitelist: [ '::1', '::ffff:10.10.10.1', diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts new file mode 100644 index 0000000..bee0068 --- /dev/null +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { IRateLimiter } from '../../../src/@types/utils' +import { SlidingWindowRateLimiter } from '../../../src/utils/sliding-window-rate-limiter' + +describe('SlidingWindowRateLimiter', () => { + let clock: Sinon.SinonFakeTimers + let cache: ICacheAdapter + let rateLimiter: IRateLimiter + + let removeRangeByScoreFromSortedSetStub: Sinon.SinonStub + let addToSortedSetStub: Sinon.SinonStub + let getRangeFromSortedSetStub: Sinon.SinonStub + let setKeyExpiryStub: Sinon.SinonStub + + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + clock = sandbox.useFakeTimers(1665546189000) + removeRangeByScoreFromSortedSetStub = sandbox.stub() + addToSortedSetStub = sandbox.stub() + getRangeFromSortedSetStub = sandbox.stub() + setKeyExpiryStub = sandbox.stub() + cache = { + removeRangeByScoreFromSortedSet: removeRangeByScoreFromSortedSetStub, + addToSortedSet: addToSortedSetStub, + getRangeFromSortedSet: getRangeFromSortedSetStub, + setKeyExpiry: setKeyExpiryStub, + } + rateLimiter = new SlidingWindowRateLimiter(cache) + }) + + afterEach(() => { + clock.restore() + sandbox.restore() + }) + + it('returns true if rate limited', async () => { + const now = Date.now() + getRangeFromSortedSetStub.resolves([ + `${now}:6`, + `${now}:4`, + `${now}:1`, + ]) + + const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) + + expect(actualResult).to.be.true + }) + + it('returns false if not rate limited',async () => { + const now = Date.now() + getRangeFromSortedSetStub.resolves([ + `${now}:10`, + ]) + + const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) + + expect(actualResult).to.be.false + }) +})