refactor(notifications): extract config secret mapping
This commit is contained in:
parent
a2d34a027e
commit
5f7f2005fa
3 changed files with 124 additions and 103 deletions
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
backupScheduleNotificationsTable,
|
backupScheduleNotificationsTable,
|
||||||
type NotificationDestination,
|
type NotificationDestination,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { sendNotification } from "../../utils/shoutrrr";
|
import { sendNotification } from "../../utils/shoutrrr";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
@ -16,6 +15,7 @@ import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { formatBytes } from "~/utils/format-bytes";
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
|
import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets";
|
||||||
|
|
||||||
const listDestinations = async () => {
|
const listDestinations = async () => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
|
|
@ -39,104 +39,6 @@ const getDestination = async (id: number) => {
|
||||||
return destination;
|
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 createDestination = async (name: string, config: NotificationConfig) => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
|
|
@ -145,7 +47,7 @@ const createDestination = async (name: string, config: NotificationConfig) => {
|
||||||
throw new BadRequestError("Name cannot be empty");
|
throw new BadRequestError("Name cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedConfig = await encryptSensitiveFields(config);
|
const encryptedConfig = await encryptNotificationConfig(config);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(notificationDestinationsTable)
|
.insert(notificationDestinationsTable)
|
||||||
|
|
@ -197,7 +99,7 @@ const updateDestination = async (
|
||||||
}
|
}
|
||||||
const newConfig = newConfigResult.data;
|
const newConfig = newConfigResult.data;
|
||||||
|
|
||||||
const encryptedConfig = await encryptSensitiveFields(newConfig);
|
const encryptedConfig = await encryptNotificationConfig(newConfig);
|
||||||
updateData.config = encryptedConfig;
|
updateData.config = encryptedConfig;
|
||||||
updateData.type = newConfig.type;
|
updateData.type = newConfig.type;
|
||||||
|
|
||||||
|
|
@ -229,7 +131,7 @@ const deleteDestination = async (id: number) => {
|
||||||
const testDestination = async (id: number) => {
|
const testDestination = async (id: number) => {
|
||||||
const destination = await getDestination(id);
|
const destination = await getDestination(id);
|
||||||
|
|
||||||
const decryptedConfig = await decryptSensitiveFields(destination.config);
|
const decryptedConfig = await decryptNotificationConfig(destination.config);
|
||||||
|
|
||||||
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
|
|
@ -426,7 +328,7 @@ const sendBackupNotification = async (
|
||||||
|
|
||||||
for (const assignment of relevantAssignments) {
|
for (const assignment of relevantAssignments) {
|
||||||
try {
|
try {
|
||||||
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config);
|
const decryptedConfig = await decryptNotificationConfig(assignment.destination.config);
|
||||||
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
const result = await sendNotification({ shoutrrrUrl, title, body });
|
const result = await sendNotification({ shoutrrrUrl, title, body });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue