test: message/event rate limiting

This commit is contained in:
Ricardo Arturo Cabral Mejía 2022-11-15 15:02:12 -05:00
parent 760cb387bf
commit b36b62520e
22 changed files with 502 additions and 50 deletions

View File

@ -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"
]
}
}
}

View File

@ -5,4 +5,4 @@ import {
RedisScripts,
} from 'redis'
export type Cache = RedisClientType<RedisModules, RedisFunctions, RedisScripts>
export type CacheClient = RedisClientType<RedisModules, RedisFunctions, RedisScripts>

View File

@ -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<void>
public constructor(private readonly client: Cache) {
public constructor(private readonly client: CacheClient) {
this.connection = client.connect()
this.connection.catch((error) => this.onClientError(error))

14
src/cache/client.ts vendored
View File

@ -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
}

View File

@ -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,
}

View File

@ -129,7 +129,7 @@ export class EventMessageHandler implements IMessageHandler {
}
protected async isRateLimited(event: Event): Promise<boolean> {
const rateLimits = this.settings().limits.event?.rateLimits
const rateLimits = this.settings().limits?.event?.rateLimits
if (!rateLimits || !rateLimits.length) {
return
}

View File

@ -21,7 +21,7 @@ export class DeleteEventStrategy implements IEventStrategy<Event, Promise<void>>
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[]

View File

@ -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]
}

View File

@ -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,
},
],

View File

@ -223,3 +223,28 @@ export async function waitForNotice(ws: WebSocket): Promise<void> {
ws.on('message', onMessage)
})
}
export async function waitForCommand(ws: WebSocket): Promise<any> {
return new Promise<void>((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)
})
}

View File

@ -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

View File

@ -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')
})

View File

@ -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(),
])
})
})

View File

@ -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

View File

@ -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')
})
})
})

View File

@ -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
})
})
})

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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',

View File

@ -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
})
})