From 5f7f2005fa0d3ff48c49fcbf44545d334cc99fe5 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 15 Mar 2026 11:45:19 +0100 Subject: [PATCH] refactor(notifications): extract config secret mapping --- .../notification-config-secrets.test.ts | 52 +++++++++ .../notification-config-secrets.ts | 67 +++++++++++ .../notifications/notifications.service.ts | 108 +----------------- 3 files changed, 124 insertions(+), 103 deletions(-) create mode 100644 app/server/modules/notifications/__tests__/notification-config-secrets.test.ts create mode 100644 app/server/modules/notifications/notification-config-secrets.ts diff --git a/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts b/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts new file mode 100644 index 00000000..a56f2836 --- /dev/null +++ b/app/server/modules/notifications/__tests__/notification-config-secrets.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { mapNotificationConfigSecrets } from "../notification-config-secrets"; + +describe("mapNotificationConfigSecrets", () => { + test("transforms only secret fields for a notification config", async () => { + const transformed = await mapNotificationConfigSecrets( + { + type: "slack", + webhookUrl: "https://hooks.slack.test/services/a/b/c", + channel: "#alerts", + username: "zerobyte", + iconEmoji: ":wave:", + }, + async (value) => `sealed:${value}`, + ); + + expect(transformed).toEqual({ + type: "slack", + webhookUrl: "sealed:https://hooks.slack.test/services/a/b/c", + channel: "#alerts", + username: "zerobyte", + iconEmoji: ":wave:", + }); + }); + + test("preserves optional undefined secrets", async () => { + const transformed = await mapNotificationConfigSecrets( + { + type: "email", + smtpHost: "smtp.example.com", + smtpPort: 587, + username: "ops", + password: undefined, + from: "ops@example.com", + to: ["alerts@example.com"], + useTLS: true, + }, + async (value) => `sealed:${value}`, + ); + + expect(transformed).toEqual({ + type: "email", + smtpHost: "smtp.example.com", + smtpPort: 587, + username: "ops", + password: undefined, + from: "ops@example.com", + to: ["alerts@example.com"], + useTLS: true, + }); + }); +}); diff --git a/app/server/modules/notifications/notification-config-secrets.ts b/app/server/modules/notifications/notification-config-secrets.ts new file mode 100644 index 00000000..16ff18e0 --- /dev/null +++ b/app/server/modules/notifications/notification-config-secrets.ts @@ -0,0 +1,67 @@ +import { cryptoUtils } from "~/server/utils/crypto"; +import type { NotificationConfig } from "~/schemas/notifications"; + +type SecretTransformer = (value: string) => Promise; + +const transformOptionalSecret = async (value: string | undefined, transformSecret: SecretTransformer) => { + if (!value) { + return value; + } + + return await transformSecret(value); +}; + +export const mapNotificationConfigSecrets = async (config: NotificationConfig, transformSecret: SecretTransformer) => { + switch (config.type) { + case "email": + return { + ...config, + password: await transformOptionalSecret(config.password, transformSecret), + }; + case "slack": + return { + ...config, + webhookUrl: await transformSecret(config.webhookUrl), + }; + case "discord": + return { + ...config, + webhookUrl: await transformSecret(config.webhookUrl), + }; + case "gotify": + return { + ...config, + token: await transformSecret(config.token), + }; + case "ntfy": + return { + ...config, + password: await transformOptionalSecret(config.password, transformSecret), + }; + case "pushover": + return { + ...config, + apiToken: await transformSecret(config.apiToken), + }; + case "telegram": + return { + ...config, + botToken: await transformSecret(config.botToken), + }; + case "custom": + return { + ...config, + shoutrrrUrl: await transformSecret(config.shoutrrrUrl), + }; + case "generic": + return config; + } +}; + +export const encryptNotificationConfig = async (config: NotificationConfig) => { + return await mapNotificationConfigSecrets(config, cryptoUtils.sealSecret); +}; + +export const decryptNotificationConfig = async (config: NotificationConfig) => { + return await mapNotificationConfigSecrets(config, cryptoUtils.resolveSecret); +}; diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index b085472e..493979e6 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -6,7 +6,6 @@ import { backupScheduleNotificationsTable, type NotificationDestination, } from "../../db/schema"; -import { cryptoUtils } from "../../utils/crypto"; import { logger } from "@zerobyte/core/node"; import { sendNotification } from "../../utils/shoutrrr"; import { formatDuration } from "~/utils/utils"; @@ -16,6 +15,7 @@ import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic"; import { toMessage } from "../../utils/errors"; import { getOrganizationId } from "~/server/core/request-context"; import { formatBytes } from "~/utils/format-bytes"; +import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets"; const listDestinations = async () => { const organizationId = getOrganizationId(); @@ -39,104 +39,6 @@ const getDestination = async (id: number) => { return destination; }; -async function encryptSensitiveFields(config: NotificationConfig): Promise { - switch (config.type) { - case "email": - return { - ...config, - password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined, - }; - case "slack": - return { - ...config, - webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl), - }; - case "discord": - return { - ...config, - webhookUrl: await cryptoUtils.sealSecret(config.webhookUrl), - }; - case "gotify": - return { - ...config, - token: await cryptoUtils.sealSecret(config.token), - }; - case "ntfy": - return { - ...config, - password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined, - }; - case "pushover": - return { - ...config, - apiToken: await cryptoUtils.sealSecret(config.apiToken), - }; - case "telegram": - return { - ...config, - botToken: await cryptoUtils.sealSecret(config.botToken), - }; - case "generic": - return config; - case "custom": - return { - ...config, - shoutrrrUrl: await cryptoUtils.sealSecret(config.shoutrrrUrl), - }; - default: - return config; - } -} - -async function decryptSensitiveFields(config: NotificationConfig): Promise { - switch (config.type) { - case "email": - return { - ...config, - password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined, - }; - case "slack": - return { - ...config, - webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl), - }; - case "discord": - return { - ...config, - webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl), - }; - case "gotify": - return { - ...config, - token: await cryptoUtils.resolveSecret(config.token), - }; - case "ntfy": - return { - ...config, - password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined, - }; - case "pushover": - return { - ...config, - apiToken: await cryptoUtils.resolveSecret(config.apiToken), - }; - case "telegram": - return { - ...config, - botToken: await cryptoUtils.resolveSecret(config.botToken), - }; - case "generic": - return config; - case "custom": - return { - ...config, - shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl), - }; - default: - return config; - } -} - const createDestination = async (name: string, config: NotificationConfig) => { const organizationId = getOrganizationId(); const trimmedName = name.trim(); @@ -145,7 +47,7 @@ const createDestination = async (name: string, config: NotificationConfig) => { throw new BadRequestError("Name cannot be empty"); } - const encryptedConfig = await encryptSensitiveFields(config); + const encryptedConfig = await encryptNotificationConfig(config); const [created] = await db .insert(notificationDestinationsTable) @@ -197,7 +99,7 @@ const updateDestination = async ( } const newConfig = newConfigResult.data; - const encryptedConfig = await encryptSensitiveFields(newConfig); + const encryptedConfig = await encryptNotificationConfig(newConfig); updateData.config = encryptedConfig; updateData.type = newConfig.type; @@ -229,7 +131,7 @@ const deleteDestination = async (id: number) => { const testDestination = async (id: number) => { const destination = await getDestination(id); - const decryptedConfig = await decryptSensitiveFields(destination.config); + const decryptedConfig = await decryptNotificationConfig(destination.config); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); @@ -426,7 +328,7 @@ const sendBackupNotification = async ( for (const assignment of relevantAssignments) { try { - const decryptedConfig = await decryptSensitiveFields(assignment.destination.config); + const decryptedConfig = await decryptNotificationConfig(assignment.destination.config); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const result = await sendNotification({ shoutrrrUrl, title, body });