test: refactor settings

This commit is contained in:
Ricardo Arturo Cabral Mejia 2022-10-20 19:25:54 -04:00
parent 59bf1a67fd
commit 46cd022598
No known key found for this signature in database
GPG Key ID: 5931EBF43A650245
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 packageJson from '../../package.json'
import { ISettings } from '../@types/settings'
import { IWebServerAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
export class WebServerAdapter extends EventEmitter implements IWebServerAdapter {
public constructor(
private readonly webServer: Server,
private readonly settings: () => ISettings,
) {
super()
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') {
const {
info: { name, description, pubkey, contact },
} = Settings
} = this.settings()
const relayInformationDocument = {
name,

View File

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

View File

@ -1,4 +1,5 @@
import { IncomingMessage, MessageType } from '../@types/messages'
import { createSettings } from './settings-factory'
import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler'
import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory'
import { EventMessageHandler } from '../handlers/event-message-handler'
@ -6,7 +7,6 @@ import { eventStrategyFactory } from './event-strategy-factory'
import { IEventRepository } from '../@types/repositories'
import { isDelegatedEvent } from '../utils/event'
import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
@ -17,13 +17,17 @@ export const messageHandlerFactory = (
case MessageType.EVENT:
{
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:
return new SubscribeMessageHandler(adapter, eventRepository)
return new SubscribeMessageHandler(adapter, eventRepository, createSettings)
case MessageType.CLOSE:
return new UnsubscribeMessageHandler(adapter,)
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(
protected readonly webSocket: 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> {
@ -49,7 +49,7 @@ export class EventMessageHandler implements IMessageHandler {
protected canAcceptEvent(event: Event): string | undefined {
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 (event.created_at > now + limits.createdAt.maxPositiveDelta) {
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 { Event } from '../@types/event'
import { IEventRepository } from '../@types/repositories'
import { ISettings } from '../@types/settings'
import { IWebSocketAdapter } from '../@types/adapters'
import { Settings } from '../utils/settings'
import { SubscribeMessage } from '../@types/messages'
import { WebSocketAdapterEvent } from '../constants/adapter'
@ -19,6 +19,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
private readonly settings: () => ISettings,
) {
this.abortController = new AbortController()
}
@ -66,7 +67,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
}
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) {
const subscriptions = this.webSocket.getSubscriptions()
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 (filters.length > 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 { WebSocketServer } from 'ws'
import { createSettings } from './factories/settings-factory'
import { EventRepository } from './repositories/event-repository'
import { getDbClient } from './database/client'
import packageJson from '../package.json'
@ -64,7 +65,8 @@ if (cluster.isPrimary) {
const adapter = new WebSocketServerAdapter(
server,
wss,
webSocketAdapterFactory(eventRepository)
webSocketAdapterFactory(eventRepository),
createSettings,
)
adapter.listen(port)

View File

@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from 'fs'
import fs from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import { mergeDeepRight } from 'ramda'
@ -6,85 +6,94 @@ import { mergeDeepRight } from 'ramda'
import { ISettings } from '../@types/settings'
import packageJson from '../../package.json'
export const getSettingsFilePath = (filename = 'settings.json'): string => join(
process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'),
filename,
)
export class SettingsStatic {
static _settings: ISettings
let _settings: ISettings
public static getSettingsFilePath(filename = 'settings.json') {
return join(
process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'),
filename
)
}
export const getDefaultSettings = (): ISettings => ({
info: {
relay_url: `wss://${packageJson.name}.your-domain.com`,
name: `${packageJson.name}.your-domain.com`,
description: packageJson.description,
pubkey: '',
contact: 'operator@your-domain.com',
},
limits: {
event: {
eventId: {
minLeadingZeroBits: 0,
public static getDefaultSettings(): ISettings {
return {
info: {
relay_url: 'wss://nostr-ts-relay.your-domain.com',
name: `${packageJson.name}.your-domain.com`,
description: packageJson.description,
pubkey: 'replace-with-your-pubkey',
contact: 'operator@your-domain.com',
},
kind: {
whitelist: [],
blacklist: [],
limits: {
event: {
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) => {
return JSON.parse(
readFileSync(
public static loadSettings(path: string) {
return JSON.parse(
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,
{ 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
JSON.stringify(settings, null, 2),
{ encoding: 'utf-8' }
)
}
}
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(
webSocket as any,
strategyFactoryStub,
{} as any,
() => ({}) as any,
)
})
@ -168,7 +168,7 @@ describe('EventMessageHandler', () => {
handler = new EventMessageHandler(
{} as any,
() => null,
settings,
() => settings,
)
})

View File

@ -1,11 +1,13 @@
import { expect } from 'chai'
import fs from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import Sinon from 'sinon'
import { getDefaultSettings, getSettingsFilePath } from '../../../src/utils/settings'
import { SettingsStatic } from '../../../src/utils/settings'
describe('Settings', () => {
describe('getSettingsFilePath', () => {
describe('SettingsStatic', () => {
describe('.getSettingsFilePath', () => {
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
@ -18,40 +20,39 @@ describe('Settings', () => {
})
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', () => {
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', () => {
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', () => {
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', () => {
expect(getDefaultSettings())
expect(SettingsStatic.getDefaultSettings())
.to.have.property('info')
.and.to.deep.equal({
relay_url: 'wss://nostr-ts-relay.your-domain.com',
name: 'nostr-ts-relay.your-domain.com',
description: 'A nostr relay written in Typescript.',
pubkey: '',
pubkey: 'replace-with-your-pubkey',
contact: 'operator@your-domain.com',
})
})
it('returns object with default limits', () => {
expect(getDefaultSettings())
expect(SettingsStatic.getDefaultSettings())
.to.have.property('limits')
.and.to.deep.equal({
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' }
)
})
})
})