diff --git a/.changeset/admin-notifications-api.md b/.changeset/admin-notifications-api.md new file mode 100644 index 00000000..bb35cb72 --- /dev/null +++ b/.changeset/admin-notifications-api.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +feat(admin): add GET/PATCH /admin/notifications API for operator alert config + +Closes #760. diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 8368444e..f43d5c9d 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -112,7 +112,11 @@ export interface INotificationDeliveryLogRepository { }, client?: DatabaseClient, ): Promise - findRecent(limit?: number, client?: DatabaseClient): Promise + findRecent( + limit?: number, + filters?: { status?: NotificationDeliveryStatus; eventType?: string }, + client?: DatabaseClient, + ): Promise findSuccessfulTargetIds(outboxId: string, client?: DatabaseClient): Promise deleteOlderThan(cutoff: Date, client?: DatabaseClient): Promise } diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index d5ed3fdd..acd9114b 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -20,7 +20,10 @@ import { import { InvoiceStatus } from '../@types/invoice' import { isExpiredInvoice } from '../utils/invoice' import { Nip05Verification } from '../@types/nip05' +import { FSWatcher } from 'fs' + import { Settings } from '../@types/settings' +import { SettingsStatic } from '../utils/settings' import { shutdownMetricsTelemetry } from '../telemetry/metrics' const UPDATE_INVOICE_INTERVAL = 60000 @@ -79,6 +82,7 @@ export function applyReverificationOutcome( export class MaintenanceWorker implements IRunnable { private interval: NodeJS.Timeout | undefined private isRunning = false + private watchers: FSWatcher[] | undefined /** * Where the next pass starts. Without it every pass re-reads the oldest ten, so * ten invoices that never resolve starve everything behind them. @@ -117,6 +121,8 @@ export class MaintenanceWorker implements IRunnable { } public run(): void { + this.watchers = SettingsStatic.watchSettings() + this.interval = setInterval(async () => { if (this.isRunning) { logger('skipping scheduled maintenance run because previous run is still in progress') @@ -289,6 +295,11 @@ export class MaintenanceWorker implements IRunnable { public close(callback?: () => void) { logger('closing') clearInterval(this.interval) + if (Array.isArray(this.watchers)) { + for (const watcher of this.watchers) { + watcher.close() + } + } if (typeof callback === 'function') { callback() } diff --git a/src/controllers/admin/get-notification-delivery-log-controller.ts b/src/controllers/admin/get-notification-delivery-log-controller.ts index 9873776f..93209970 100644 --- a/src/controllers/admin/get-notification-delivery-log-controller.ts +++ b/src/controllers/admin/get-notification-delivery-log-controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express' import { IController } from '../../@types/controllers' +import { NotificationDeliveryStatus } from '../../@types/operator-notifications' import { INotificationDeliveryLogRepository } from '../../@types/repositories' export class GetAdminNotificationDeliveryLogController implements IController { @@ -19,7 +20,26 @@ export class GetAdminNotificationDeliveryLogController implements IController { limit = Math.min(parsed, 200) } - const entries = await this.deliveryLogRepository.findRecent(limit) + let status: NotificationDeliveryStatus | undefined + if (_request.query.status !== undefined) { + const value = String(_request.query.status) + if (value !== NotificationDeliveryStatus.SUCCESS && value !== NotificationDeliveryStatus.FAILED) { + response.status(400).setHeader('content-type', 'application/json').send({ + error: 'status must be success or failed', + }) + return + } + status = value + } + + const eventType = + _request.query.eventType !== undefined ? String(_request.query.eventType).trim() : undefined + if (eventType !== undefined && !eventType) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'eventType must be non-empty' }) + return + } + + const entries = await this.deliveryLogRepository.findRecent(limit, { status, eventType }) response.status(200).setHeader('content-type', 'application/json').send({ entries: entries.map((entry) => ({ diff --git a/src/controllers/admin/get-notifications-controller.ts b/src/controllers/admin/get-notifications-controller.ts new file mode 100644 index 00000000..93160c69 --- /dev/null +++ b/src/controllers/admin/get-notifications-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { getRedactedAdminNotifications } from '../../utils/admin-notifications-settings' + +export class GetAdminNotificationsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ + notifications: getRedactedAdminNotifications(), + }) + } +} diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts new file mode 100644 index 00000000..fbfa5f79 --- /dev/null +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -0,0 +1,78 @@ +import { Request, Response } from 'express' +import { mergeDeepRight } from 'ramda' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { OperatorNotificationEventType } from '../../@types/operator-notifications' +import { createLogger } from '../../factories/logger-factory' +import { adminNotificationsPatchBodySchema } from '../../schemas/admin-notifications-schema' +import { + getMergedAdminNotifications, + getRedactedAdminNotifications, + mergeAdminNotificationsPatch, +} from '../../utils/admin-notifications-settings' +import { + appendSettingsAuditLog, + loadDefaults, + loadMergedSettings, + loadUserSettings, + saveSettings, + validateSettings, +} from '../../utils/settings-config' +import { validateSchema } from '../../utils/validation' + +const logger = createLogger('patch-admin-notifications-controller') + +export class PatchAdminNotificationsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminNotificationsPatchBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const merged = loadMergedSettings() + const current = getMergedAdminNotifications(merged) + const nextNotifications = mergeAdminNotificationsPatch(current, validation.value) + + const userSettings = loadUserSettings() as Settings + const nextUserSettings = mergeDeepRight(userSettings, { + admin: { + ...userSettings.admin, + notifications: nextNotifications, + }, + }) as Settings + + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Validation failed', issues }) + return + } + + saveSettings(nextUserSettings) + appendSettingsAuditLog({ + action: 'settings.updated', + changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], + remoteAddress: request.ip, + }) + + try { + await this.notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { + changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], + remoteAddress: request.ip, + }) + } catch (error) { + logger.error('Unable to enqueue notifications settings outbox event', error) + } + + response.status(200).setHeader('content-type', 'application/json').send({ + ok: true, + notifications: getRedactedAdminNotifications(), + }) + } +} diff --git a/src/factories/controllers/get-admin-notifications-controller-factory.ts b/src/factories/controllers/get-admin-notifications-controller-factory.ts new file mode 100644 index 00000000..d144e583 --- /dev/null +++ b/src/factories/controllers/get-admin-notifications-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminNotificationsController } from '../../controllers/admin/get-notifications-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminNotificationsController = (): IController => { + return new GetAdminNotificationsController() +} diff --git a/src/factories/controllers/patch-admin-notifications-controller-factory.ts b/src/factories/controllers/patch-admin-notifications-controller-factory.ts new file mode 100644 index 00000000..57a6d62a --- /dev/null +++ b/src/factories/controllers/patch-admin-notifications-controller-factory.ts @@ -0,0 +1,9 @@ +import { PatchAdminNotificationsController } from '../../controllers/admin/patch-notifications-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPatchAdminNotificationsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PatchAdminNotificationsController(notificationOutboxRepository) +} diff --git a/src/repositories/notification-delivery-log-repository.ts b/src/repositories/notification-delivery-log-repository.ts index b83bd6ea..ee508933 100644 --- a/src/repositories/notification-delivery-log-repository.ts +++ b/src/repositories/notification-delivery-log-repository.ts @@ -64,11 +64,24 @@ export class NotificationDeliveryLogRepository implements INotificationDeliveryL .pluck('target_id') } - public async findRecent(limit = 50, client: DatabaseClient = this.dbClient): Promise { - const rows = await client('notification_delivery_log') + public async findRecent( + limit = 50, + filters?: { status?: NotificationDeliveryStatus; eventType?: string }, + client: DatabaseClient = this.dbClient, + ): Promise { + let query = client('notification_delivery_log') .orderBy('created_at', 'desc') .limit(limit) - .select('*') + + if (filters?.status) { + query = query.where('status', filters.status) + } + + if (filters?.eventType) { + query = query.where('event_type', filters.eventType) + } + + const rows = await query.select('*') return rows.map(fromDB) } diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index c2b9f9f6..0f73629f 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,6 +1,8 @@ import express, { json, Router } from 'express' import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory' +import { createGetAdminNotificationsController } from '../../factories/controllers/get-admin-notifications-controller-factory' +import { createPatchAdminNotificationsController } from '../../factories/controllers/patch-admin-notifications-controller-factory' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory' @@ -106,6 +108,20 @@ router.post( adminAuthMiddleware, withAdminController(createPostAdminSettingsRestoreController), ) +router.get( + '/notifications', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminNotificationsController), +) +router.patch( + '/notifications', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPatchAdminNotificationsController), +) router.get( '/notifications/deliveries', adminRateLimitMiddleware, diff --git a/src/schemas/admin-notifications-schema.ts b/src/schemas/admin-notifications-schema.ts new file mode 100644 index 00000000..c7d05c0a --- /dev/null +++ b/src/schemas/admin-notifications-schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +const targetSchema = z + .object({ + id: z.string().min(1), + type: z.enum(['http', 'discord', 'slack', 'telegram']), + enabled: z.boolean(), + url: z.string().optional(), + botToken: z.string().optional(), + chatId: z.string().optional(), + }) + .strict() + +export const adminNotificationsPatchBodySchema = z + .object({ + enabled: z.boolean().optional(), + targets: z.array(targetSchema).optional(), + events: z + .object({ + 'admission.invoice.created': z.boolean().optional(), + 'admission.invoice.paid': z.boolean().optional(), + 'admission.invoice.failed': z.boolean().optional(), + 'settings.changed': z.boolean().optional(), + 'relay.restarted': z.boolean().optional(), + }) + .strict() + .optional(), + retry: z + .object({ + maxAttempts: z.number().int().min(1).optional(), + baseDelayMs: z.number().int().min(0).optional(), + }) + .strict() + .optional(), + deliveryLogRetentionDays: z.number().int().min(1).optional(), + }) + .strict() diff --git a/src/services/operator-notification-service.ts b/src/services/operator-notification-service.ts index 3a9a5576..65cc9dc0 100644 --- a/src/services/operator-notification-service.ts +++ b/src/services/operator-notification-service.ts @@ -100,12 +100,38 @@ export class OperatorNotificationService implements INotificationDispatcher { throw new Error(`Unknown notification target: ${targetId}`) } - const envelope = this.buildEnvelope(OperatorNotificationEventType.RELAY_RESTARTED, { + const relayName = this.settings().info?.name?.trim() || this.settings().info.relay_url + const eventType = OperatorNotificationEventType.RELAY_RESTARTED + const envelope = this.buildEnvelope(eventType, { test: true, - message: 'Operator notification test delivery', + message: `Test notification from ${relayName}`, }) - await deliverToTarget(target, envelope) + try { + await deliverToTarget(target, envelope) + await this.deliveryLogRepository.append({ + outboxId: null, + eventType, + targetId: target.id, + targetType: target.type, + status: NotificationDeliveryStatus.SUCCESS, + attemptNumber: 1, + errorSnippet: null, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error('test delivery failed for %s: %s', maskTargetForLog(target), message) + await this.deliveryLogRepository.append({ + outboxId: null, + eventType, + targetId: target.id, + targetType: target.type, + status: NotificationDeliveryStatus.FAILED, + attemptNumber: 1, + errorSnippet: message.slice(0, 2000), + }) + throw error + } } public getMaxAttempts(): number { diff --git a/src/utils/admin-notifications-settings.ts b/src/utils/admin-notifications-settings.ts new file mode 100644 index 00000000..1d801a14 --- /dev/null +++ b/src/utils/admin-notifications-settings.ts @@ -0,0 +1,81 @@ +import { AdminNotificationsSettings, OperatorNotificationTarget } from '../@types/operator-notifications' +import { Settings } from '../@types/settings' +import { loadDefaults, loadMergedSettings } from './settings-config' +import { redactSettingsSecrets } from './settings-redaction' + +const REDACTED_SECRET = '***' + +export const getMergedAdminNotifications = (settings: Settings = loadMergedSettings()): AdminNotificationsSettings => { + const defaults = loadDefaults().admin?.notifications + const configured = settings.admin?.notifications + + return { + enabled: configured?.enabled ?? defaults?.enabled ?? false, + targets: configured?.targets ?? defaults?.targets ?? [], + events: { ...defaults?.events, ...configured?.events }, + retry: { + maxAttempts: configured?.retry?.maxAttempts ?? defaults?.retry?.maxAttempts ?? 5, + baseDelayMs: configured?.retry?.baseDelayMs ?? defaults?.retry?.baseDelayMs ?? 1000, + }, + deliveryLogRetentionDays: + configured?.deliveryLogRetentionDays ?? defaults?.deliveryLogRetentionDays ?? 30, + } +} + +export const getRedactedAdminNotifications = (): AdminNotificationsSettings => { + const merged = loadMergedSettings() + const notifications = getMergedAdminNotifications(merged) + const redacted = redactSettingsSecrets({ admin: { notifications } }) as Settings + + return getMergedAdminNotifications(redacted) +} + +const isRedactedSecret = (value: unknown): boolean => value === REDACTED_SECRET + +const mergeTargetSecrets = ( + incoming: OperatorNotificationTarget, + existing: OperatorNotificationTarget | undefined, +): OperatorNotificationTarget => { + const merged: OperatorNotificationTarget = { ...incoming } + + if (isRedactedSecret(incoming.url)) { + if (existing?.url) { + merged.url = existing.url + } else { + delete merged.url + } + } else if (incoming.url === undefined && existing?.url) { + merged.url = existing.url + } + + if (isRedactedSecret(incoming.botToken)) { + if (existing?.botToken) { + merged.botToken = existing.botToken + } else { + delete merged.botToken + } + } else if (incoming.botToken === undefined && existing?.botToken) { + merged.botToken = existing.botToken + } + + return merged +} + +export const mergeAdminNotificationsPatch = ( + current: AdminNotificationsSettings, + patch: Partial, +): AdminNotificationsSettings => { + const next: AdminNotificationsSettings = { + ...current, + ...patch, + events: patch.events ? { ...current.events, ...patch.events } : current.events, + retry: patch.retry ? { ...current.retry, ...patch.retry } : current.retry, + } + + if (patch.targets) { + const existingById = new Map(current.targets.map((target) => [target.id, target])) + next.targets = patch.targets.map((target) => mergeTargetSecrets(target, existingById.get(target.id))) + } + + return next +} diff --git a/test/unit/controllers/admin/get-notifications-controller.spec.ts b/test/unit/controllers/admin/get-notifications-controller.spec.ts new file mode 100644 index 00000000..d72d674b --- /dev/null +++ b/test/unit/controllers/admin/get-notifications-controller.spec.ts @@ -0,0 +1,48 @@ +import chai from 'chai' +import Sinon from 'sinon' + +import { GetAdminNotificationsController } from '../../../../src/controllers/admin/get-notifications-controller' +import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' + +const { expect } = chai + +describe('GetAdminNotificationsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ + enabled: false, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns redacted notifications config', async () => { + const controller = new GetAdminNotificationsController() + await controller.handleRequest({} as any, response as any) + + expect(response.status.calledOnceWithExactly(200)).to.equal(true) + expect( + response.send.calledOnceWith({ + notifications: { + enabled: false, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }), + ).to.equal(true) + }) +}) diff --git a/test/unit/controllers/admin/patch-notifications-controller.spec.ts b/test/unit/controllers/admin/patch-notifications-controller.spec.ts new file mode 100644 index 00000000..9698bcfa --- /dev/null +++ b/test/unit/controllers/admin/patch-notifications-controller.spec.ts @@ -0,0 +1,142 @@ +import chai from 'chai' +import Sinon from 'sinon' +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' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { PatchAdminNotificationsController } = require('../../../../src/controllers/admin/patch-notifications-controller') + +chai.use(sinonChai) + +const { expect } = chai + +const baseNotifications = { + enabled: false, + targets: [ + { + id: 'discord-1', + type: 'discord' as const, + enabled: true, + url: 'https://discord.com/api/webhooks/secret', + }, + ], + events: { 'settings.changed': true }, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + deliveryLogRetentionDays: 30, +} + +const baseMergedSettings = { + info: { relay_url: 'wss://relay.example' }, + admin: { enabled: true, notifications: baseNotifications }, +} + +describe('PatchAdminNotificationsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + + sandbox.stub(settingsConfig, 'loadMergedSettings').returns(baseMergedSettings as any) + sandbox.stub(settingsConfig, 'loadUserSettings').returns({ admin: { notifications: baseNotifications } } as any) + sandbox.stub(settingsConfig, 'saveSettings') + sandbox.stub(settingsConfig, 'appendSettingsAuditLog') + sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ + ...baseNotifications, + targets: [{ id: 'discord-1', type: 'discord', enabled: true, url: '***' }], + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest({ body: { retry: { maxAttempts: 'five' } }, ip: '127.0.0.1' } as any, response as any) + + expect(response.status.calledOnceWithExactly(400)).to.equal(true) + expect(response.send.calledOnceWithExactly({ error: 'Invalid request' })).to.equal(true) + expect(outboxRepository.enqueue).to.not.have.been.called + }) + + it('returns 400 when settings validation fails after merge', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest( + { + body: { + targets: [ + { + id: 'telegram-new', + type: 'telegram', + enabled: true, + botToken: '***', + chatId: '123', + }, + ], + }, + ip: '127.0.0.1', + } as any, + response as any, + ) + + 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(outboxRepository.enqueue).to.not.have.been.called + }) + + it('persists partial updates and preserves redacted secrets for existing targets', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest( + { + body: { + enabled: true, + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: false, + url: '***', + }, + ], + }, + 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 + const saved = (settingsConfig.saveSettings 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') + expect(outboxRepository.enqueue).to.have.been.calledOnceWith( + OperatorNotificationEventType.SETTINGS_CHANGED, + Sinon.match.object, + ) + expect(response.send.firstCall.args[0].ok).to.equal(true) + }) + + it('still returns 200 when outbox enqueue fails', async () => { + outboxRepository.enqueue.rejects(new Error('db unavailable')) + const controller = new PatchAdminNotificationsController(outboxRepository as any) + + 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 + }) +}) diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index a06064cc..de96ae29 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -8,6 +8,7 @@ import { EventKinds, EventTags } from '../../../src/constants/base' import * as getAdminHealthControllerFactory from '../../../src/factories/controllers/get-admin-health-controller-factory' import * as getAdminMetricsControllerFactory from '../../../src/factories/controllers/get-admin-metrics-controller-factory' import * as getAdminNetworkHealthControllerFactory from '../../../src/factories/controllers/get-admin-network-health-controller-factory' +import * as getAdminNotificationsControllerFactory from '../../../src/factories/controllers/get-admin-notifications-controller-factory' import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' import * as settingsFactory from '../../../src/factories/settings-factory' @@ -21,6 +22,7 @@ describe('admin router', () => { let createGetAdminHealthControllerStub: Sinon.SinonStub let createGetAdminMetricsControllerStub: Sinon.SinonStub let createGetAdminNetworkHealthControllerStub: Sinon.SinonStub + let createGetAdminNotificationsControllerStub: Sinon.SinonStub let createSettingsStub: Sinon.SinonStub let rateLimiterMiddlewareStub: Sinon.SinonStub let adminRateLimitMiddlewareStub: Sinon.SinonStub @@ -80,6 +82,17 @@ describe('admin router', () => { .send({ snapshot: null }) }, } as any) + createGetAdminNotificationsControllerStub = Sinon.stub( + getAdminNotificationsControllerFactory, + 'createGetAdminNotificationsController', + ).returns({ + handleRequest: async (_request: any, response: any) => { + response + .status(200) + .setHeader('content-type', 'application/json') + .send({ notifications: { enabled: false, targets: [], events: {}, retry: { maxAttempts: 5, baseDelayMs: 1000 } } }) + }, + } as any) createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) const passthrough = async (_request: any, _response: any, next: any) => { next() @@ -110,6 +123,7 @@ describe('admin router', () => { createGetAdminHealthControllerStub?.restore() createGetAdminMetricsControllerStub?.restore() createGetAdminNetworkHealthControllerStub?.restore() + createGetAdminNotificationsControllerStub?.restore() createSettingsStub?.restore() rateLimiterMiddlewareStub?.restore() adminRateLimitMiddlewareStub?.restore() @@ -190,12 +204,14 @@ describe('admin router', () => { const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) const metricsResponse = await axios.get(`${baseUrl}/metrics`, { validateStatus: () => true }) const networkHealthResponse = await axios.get(`${baseUrl}/network-health`, { validateStatus: () => true }) + const notificationsResponse = await axios.get(`${baseUrl}/notifications`, { validateStatus: () => true }) expect(sessionResponse.status).to.equal(401) expect(healthResponse.status).to.equal(401) expect(metricsResponse.status).to.equal(401) expect(networkHealthResponse.status).to.equal(401) - expect(rateLimiterMiddlewareStub.callCount).to.equal(4) + expect(notificationsResponse.status).to.equal(401) + expect(rateLimiterMiddlewareStub.callCount).to.equal(5) }) it('authenticates a protected route with a signed NIP-98 event', async () => { diff --git a/test/unit/services/operator-notification-service.spec.ts b/test/unit/services/operator-notification-service.spec.ts index 2d9dba7f..e48e4d00 100644 --- a/test/unit/services/operator-notification-service.spec.ts +++ b/test/unit/services/operator-notification-service.spec.ts @@ -84,6 +84,20 @@ describe('OperatorNotificationService', () => { ).to.be.rejectedWith('network down') }) + it('logs success when a test delivery succeeds', async () => { + await service.dispatchTestTarget('discord-main') + + expect(axios.post).to.have.been.calledOnce + expect(deliveryLogRepository.append).to.have.been.calledOnce + }) + + it('logs failure and rethrows when a test delivery fails', async () => { + ;(axios.post as Sinon.SinonStub).rejects(new Error('network down')) + + await expect(service.dispatchTestTarget('discord-main')).to.be.rejectedWith('network down') + expect(deliveryLogRepository.append).to.have.been.calledOnce + }) + it('skips targets that already succeeded for the same outbox message', async () => { service = new OperatorNotificationService( () => diff --git a/test/unit/utils/admin-notifications-settings.spec.ts b/test/unit/utils/admin-notifications-settings.spec.ts new file mode 100644 index 00000000..27997a92 --- /dev/null +++ b/test/unit/utils/admin-notifications-settings.spec.ts @@ -0,0 +1,97 @@ +import chai from 'chai' + +import { + getMergedAdminNotifications, + mergeAdminNotificationsPatch, +} from '../../../src/utils/admin-notifications-settings' + +const { expect } = chai + +describe('admin-notifications-settings', () => { + it('merges patch values and preserves redacted webhook secrets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: true, + url: 'https://discord.com/api/webhooks/secret', + }, + ], + events: { 'settings.changed': true }, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'discord-1', + type: 'discord', + enabled: false, + url: '***', + }, + ], + }) + + expect(next.targets[0].enabled).to.equal(false) + expect(next.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') + }) + + it('does not persist redacted placeholders for new targets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'telegram-new', + type: 'telegram', + enabled: true, + botToken: '***', + chatId: '123', + }, + ], + }) + + expect(next.targets[0].botToken).to.equal(undefined) + }) + + it('does not persist redacted webhook url for new http targets', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'http-new', + type: 'http', + enabled: true, + url: '***', + }, + ], + }) + + expect(next.targets[0].url).to.equal(undefined) + }) +})