diff --git a/.changeset/settings-db-overrides.md b/.changeset/settings-db-overrides.md new file mode 100644 index 000000000..466c634e3 --- /dev/null +++ b/.changeset/settings-db-overrides.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: store relay settings overrides in PostgreSQL when SETTINGS_BACKEND=db diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c5490a2a0..11b9dcc86 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -65,6 +65,7 @@ The following environment variables can be set: | ADMIN_DEPENDENCY_PING_TIMEOUT_MS | Timeout for admin DB/Redis dependency pings (ms) | 3000 | | GRAFANA_URL | Grafana base URL for admin dashboard embeds | http://127.0.0.1:7777 | | NOSTR_CONFIG_DIR | Configuration directory | /.nostr/ | +| SETTINGS_BACKEND | Where operator overrides are stored: `file` (default) or `db` | file | | MONITOR_PRIVATE_KEY | Hex-encoded private key for the NIP-66 monitor identity that will sign kind 30166/10166 events. Configure via environment variable, not settings.yaml. | | | DEBUG | Debugging filter | | | ZEBEDEE_API_KEY | Zebedee Project API Key | | diff --git a/migrations/20260905_120000_create_settings_overrides_table.js b/migrations/20260905_120000_create_settings_overrides_table.js new file mode 100644 index 000000000..e91fe7711 --- /dev/null +++ b/migrations/20260905_120000_create_settings_overrides_table.js @@ -0,0 +1,11 @@ +exports.up = async function (knex) { + await knex.schema.createTable('settings_overrides', (table) => { + table.text('path').primary() + table.jsonb('value').notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('settings_overrides') +} diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index c851ff7ce..5d61520a2 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -6,7 +6,6 @@ import { loadMergedSettings, loadUserSettings, parseTypedValue, - saveSettings, setByPath, toCategoryLabel, validatePathAgainstDefaults, @@ -21,6 +20,7 @@ import { validateEnvPair, validateEnvValues, } from '../utils/env-config' +import { bootstrapSettingsStore, saveUserSettingsOverrides } from '../../utils/settings-store' import { logError, logInfo } from '../utils/output' import { runStart } from './start' import { runStop } from './stop' @@ -77,6 +77,7 @@ const restartRelay = async (): Promise => { } export const runConfigList = async (options: { json?: boolean } = {}): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() if (options.json) { @@ -89,6 +90,7 @@ export const runConfigList = async (options: { json?: boolean } = {}): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() as unknown as Record const value = getByPath(settings, path) @@ -122,6 +124,8 @@ export const runConfigSet = async ( ): Promise => { const valueType = options.valueType ?? 'inferred' + await bootstrapSettingsStore() + const pathIssues = validatePathAgainstDefaults(path) if (pathIssues.length > 0) { logError(pathIssues[0].message) @@ -146,7 +150,7 @@ export const runConfigSet = async ( } } - saveSettings(next as any) + await saveUserSettingsOverrides(next as any) logInfo(`Updated ${path}`) @@ -158,6 +162,7 @@ export const runConfigSet = async ( } export const runConfigValidate = async (): Promise => { + await bootstrapSettingsStore() const settings = loadMergedSettings() const issues = validateSettings(settings) diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts index 2e3545d49..fdfbd83a1 100644 --- a/src/controllers/admin/patch-notifications-controller.ts +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -17,9 +17,9 @@ import { loadDefaults, loadMergedSettings, loadUserSettings, - saveSettings, validateSettings, } from '../../utils/settings-config' +import { saveUserSettingsOverrides } from '../../utils/settings-store' import { validateSchema } from '../../utils/validation' import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' @@ -55,7 +55,7 @@ export class PatchAdminNotificationsController implements IController { return } - saveSettings(nextUserSettings) + await saveUserSettingsOverrides(nextUserSettings) appendSettingsAuditLog({ action: 'settings.updated', changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index f67eb099e..1829090f7 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -8,11 +8,11 @@ import { getByPath, loadMergedSettings, loadUserSettings, - saveSettings, setByPath, validatePathAgainstDefaults, validateSettings, } from '../../utils/settings-config' +import { saveUserSettingsOverrides } from '../../utils/settings-store' import { isWriteProtectedSettingsPath, redactSettingsValue, @@ -70,7 +70,7 @@ export class PatchAdminSettingsController implements IController { return } - saveSettings(nextUserSettings as unknown as Settings) + await saveUserSettingsOverrides(nextUserSettings as unknown as Settings) const updatedChanges = changes.map(({ path }) => ({ path, value: redactSettingsValue(path, getByPath(nextUserSettings, path)), diff --git a/src/controllers/admin/post-settings-restore-controller.ts b/src/controllers/admin/post-settings-restore-controller.ts index aded18e40..1bb3c65e9 100644 --- a/src/controllers/admin/post-settings-restore-controller.ts +++ b/src/controllers/admin/post-settings-restore-controller.ts @@ -15,7 +15,7 @@ export class PostAdminSettingsRestoreController implements IController { } try { - restoreSettingsBackup(validation.value.filename) + await restoreSettingsBackup(validation.value.filename) appendSettingsAuditLog({ action: 'settings.restored', filename: validation.value.filename, diff --git a/src/index.ts b/src/index.ts index b909447ca..4adb7c62d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,15 @@ import cluster from 'cluster' import { appFactory } from './factories/app-factory' +import { createLogger } from './factories/logger-factory' import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory' import { workerFactory } from './factories/worker-factory' import { initializeMetricsTelemetry } from './telemetry/metrics' +import { bootstrapSettingsStore } from './utils/settings-store' + +const logger = createLogger('index') export const getRunner = () => { if (cluster.isPrimary) { @@ -29,5 +33,12 @@ export const getRunner = () => { if (require.main === module) { initializeMetricsTelemetry() - getRunner().run() + void bootstrapSettingsStore() + .then(() => { + getRunner().run() + }) + .catch((error) => { + logger.error('failed to bootstrap settings store:', error) + process.exit(1) + }) } diff --git a/src/repositories/settings-override-repository.ts b/src/repositories/settings-override-repository.ts new file mode 100644 index 000000000..a52016e20 --- /dev/null +++ b/src/repositories/settings-override-repository.ts @@ -0,0 +1,44 @@ +import { DatabaseClient } from '../@types/base' +import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' +import { flattenSettingsToPaths, setByPath } from '../utils/settings-config' + +const logger = createLogger('settings-override-repository') + +export class SettingsOverrideRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async loadOverrides(): Promise { + const rows = await this.dbClient('settings_overrides').select('path', 'value') + + let settings: Record = {} + for (const row of rows) { + settings = setByPath(settings, row.path, row.value) + } + + return settings as unknown as Settings + } + + public async replaceOverrides(overrides: Settings): Promise { + const entries = flattenSettingsToPaths(overrides) + + await this.dbClient.transaction(async (transaction) => { + await transaction('settings_overrides').delete() + + if (entries.length === 0) { + return + } + + const now = new Date() + await transaction('settings_overrides').insert( + entries.map((entry) => ({ + path: entry.path, + value: JSON.stringify(entry.value), + updated_at: now, + })), + ) + }) + + logger('replaced %d settings override paths', entries.length) + } +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index de7e94a9a..882b5dd13 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -72,6 +72,49 @@ const hasOwn = (object: Record, key: string): boolean => { return Object.prototype.hasOwnProperty.call(object, key) } +const QUOTED_PATH_KEY = /^\["((?:\\.|[^"\\])*)"\]$/ + +export const formatSettingsPathKey = (key: string): string => { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key + } + + return `["${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` +} + +const tokenizePathSegments = (input: string): string[] => { + const segments: string[] = [] + let index = 0 + + while (index < input.length) { + if (input[index] === '.') { + index += 1 + continue + } + + if (input[index] === '[') { + const closing = input.indexOf('"]', index + 1) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + + segments.push(input.slice(index, closing + 2)) + index = closing + 2 + continue + } + + let next = index + while (next < input.length && input[next] !== '.') { + next += 1 + } + + segments.push(input.slice(index, next)) + index = next + } + + return segments.filter(Boolean) +} + const parsePath = (path: string): PathToken[] => { const input = path.trim() @@ -80,11 +123,14 @@ const parsePath = (path: string): PathToken[] => { } const tokens: PathToken[] = [] - const segments = input.split('.').map((part) => part.trim()) - for (const segment of segments) { - if (!segment) { - throw new Error(`Invalid path segment in: ${path}`) + for (const segment of tokenizePathSegments(input)) { + const quoted = segment.match(QUOTED_PATH_KEY) + if (quoted) { + const key = quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + assertSafePathKey(key, segment) + tokens.push({ type: 'key', key }) + continue } const match = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$/) @@ -114,7 +160,8 @@ const formatPathTokens = (tokens: PathToken[]): string => { for (const token of tokens) { if (token.type === 'key') { - out = out ? `${out}.${token.key}` : token.key + const formatted = formatSettingsPathKey(token.key) + out = out ? `${out}.${formatted}` : formatted continue } @@ -316,7 +363,7 @@ export const loadDefaults = (): Settings => { return yaml.load(defaultsRaw) as Settings } -export const loadUserSettings = (): Settings => { +export const loadUserSettingsFromFiles = (): Settings => { ensureSettingsExists() const settingsPath = getSettingsFilePath() @@ -336,6 +383,17 @@ export const loadUserSettings = (): Settings => { return {} as Settings } +export const loadUserSettings = (): Settings => { + if (process.env.SETTINGS_BACKEND === 'db') { + // Lazy import avoids a circular dependency with settings-store bootstrap. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getDbOverridesCache } = require('./settings-store') as typeof import('./settings-store') + return getDbOverridesCache() + } + + return loadUserSettingsFromFiles() +} + export const loadMergedSettings = (): Settings => { return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings } @@ -379,6 +437,49 @@ export const filterSettingsAgainstDefaults = (settings: unknown, defaults: unkno return settings } +export const flattenSettingsToPaths = ( + value: unknown, + prefix = '', +): Array<{ path: string; value: unknown }> => { + if (Array.isArray(value)) { + if (value.length === 0 && prefix) { + return [{ path: prefix, value: [] }] + } + + return value.flatMap((entry, index) => { + const nextPrefix = prefix ? `${prefix}[${index}]` : `[${index}]` + return flattenSettingsToPaths(entry, nextPrefix) + }) + } + + if (isPlainObject(value)) { + const entries = Object.entries(value) + if (entries.length === 0 && prefix) { + return [{ path: prefix, value: {} }] + } + + return entries.flatMap(([key, entry]) => { + const keySegment = formatSettingsPathKey(key) + const nextPrefix = prefix + ? keySegment.startsWith('[') + ? `${prefix}${keySegment}` + : `${prefix}.${keySegment}` + : keySegment + if (isPlainObject(entry) || Array.isArray(entry)) { + return flattenSettingsToPaths(entry, nextPrefix) + } + + return [{ path: nextPrefix, value: entry }] + }) + } + + if (prefix) { + return [{ path: prefix, value }] + } + + return [] +} + export const saveSettings = (settings: Settings): void => { ensureSettingsExists() const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) @@ -418,7 +519,23 @@ export type SettingsBackupInfo = { } const isSafeBackupFilename = (filename: string): boolean => { - return /^settings\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) + return /^settings(?:-db)?\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) +} + +export const backupUserSettingsOverrides = (overrides: Settings): string => { + ensureSettingsExists() + const backupDirectory = getSettingsBackupDir() + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `settings-db.${timestamp}.yaml` + + fs.mkdirSync(backupDirectory, { recursive: true }) + fs.writeFileSync( + join(backupDirectory, filename), + yaml.dump(toSerializable(overrides), { lineWidth: 120 }), + { encoding: 'utf-8', mode: 0o600 }, + ) + + return filename } export const listSettingsBackups = (): SettingsBackupInfo[] => { @@ -441,7 +558,7 @@ export const listSettingsBackups = (): SettingsBackupInfo[] => { .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) } -export const restoreSettingsBackup = (filename: string): Settings => { +const loadValidatedSettingsBackup = (filename: string): Settings => { if (!isSafeBackupFilename(filename)) { throw new Error('Invalid backup filename') } @@ -457,7 +574,15 @@ export const restoreSettingsBackup = (filename: string): Settings => { throw new Error(`Backup failed validation: ${issues.map((issue) => issue.path).join(', ')}`) } - saveSettings(restored) + return restored +} + +export const restoreSettingsBackup = async (filename: string): Promise => { + const restored = loadValidatedSettingsBackup(filename) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { saveUserSettingsOverrides } = require('./settings-store') as typeof import('./settings-store') + await saveUserSettingsOverrides(restored) return restored } diff --git a/src/utils/settings-paths.ts b/src/utils/settings-paths.ts index d55743195..6bc154f75 100644 --- a/src/utils/settings-paths.ts +++ b/src/utils/settings-paths.ts @@ -12,3 +12,5 @@ export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'res export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') + +export const getSettingsDbImportMarkerPath = (): string => join(getConfigBaseDir(), '.settings-db-import-complete') diff --git a/src/utils/settings-store.ts b/src/utils/settings-store.ts new file mode 100644 index 000000000..efb6ab846 --- /dev/null +++ b/src/utils/settings-store.ts @@ -0,0 +1,128 @@ +import fs from 'fs' + +import cluster from 'cluster' + +import { Settings } from '../@types/settings' +import { getMasterDbClient } from '../database/client' +import { createLogger } from '../factories/logger-factory' +import { SettingsOverrideRepository } from '../repositories/settings-override-repository' +import { SettingsStatic } from './settings' +import { + backupUserSettingsOverrides, + loadUserSettingsFromFiles, + saveSettings, +} from './settings-config' +import { getSettingsDbImportMarkerPath } from './settings-paths' + +const logger = createLogger('settings-store') + +const SETTINGS_OVERRIDES_RELOAD_EVENT = 'settings-overrides-reload' + +let dbOverridesCache: Settings = {} as Settings +let bootstrapComplete = false +let reloadListenerRegistered = false + +export type SettingsBackend = 'file' | 'db' + +export const getSettingsBackend = (): SettingsBackend => { + return process.env.SETTINGS_BACKEND === 'db' ? 'db' : 'file' +} + +export const getDbOverridesCache = (): Settings => structuredClone(dbOverridesCache) + +const isEmptyOverrides = (settings: Settings): boolean => { + return Object.keys(settings as object).length === 0 +} + +const markSettingsFileImportComplete = (): void => { + fs.writeFileSync(getSettingsDbImportMarkerPath(), `${new Date().toISOString()}\n`, { encoding: 'utf-8', mode: 0o600 }) +} + +const hasCompletedSettingsFileImport = (): boolean => { + return fs.existsSync(getSettingsDbImportMarkerPath()) +} + +export const reloadDbOverridesFromDatabase = async (): Promise => { + if (getSettingsBackend() !== 'db') { + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + dbOverridesCache = await repository.loadOverrides() + SettingsStatic.invalidateCache() +} + +const registerSettingsOverridesReloadListener = (): void => { + if (reloadListenerRegistered || getSettingsBackend() !== 'db') { + return + } + + reloadListenerRegistered = true + process.on('message', (message: { eventName?: string }) => { + if (message?.eventName !== SETTINGS_OVERRIDES_RELOAD_EVENT) { + return + } + + void reloadDbOverridesFromDatabase().catch((error) => { + logger.error('failed to reload database settings overrides: %o', error) + }) + }) +} + +const broadcastSettingsOverridesReload = (): void => { + if (getSettingsBackend() !== 'db' || !cluster.isPrimary || !cluster.workers) { + return + } + + for (const worker of Object.values(cluster.workers)) { + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) + } +} + +export const bootstrapSettingsStore = async (): Promise => { + registerSettingsOverridesReloadListener() + + if (getSettingsBackend() !== 'db') { + bootstrapComplete = true + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + let overrides = await repository.loadOverrides() + + if (!hasCompletedSettingsFileImport() && isEmptyOverrides(overrides)) { + const fileOverrides = loadUserSettingsFromFiles() + if (!isEmptyOverrides(fileOverrides)) { + logger.info('importing settings overrides from file into database') + await repository.replaceOverrides(fileOverrides) + overrides = fileOverrides + } + markSettingsFileImportComplete() + } + + dbOverridesCache = overrides + bootstrapComplete = true + SettingsStatic.invalidateCache() +} + +export const saveUserSettingsOverrides = async (settings: Settings): Promise => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + const previous = await repository.loadOverrides() + if (!isEmptyOverrides(previous)) { + backupUserSettingsOverrides(previous) + } + + await repository.replaceOverrides(settings) + dbOverridesCache = structuredClone(settings) + SettingsStatic.invalidateCache() + broadcastSettingsOverridesReload() + return + } + + saveSettings(settings) +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index c47aa321a..665a6ff4a 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -95,6 +95,10 @@ export class SettingsStatic { } } + public static invalidateCache(): void { + SettingsStatic._settings = undefined + } + public static saveSettings(path: string, settings: Settings) { logger('saving settings to %s: %o', path, settings) return fs.writeFileSync(join(path, 'settings.yaml'), yaml.dump(settings), { encoding: 'utf-8' }) diff --git a/test/unit/controllers/admin/patch-notifications-controller.spec.ts b/test/unit/controllers/admin/patch-notifications-controller.spec.ts index 9698bcfae..70926bb6e 100644 --- a/test/unit/controllers/admin/patch-notifications-controller.spec.ts +++ b/test/unit/controllers/admin/patch-notifications-controller.spec.ts @@ -5,6 +5,7 @@ import sinonChai from 'sinon-chai' import { OperatorNotificationEventType } from '../../../../src/@types/operator-notifications' import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' import * as settingsConfig from '../../../../src/utils/settings-config' +import * as settingsStore from '../../../../src/utils/settings-store' // eslint-disable-next-line @typescript-eslint/no-var-requires const { PatchAdminNotificationsController } = require('../../../../src/controllers/admin/patch-notifications-controller') @@ -49,7 +50,7 @@ describe('PatchAdminNotificationsController', () => { sandbox.stub(settingsConfig, 'loadMergedSettings').returns(baseMergedSettings as any) sandbox.stub(settingsConfig, 'loadUserSettings').returns({ admin: { notifications: baseNotifications } } as any) - sandbox.stub(settingsConfig, 'saveSettings') + sandbox.stub(settingsStore, 'saveUserSettingsOverrides').resolves() sandbox.stub(settingsConfig, 'appendSettingsAuditLog') sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ ...baseNotifications, @@ -93,7 +94,7 @@ describe('PatchAdminNotificationsController', () => { expect(response.status.calledOnceWithExactly(400)).to.equal(true) expect(response.send.firstCall.args[0].error).to.equal('Validation failed') expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty - expect(settingsConfig.saveSettings).to.not.have.been.called + expect(settingsStore.saveUserSettingsOverrides).to.not.have.been.called expect(outboxRepository.enqueue).to.not.have.been.called }) @@ -118,8 +119,8 @@ describe('PatchAdminNotificationsController', () => { ) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce - const saved = (settingsConfig.saveSettings as Sinon.SinonStub).firstCall.args[0] + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce + const saved = (settingsStore.saveUserSettingsOverrides as Sinon.SinonStub).firstCall.args[0] expect(saved.admin.notifications.enabled).to.equal(true) expect(saved.admin.notifications.targets[0].enabled).to.equal(false) expect(saved.admin.notifications.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') @@ -137,6 +138,6 @@ describe('PatchAdminNotificationsController', () => { await controller.handleRequest({ body: { enabled: true }, ip: '127.0.0.1' } as any, response as any) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce }) }) diff --git a/test/unit/migrations/settings-overrides-migration.spec.ts b/test/unit/migrations/settings-overrides-migration.spec.ts new file mode 100644 index 000000000..bd197646f --- /dev/null +++ b/test/unit/migrations/settings-overrides-migration.spec.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module' + +import { expect } from 'chai' + +const requireFromHere = createRequire(__filename) +const migration = requireFromHere('../../../migrations/20260905_120000_create_settings_overrides_table.js') + +describe('migrations/20260905_120000_create_settings_overrides_table', () => { + it('exports up and down', () => { + expect(migration.up).to.be.a('function') + expect(migration.down).to.be.a('function') + }) +}) diff --git a/test/unit/repositories/settings-override-repository.spec.ts b/test/unit/repositories/settings-override-repository.spec.ts new file mode 100644 index 000000000..dddad30e7 --- /dev/null +++ b/test/unit/repositories/settings-override-repository.spec.ts @@ -0,0 +1,96 @@ +import * as chai from 'chai' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { SettingsOverrideRepository } from '../../../src/repositories/settings-override-repository' + +chai.use(sinonChai) + +const { expect } = chai + +describe('SettingsOverrideRepository', () => { + let queryBuilder: { + select: sinon.SinonStub + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let transactionBuilder: { + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let dbClient: any + let repository: SettingsOverrideRepository + + beforeEach(() => { + queryBuilder = { + select: sinon.stub().resolves([ + { path: 'info.name', value: 'Test Relay' }, + { path: 'nip66.enabled', value: true }, + ]), + delete: sinon.stub(), + insert: sinon.stub(), + } + + transactionBuilder = { + delete: sinon.stub().resolves(), + insert: sinon.stub().resolves(), + } + + const transactionFn = sinon.stub() + transactionFn.withArgs('settings_overrides').returns(transactionBuilder) + + dbClient = sinon.stub() + dbClient.callsFake((table: string) => { + if (table === 'settings_overrides') { + return queryBuilder + } + + throw new Error(`unexpected table ${table}`) + }) + dbClient.transaction = sinon.stub().callsFake(async (callback: (trx: typeof transactionFn) => Promise) => { + await callback(transactionFn) + }) + + repository = new SettingsOverrideRepository(dbClient) + }) + + it('loads overrides into a nested settings object', async () => { + const overrides = await repository.loadOverrides() + + expect(overrides).to.deep.equal({ + info: { name: 'Test Relay' }, + nip66: { enabled: true }, + }) + }) + + it('replaces all override rows from a nested settings object', async () => { + await repository.replaceOverrides({ + info: { name: 'Updated Relay' }, + admin: { enabled: true }, + } as any) + + expect(dbClient.transaction).to.have.been.calledOnce + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).to.have.been.calledOnce + + const insertedRows = transactionBuilder.insert.firstCall.args[0] + expect(insertedRows).to.have.length(2) + expect(insertedRows[0]).to.deep.include({ + path: 'info.name', + value: JSON.stringify('Updated Relay'), + }) + expect(insertedRows[0].updated_at).to.be.instanceOf(Date) + expect(insertedRows[1]).to.deep.include({ + path: 'admin.enabled', + value: JSON.stringify(true), + }) + expect(insertedRows[1].updated_at).to.be.instanceOf(Date) + }) + + it('clears all rows when overrides are empty', async () => { + await repository.replaceOverrides({} as any) + + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).not.to.have.been.called + }) +}) diff --git a/test/unit/utils/settings-flatten.spec.ts b/test/unit/utils/settings-flatten.spec.ts new file mode 100644 index 000000000..88435deab --- /dev/null +++ b/test/unit/utils/settings-flatten.spec.ts @@ -0,0 +1,55 @@ +import { expect } from 'chai' + +import { flattenSettingsToPaths } from '../../../src/utils/settings-config' + +describe('flattenSettingsToPaths', () => { + it('flattens nested objects into dot paths', () => { + expect( + flattenSettingsToPaths({ + info: { name: 'Relay', relay_url: 'wss://relay.example.com' }, + nip66: { enabled: true }, + }), + ).to.deep.equal([ + { path: 'info.name', value: 'Relay' }, + { path: 'info.relay_url', value: 'wss://relay.example.com' }, + { path: 'nip66.enabled', value: true }, + ]) + }) + + it('preserves notification event keys that contain dots', () => { + expect( + flattenSettingsToPaths({ + admin: { + notifications: { + events: { 'settings.changed': true, 'mirror.disconnected': false }, + }, + }, + }), + ).to.deep.equal([ + { path: 'admin.notifications.events["settings.changed"]', value: true }, + { path: 'admin.notifications.events["mirror.disconnected"]', value: false }, + ]) + }) + + it('stores empty arrays as explicit override rows', () => { + expect( + flattenSettingsToPaths({ + limits: { + message: { + ipWhitelist: [], + }, + }, + }), + ).to.deep.equal([{ path: 'limits.message.ipWhitelist', value: [] }]) + }) + + it('flattens indexed array paths', () => { + expect( + flattenSettingsToPaths({ + nip66: { + targets: ['wss://relay.example.com'], + }, + }), + ).to.deep.equal([{ path: 'nip66.targets[0]', value: 'wss://relay.example.com' }]) + }) +}) diff --git a/test/unit/utils/settings-store.spec.ts b/test/unit/utils/settings-store.spec.ts new file mode 100644 index 000000000..aedd91f5b --- /dev/null +++ b/test/unit/utils/settings-store.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' + +import { getSettingsBackend } from '../../../src/utils/settings-store' + +describe('settings-store backend selection', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(() => { + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('defaults to file backend', () => { + delete process.env.SETTINGS_BACKEND + + expect(getSettingsBackend()).to.equal('file') + }) + + it('selects db backend when SETTINGS_BACKEND=db', () => { + process.env.SETTINGS_BACKEND = 'db' + + expect(getSettingsBackend()).to.equal('db') + }) +})