refactor(notifications): extract config secret mapping

This commit is contained in:
Nicolas Meienberger
2026-03-15 11:45:19 +01:00
parent a2d34a027e
commit 5f7f2005fa
3 changed files with 124 additions and 103 deletions

View File

@@ -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,
});
});
});

View File

@@ -0,0 +1,67 @@
import { cryptoUtils } from "~/server/utils/crypto";
import type { NotificationConfig } from "~/schemas/notifications";
type SecretTransformer = (value: string) => Promise<string>;
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);
};

View File

@@ -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<NotificationConfig> {
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<NotificationConfig> {
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 });