test: refactor settings

This commit is contained in:
Ricardo Arturo Cabral Mejia
2022-10-20 19:25:54 -04:00
parent 59bf1a67fd
commit 46cd022598
11 changed files with 299 additions and 110 deletions

View File

@@ -2,13 +2,14 @@ import { Duplex, EventEmitter } from 'stream'
import { IncomingMessage, Server, ServerResponse } from 'http' import { IncomingMessage, Server, ServerResponse } from 'http'
import packageJson from '../../package.json' import packageJson from '../../package.json'
import { ISettings } from '../@types/settings'
import { IWebServerAdapter } from '../@types/adapters' import { IWebServerAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter { export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
public constructor( public constructor(
private readonly webServer: Server, private readonly webServer: Server,
private readonly settings: () => ISettings,
) { ) {
super() super()
this.webServer.on('request', this.onWebServerRequest.bind(this)) this.webServer.on('request', this.onWebServerRequest.bind(this))
@@ -25,7 +26,7 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
if (request.method === 'GET' && request.headers['accept'] === 'application/nostr+json') { if (request.method === 'GET' && request.headers['accept'] === 'application/nostr+json') {
const { const {
info: { name, description, pubkey, contact }, info: { name, description, pubkey, contact },
} = Settings } = this.settings()
const relayInformationDocument = { const relayInformationDocument = {
name, name,

View File

@@ -5,6 +5,7 @@ import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter' import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
import { Event } from '../@types/event' import { Event } from '../@types/event'
import { Factory } from '../@types/base' import { Factory } from '../@types/base'
import { ISettings } from '../@types/settings'
import { propEq } from 'ramda' import { propEq } from 'ramda'
import { WebServerAdapter } from './web-server-adapter' import { WebServerAdapter } from './web-server-adapter'
@@ -22,9 +23,10 @@ export class WebSocketServerAdapter extends WebServerAdapter implements IWebSock
private readonly createWebSocketAdapter: Factory< private readonly createWebSocketAdapter: Factory<
IWebSocketAdapter, IWebSocketAdapter,
[WebSocket, IncomingMessage, IWebSocketServerAdapter] [WebSocket, IncomingMessage, IWebSocketServerAdapter]
> >,
settings: () => ISettings,
) { ) {
super(webServer) super(webServer, settings)
this.webSocketsAdapters = new WeakMap() this.webSocketsAdapters = new WeakMap()

View File

@@ -1,4 +1,5 @@
import { IncomingMessage, MessageType } from '../@types/messages' import { IncomingMessage, MessageType } from '../@types/messages'
import { createSettings } from './settings-factory'
import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler' import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler'
import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory' import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory'
import { EventMessageHandler } from '../handlers/event-message-handler' import { EventMessageHandler } from '../handlers/event-message-handler'
@@ -6,7 +7,6 @@ import { eventStrategyFactory } from './event-strategy-factory'
import { IEventRepository } from '../@types/repositories' import { IEventRepository } from '../@types/repositories'
import { isDelegatedEvent } from '../utils/event' import { isDelegatedEvent } from '../utils/event'
import { IWebSocketAdapter } from '../@types/adapters' import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler' import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler' import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
@@ -17,13 +17,17 @@ export const messageHandlerFactory = (
case MessageType.EVENT: case MessageType.EVENT:
{ {
if (isDelegatedEvent(message[1])) { if (isDelegatedEvent(message[1])) {
return new DelegatedEventMessageHandler(adapter, delegatedEventStrategyFactory(eventRepository), Settings) return new DelegatedEventMessageHandler(
adapter,
delegatedEventStrategyFactory(eventRepository),
createSettings
)
} }
return new EventMessageHandler(adapter, eventStrategyFactory(eventRepository), Settings) return new EventMessageHandler(adapter, eventStrategyFactory(eventRepository), createSettings)
} }
case MessageType.REQ: case MessageType.REQ:
return new SubscribeMessageHandler(adapter, eventRepository) return new SubscribeMessageHandler(adapter, eventRepository, createSettings)
case MessageType.CLOSE: case MessageType.CLOSE:
return new UnsubscribeMessageHandler(adapter,) return new UnsubscribeMessageHandler(adapter,)
default: default:

View File

@@ -0,0 +1,4 @@
import { ISettings } from '../@types/settings'
import { SettingsStatic } from '../utils/settings'
export const createSettings = (): ISettings => SettingsStatic.createSettings()

View File

@@ -13,7 +13,7 @@ export class EventMessageHandler implements IMessageHandler {
public constructor( public constructor(
protected readonly webSocket: IWebSocketAdapter, protected readonly webSocket: IWebSocketAdapter,
protected readonly strategyFactory: Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]>, protected readonly strategyFactory: Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]>,
private readonly settings: ISettings private readonly settings: () => ISettings
) { } ) { }
public async handleMessage(message: IncomingEventMessage): Promise<void> { public async handleMessage(message: IncomingEventMessage): Promise<void> {
@@ -49,7 +49,7 @@ export class EventMessageHandler implements IMessageHandler {
protected canAcceptEvent(event: Event): string | undefined { protected canAcceptEvent(event: Event): string | undefined {
const now = Math.floor(Date.now()/1000) const now = Math.floor(Date.now()/1000)
const limits = this.settings.limits.event const limits = this.settings().limits.event
if (limits.createdAt.maxPositiveDelta > 0) { if (limits.createdAt.maxPositiveDelta > 0) {
if (event.created_at > now + limits.createdAt.maxPositiveDelta) { if (event.created_at > now + limits.createdAt.maxPositiveDelta) {
return `created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future` return `created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`

View File

@@ -8,8 +8,8 @@ import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { Event } from '../@types/event' import { Event } from '../@types/event'
import { IEventRepository } from '../@types/repositories' import { IEventRepository } from '../@types/repositories'
import { ISettings } from '../@types/settings'
import { IWebSocketAdapter } from '../@types/adapters' import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
import { SubscribeMessage } from '../@types/messages' import { SubscribeMessage } from '../@types/messages'
import { WebSocketAdapterEvent } from '../constants/adapter' import { WebSocketAdapterEvent } from '../constants/adapter'
@@ -19,6 +19,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
public constructor( public constructor(
private readonly webSocket: IWebSocketAdapter, private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository, private readonly eventRepository: IEventRepository,
private readonly settings: () => ISettings,
) { ) {
this.abortController = new AbortController() this.abortController = new AbortController()
} }
@@ -66,7 +67,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
} }
private canSubscribe(subscriptionId: string, filters: SubscriptionFilter[]): string | undefined { private canSubscribe(subscriptionId: string, filters: SubscriptionFilter[]): string | undefined {
const maxSubscriptions = Settings.limits.client.subscription.maxSubscriptions const maxSubscriptions = this.settings().limits.client.subscription.maxSubscriptions
if (maxSubscriptions > 0) { if (maxSubscriptions > 0) {
const subscriptions = this.webSocket.getSubscriptions() const subscriptions = this.webSocket.getSubscriptions()
if (!subscriptions.has(subscriptionId) && subscriptions.size + 1 > maxSubscriptions) { if (!subscriptions.has(subscriptionId) && subscriptions.size + 1 > maxSubscriptions) {
@@ -74,7 +75,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
} }
} }
const maxFilters = Settings.limits.client.subscription.maxFilters const maxFilters = this.settings().limits.client.subscription.maxFilters
if (maxFilters > 0) { if (maxFilters > 0) {
if (filters.length > maxFilters) { if (filters.length > maxFilters) {
return `Too many filters: Number of filters per susbscription must be less or equal to ${maxFilters}` return `Too many filters: Number of filters per susbscription must be less or equal to ${maxFilters}`

View File

@@ -4,6 +4,7 @@ import http from 'http'
import process from 'process' import process from 'process'
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { createSettings } from './factories/settings-factory'
import { EventRepository } from './repositories/event-repository' import { EventRepository } from './repositories/event-repository'
import { getDbClient } from './database/client' import { getDbClient } from './database/client'
import packageJson from '../package.json' import packageJson from '../package.json'
@@ -64,7 +65,8 @@ if (cluster.isPrimary) {
const adapter = new WebSocketServerAdapter( const adapter = new WebSocketServerAdapter(
server, server,
wss, wss,
webSocketAdapterFactory(eventRepository) webSocketAdapterFactory(eventRepository),
createSettings,
) )
adapter.listen(port) adapter.listen(port)

View File

@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from 'fs' import fs from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import { mergeDeepRight } from 'ramda' import { mergeDeepRight } from 'ramda'
@@ -6,85 +6,94 @@ import { mergeDeepRight } from 'ramda'
import { ISettings } from '../@types/settings' import { ISettings } from '../@types/settings'
import packageJson from '../../package.json' import packageJson from '../../package.json'
export const getSettingsFilePath = (filename = 'settings.json'): string => join( export class SettingsStatic {
process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'), static _settings: ISettings
filename,
)
let _settings: ISettings public static getSettingsFilePath(filename = 'settings.json') {
return join(
process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'),
filename
)
}
export const getDefaultSettings = (): ISettings => ({ public static getDefaultSettings(): ISettings {
info: { return {
relay_url: `wss://${packageJson.name}.your-domain.com`, info: {
name: `${packageJson.name}.your-domain.com`, relay_url: 'wss://nostr-ts-relay.your-domain.com',
description: packageJson.description, name: `${packageJson.name}.your-domain.com`,
pubkey: '', description: packageJson.description,
contact: 'operator@your-domain.com', pubkey: 'replace-with-your-pubkey',
}, contact: 'operator@your-domain.com',
limits: {
event: {
eventId: {
minLeadingZeroBits: 0,
}, },
kind: { limits: {
whitelist: [], event: {
blacklist: [], eventId: {
minLeadingZeroBits: 0,
},
kind: {
whitelist: [],
blacklist: [],
},
pubkey: {
minLeadingZeroBits: 0,
whitelist: [],
blacklist: [],
},
createdAt: {
maxPositiveDelta: 900,
maxNegativeDelta: 0, // disabled
},
},
client: {
subscription: {
maxSubscriptions: 10,
maxFilters: 10,
},
},
}, },
pubkey: { }
minLeadingZeroBits: 0, }
whitelist: [],
blacklist: [],
},
createdAt: {
maxPositiveDelta: 900, // +15 min
maxNegativeDelta: 0, // disabled
},
},
client: {
subscription: {
maxSubscriptions: 10,
maxFilters: 10,
},
},
},
})
const loadSettings = (path: string) => { public static loadSettings(path: string) {
return JSON.parse( return JSON.parse(
readFileSync( fs.readFileSync(
path,
{ encoding: 'utf-8' }
)
)
}
public static createSettings(): ISettings {
if (SettingsStatic._settings) {
return SettingsStatic._settings
}
const path = SettingsStatic.getSettingsFilePath()
const defaults = SettingsStatic.getDefaultSettings()
try {
if (fs.existsSync(path)) {
SettingsStatic._settings = mergeDeepRight(
defaults,
SettingsStatic.loadSettings(path)
)
} else {
SettingsStatic.saveSettings(path, defaults)
SettingsStatic._settings = mergeDeepRight({}, defaults)
}
return SettingsStatic._settings
} catch (error) {
console.error('Unable to read config file. Reason: %s', error.message)
return defaults
}
}
public static saveSettings(path: string, settings: ISettings) {
return fs.writeFileSync(
path, path,
{ encoding: 'utf-8' }, JSON.stringify(settings, null, 2),
), { encoding: 'utf-8' }
) )
}
const createSettings = (): ISettings => {
const path = getSettingsFilePath()
const defaults = getDefaultSettings()
try {
if (_settings) {
return _settings
}
if (!existsSync(path)) {
saveSettings(path, defaults)
}
_settings = mergeDeepRight(defaults, loadSettings(path))
return _settings
} catch (error) {
console.error('Unable to read config file. Reason: %s', error.message)
return defaults
} }
} }
export const saveSettings = (path: string, settings: ISettings) => {
return writeFileSync(
path,
JSON.stringify(settings, null, 2),
{ encoding: 'utf-8' }
)
}
export const Settings = createSettings()

View File

@@ -0,0 +1,24 @@
import { expect } from 'chai'
import Sinon from 'sinon'
import { createSettings } from '../../../src/factories/settings-factory'
import { SettingsStatic } from '../../../src/utils/settings'
describe('getSettings', () => {
let createSettingsStub: Sinon.SinonStub
beforeEach(() => {
createSettingsStub = Sinon.stub(SettingsStatic, 'createSettings')
})
afterEach(() => {
createSettingsStub.restore()
})
it('calls createSettings and returns', () => {
const settings = Symbol()
createSettingsStub.returns(settings)
expect(createSettings()).to.equal(settings)
})
})

View File

@@ -64,7 +64,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler( handler = new EventMessageHandler(
webSocket as any, webSocket as any,
strategyFactoryStub, strategyFactoryStub,
{} as any, () => ({}) as any,
) )
}) })
@@ -168,7 +168,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler( handler = new EventMessageHandler(
{} as any, {} as any,
() => null, () => null,
settings, () => settings,
) )
}) })

View File

@@ -1,11 +1,13 @@
import { expect } from 'chai' import { expect } from 'chai'
import fs from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import Sinon from 'sinon'
import { getDefaultSettings, getSettingsFilePath } from '../../../src/utils/settings' import { SettingsStatic } from '../../../src/utils/settings'
describe('Settings', () => { describe('SettingsStatic', () => {
describe('getSettingsFilePath', () => { describe('.getSettingsFilePath', () => {
let originalEnv: NodeJS.ProcessEnv let originalEnv: NodeJS.ProcessEnv
beforeEach(() => { beforeEach(() => {
@@ -18,40 +20,39 @@ describe('Settings', () => {
}) })
it('returns string ending with settings.json by default', () => { it('returns string ending with settings.json by default', () => {
expect(getSettingsFilePath()).to.be.a('string').and.to.match(/settings\.json$/) expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.to.match(/settings\.json$/)
}) })
it('returns string ending with given string', () => { it('returns string ending with given string', () => {
expect(getSettingsFilePath('ending')).to.be.a('string').and.to.match(/ending$/) expect(SettingsStatic.getSettingsFilePath('ending')).to.be.a('string').and.to.match(/ending$/)
}) })
it('returns path begins with user\'s home dir by default', () => { it('returns path begins with user\'s home dir by default', () => {
expect(getSettingsFilePath()).to.be.a('string').and.equal(`${join(homedir(), '.nostr')}/settings.json`) expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.equal(`${join(homedir(), '.nostr')}/settings.json`)
}) })
it('returns path with NOSTR_CONFIG_DIR if set', () => { it('returns path with NOSTR_CONFIG_DIR if set', () => {
process.env.NOSTR_CONFIG_DIR = '/some/path' process.env.NOSTR_CONFIG_DIR = '/some/path'
expect(getSettingsFilePath()).to.be.a('string').and.equal('/some/path/settings.json') expect(SettingsStatic.getSettingsFilePath()).to.be.a('string').and.equal('/some/path/settings.json')
}) })
}) })
describe('getDefaultSettings', () => { describe('.getDefaultSettings', () => {
it('returns object with info', () => { it('returns object with info', () => {
expect(getDefaultSettings()) expect(SettingsStatic.getDefaultSettings())
.to.have.property('info') .to.have.property('info')
.and.to.deep.equal({ .and.to.deep.equal({
relay_url: 'wss://nostr-ts-relay.your-domain.com', relay_url: 'wss://nostr-ts-relay.your-domain.com',
name: 'nostr-ts-relay.your-domain.com', name: 'nostr-ts-relay.your-domain.com',
description: 'A nostr relay written in Typescript.', description: 'A nostr relay written in Typescript.',
pubkey: '', pubkey: 'replace-with-your-pubkey',
contact: 'operator@your-domain.com', contact: 'operator@your-domain.com',
}) })
}) })
it('returns object with default limits', () => { it('returns object with default limits', () => {
expect(getDefaultSettings()) expect(SettingsStatic.getDefaultSettings())
.to.have.property('limits') .to.have.property('limits')
.and.to.deep.equal({ .and.to.deep.equal({
event: { event: {
@@ -82,15 +83,156 @@ describe('Settings', () => {
}) })
}) })
// describe('loadSettings', () => { describe('.loadSettings', () => {
let readFileSyncStub: Sinon.SinonStub
// }) beforeEach(() => {
readFileSyncStub = Sinon.stub(fs, 'readFileSync')
})
// describe('createSettings', () => { afterEach(() => {
readFileSyncStub.restore()
})
// }) it('loads settings from given path', () => {
readFileSyncStub.returns('"content"')
// describe('saveSettings', () => { expect(SettingsStatic.loadSettings('/some/path')).to.equal('content')
// }) expect(readFileSyncStub).to.have.been.calledOnceWithExactly(
}) '/some/path',
{ encoding: 'utf-8' }
)
})
})
describe('.createSettings', () => {
let existsSyncStub: Sinon.SinonStub
let getSettingsFilePathStub: Sinon.SinonStub
let getDefaultSettingsStub: Sinon.SinonStub
let saveSettingsStub: Sinon.SinonStub
let loadSettingsStub: Sinon.SinonStub
let sandbox: Sinon.SinonSandbox
beforeEach(() => {
SettingsStatic._settings = undefined
sandbox = Sinon.createSandbox()
existsSyncStub = sandbox.stub(fs, 'existsSync')
getSettingsFilePathStub = sandbox.stub(SettingsStatic, 'getSettingsFilePath')
getDefaultSettingsStub = sandbox.stub(SettingsStatic, 'getDefaultSettings')
saveSettingsStub = sandbox.stub(SettingsStatic, 'saveSettings')
loadSettingsStub = sandbox.stub(SettingsStatic, 'loadSettings')
})
afterEach(() => {
sandbox.restore()
})
it('creates settings from default if settings file is missing', () => {
getDefaultSettingsStub.returns({})
getSettingsFilePathStub.returns('/some/path/settings.json')
existsSyncStub.returns(false)
expect(SettingsStatic.createSettings()).to.deep.equal({})
expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
expect(getSettingsFilePathStub).to.have.been.calledOnce
expect(getDefaultSettingsStub).to.have.been.calledOnce
expect(saveSettingsStub).to.have.been.calledOnceWithExactly(
'/some/path/settings.json',
{},
)
expect(loadSettingsStub).not.to.have.been.called
})
it('returns default settings if saving settings file throws', () => {
const error = new Error('mistakes were made')
const defaults = Symbol()
getSettingsFilePathStub.returns('/some/path/settings.json')
getDefaultSettingsStub.returns(defaults)
saveSettingsStub.throws(error)
existsSyncStub.returns(false)
expect(SettingsStatic.createSettings()).to.equal(defaults)
expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
expect(getSettingsFilePathStub).to.have.been.calledOnce
expect(getDefaultSettingsStub).to.have.been.calledOnce
expect(saveSettingsStub).to.have.been.calledOnceWithExactly(
'/some/path/settings.json',
defaults,
)
expect(loadSettingsStub).not.to.have.been.called
})
it('loads settings from file if settings file is exists', () => {
getDefaultSettingsStub.returns({})
loadSettingsStub.returns({})
getSettingsFilePathStub.returns('/some/path/settings.json')
existsSyncStub.returns(true)
expect(SettingsStatic.createSettings()).to.deep.equal({})
expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
expect(getSettingsFilePathStub).to.have.been.calledOnce
expect(getDefaultSettingsStub).to.have.been.calledOnce
expect(saveSettingsStub).not.to.have.been.called
expect(loadSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
})
it('returns defaults if loading settings file throws', () => {
const defaults = Symbol()
const error = new Error('mistakes were made')
getDefaultSettingsStub.returns(defaults)
loadSettingsStub.throws(error)
getSettingsFilePathStub.returns('/some/path/settings.json')
existsSyncStub.returns(true)
expect(SettingsStatic.createSettings()).to.equal(defaults)
expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
expect(getSettingsFilePathStub).to.have.been.calledOnce
expect(getDefaultSettingsStub).to.have.been.calledOnce
expect(saveSettingsStub).not.to.have.been.called
expect(loadSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.json')
})
it('returns cached settings if set', () => {
const cachedSettings = Symbol()
SettingsStatic._settings = cachedSettings as any
expect(SettingsStatic.createSettings()).to.equal(cachedSettings)
expect(getSettingsFilePathStub).not.to.have.been.calledOnce
expect(getDefaultSettingsStub).not.to.have.been.calledOnce
expect(existsSyncStub).not.to.have.been.called
expect(saveSettingsStub).not.to.have.been.called
expect(loadSettingsStub).not.to.have.been.called
})
})
describe('.saveSettings', () => {
let writeFileSyncStub: Sinon.SinonStub
beforeEach(() => {
writeFileSyncStub = Sinon.stub(fs, 'writeFileSync')
})
afterEach(() => {
writeFileSyncStub.restore()
})
it('saves settings to given path', () => {
SettingsStatic.saveSettings('/some/path/settings.json', {key: 'value'} as any)
expect(writeFileSyncStub).to.have.been.calledOnceWithExactly(
'/some/path/settings.json',
'{\n "key": "value"\n}',
{ encoding: 'utf-8' }
)
})
})
})