Merge pull request #3947 from bluewave-labs/feat/validate-and-encrypt-docker-tls-key

feat: add validation for docker TLS fields, encrypt TLS key on create/edit monitor
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-09-11 11:13:52 -07:00
commit 62d0630702
5 files changed
+411 -3

No files matched your search

+49 -2
View File
@@ -16,6 +16,8 @@ import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { X509Certificate } from "node:crypto";
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -132,6 +134,49 @@ const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.Refinemen
}
};
type DockerTlsFields = {
type?: string;
url?: string;
ignoreTlsErrors?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKey?: string;
};
const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
let certificate: X509Certificate | undefined;
try {
if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
} catch (error: unknown) {
issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
}
try {
if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
} catch (error) {
issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
}
try {
if (data.dockerTlsKey) {
const key = parsePrivateKey(data.dockerTlsKey);
if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
}
} catch (error: unknown) {
issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -180,7 +225,8 @@ export const createMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("create"));
export const editMonitorBodyValidation = z
.object({
@@ -228,7 +274,8 @@ export const editMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("edit"));
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
+1
View File
@@ -80,6 +80,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: shared.encryptionService,
});
const maintenanceWindowService = new MaintenanceWindowService({
@@ -33,6 +33,8 @@ import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
const SERVICE_NAME = "MonitorService";
@@ -144,6 +146,7 @@ export class MonitorService implements IMonitorService {
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
private encryptionService: IEncryptionService;
constructor({
scheduler,
@@ -156,6 +159,7 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService,
}: {
scheduler: IJobScheduler;
logger: ILogger;
@@ -167,6 +171,7 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
encryptionService: IEncryptionService;
}) {
this.scheduler = scheduler;
this.logger = logger;
@@ -178,6 +183,7 @@ export class MonitorService implements IMonitorService {
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
this.encryptionService = encryptionService;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
@@ -185,6 +191,10 @@ export class MonitorService implements IMonitorService {
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
// Encrypt docker TLS keys
this.encryptDockerTls(body, "create");
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
@@ -537,6 +547,20 @@ export class MonitorService implements IMonitorService {
if (unsetProxyId) {
delete body.proxyId;
}
// Handle Docker TLS
this.encryptDockerTls(body, "edit");
if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) {
const stored = await this.monitorsRepository.findById(monitorId, teamId);
if (!stored.dockerTlsKeySet) {
throw new AppError({
message: "Key is required for a TLS Docker host",
status: 422,
service: SERVICE_NAME,
method: "editMonitor",
});
}
}
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
@@ -724,4 +748,34 @@ export class MonitorService implements IMonitorService {
return { imported: createdMonitors!.length, errors };
};
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
if (body.type !== "docker" && mode === "create") return;
if (body.url !== undefined && !isDockerTlsUrl(body.url)) {
body.dockerTlsCa = undefined;
body.dockerTlsCert = undefined;
body.dockerTlsKey = undefined;
body.dockerTlsKeySet = false;
return;
}
if (!body.dockerTlsKey) {
delete body.dockerTlsKey; // empty field means keeps stored key
return;
}
if (!this.encryptionService.isConfigured()) {
throw new AppError({
message:
'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.',
status: 422,
service: SERVICE_NAME,
method: "encryptDockerTls",
});
}
body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey);
body.dockerTlsKeySet = true;
};
}
@@ -7,6 +7,8 @@ import type { IIncidentsRepository } from "../../../src/domain/incidents/inciden
import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts";
import type { IEncryptionService } from "../../../src/service/encryption/encryptionService.ts";
import { AppError } from "../../../src/utils/AppError.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const createMonitorsRepositoryMock = () =>
@@ -70,6 +72,17 @@ const createJobQueueMock = () => ({
deleteJob: jest.fn(),
});
const createEncryptionServiceMock = (overrides: Partial<IEncryptionService> = {}) =>
({
isConfigured: jest.fn(() => true),
currentKeyId: jest.fn(() => "key-1"),
keyIdOf: jest.fn(() => "key-1"),
needsReencryption: jest.fn(() => false),
encrypt: jest.fn((plaintext: string) => `enc:${plaintext}`),
decrypt: jest.fn((ciphertext: string) => ciphertext.replace(/^enc:/, "")),
...overrides,
}) as unknown as IEncryptionService;
const TEAM_ID = "team-1";
const MONITOR_ID = "monitor-1";
const USER_ID = "user-1";
@@ -98,6 +111,7 @@ const createService = (
geoChecksRepository?: IGeoChecksRepository;
dockerLogsRepository?: IDockerLogsRepository;
incidentsRepository?: IIncidentsRepository;
encryptionService?: IEncryptionService;
jobQueue?: ReturnType<typeof createJobQueueMock>;
logger?: ReturnType<typeof createMockLogger>;
games?: Record<string, unknown>;
@@ -116,6 +130,7 @@ const createService = (
monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(),
statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(),
incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(),
encryptionService: overrides.encryptionService ?? createEncryptionServiceMock(),
});
return { service, jobQueue, logger };
};
@@ -1893,6 +1908,7 @@ describe("MonitorService", () => {
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: createEncryptionServiceMock(),
});
const result = await service.deleteAllMonitors({ teamId: TEAM_ID });
@@ -1981,3 +1997,167 @@ describe("MonitorService", () => {
});
});
});
describe("MonitorService — Docker TLS credentials", () => {
const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----";
const tlsBody = { type: "docker" as const, url: "tcp://host:2376", dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: KEY_PEM };
const expectAppError = async (fn: () => Promise<unknown>, status: number, message: string | RegExp) => {
let caught: unknown;
try {
await fn();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(AppError);
expect((caught as AppError).status).toBe(status);
expect((caught as AppError).message).toMatch(message);
};
describe("createMonitor", () => {
it("encrypts the key and sets dockerTlsKeySet for a TLS url", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any);
expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM);
expect(monitorsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
TEAM_ID,
USER_ID
);
});
it("does not encrypt anything for a socket url and leaves dockerTlsKeySet false", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock" }) as any);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
const [created] = (monitorsRepository.create as jest.Mock).mock.calls[0] as [Record<string, unknown>];
expect(created.dockerTlsKeySet).toBe(false);
expect(created.dockerTlsKey).toBeUndefined();
});
it("ignores TLS fields on non-docker monitors", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor());
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ dockerTlsKey: KEY_PEM }) as any);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ dockerTlsKey: KEY_PEM }), TEAM_ID, USER_ID);
});
it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any });
const { service } = createService({ monitorsRepository, encryptionService });
await expectAppError(() => service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any), 422, /ENCRYPTION_KEY/);
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.create).not.toHaveBeenCalled();
});
});
describe("editMonitor", () => {
it("keeps the stored key when the key field is blank", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: true }));
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } });
expect(encryptionService.encrypt).not.toHaveBeenCalled();
const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record<string, unknown>];
expect(patch).not.toHaveProperty("dockerTlsKey");
expect(patch).not.toHaveProperty("dockerTlsKeySet");
expect(patch).toMatchObject({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem" });
});
it("rejects a blank key with a 422 when no key is stored", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: false }));
const { service } = createService({ monitorsRepository });
await expectAppError(
() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }),
422,
/required for a TLS Docker host/
);
expect(monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("encrypts a replacement key without reading the stored monitor", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody });
expect(monitorsRepository.findById).not.toHaveBeenCalled();
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
MONITOR_ID,
TEAM_ID,
expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
{ unsetProxyId: false }
);
});
it("encrypts a replacement key when the body omits the url", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { type: "docker", dockerTlsKey: KEY_PEM } });
expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM);
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
MONITOR_ID,
TEAM_ID,
expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }),
{ unsetProxyId: false }
);
});
it("clears dockerTlsKeySet when the url changes back to a socket", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
const encryptionService = createEncryptionServiceMock();
const { service } = createService({ monitorsRepository, encryptionService });
await service.editMonitor({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
body: { type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKey: KEY_PEM },
});
expect(encryptionService.encrypt).not.toHaveBeenCalled();
expect(monitorsRepository.findById).not.toHaveBeenCalled();
const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record<string, unknown>];
expect(patch.dockerTlsKeySet).toBe(false);
expect(patch.dockerTlsKey).toBeUndefined();
});
it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any });
const { service } = createService({ monitorsRepository, encryptionService });
await expectAppError(() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }), 422, /ENCRYPTION_KEY/);
expect(monitorsRepository.updateById).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { readFileSync } from "node:fs";
import {
createMonitorBodyValidation,
editMonitorBodyValidation,
@@ -621,8 +622,10 @@ describe("monitorValidation — Docker host url", () => {
});
it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => {
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8");
const tlsFields = { dockerTlsCa: fixture("ca.pem"), dockerTlsCert: fixture("client-cert.pem"), dockerTlsKey: fixture("client-key.pem") };
for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) {
expect(createMonitorBodyValidation.parse({ ...baseDockerBody, url }).url).toBe(url);
expect(createMonitorBodyValidation.parse({ ...baseDockerBody, ...tlsFields, url }).url).toBe(url);
}
});
@@ -673,3 +676,126 @@ describe("monitorValidation — Docker host url", () => {
});
});
});
describe("monitorValidation — Docker TLS credentials", () => {
const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8");
const CA = fixture("ca.pem");
const CLIENT_CERT = fixture("client-cert.pem");
const CLIENT_KEY = fixture("client-key.pem");
const OTHER_KEY = fixture("other-key.pem");
const ENCRYPTED_KEY = fixture("encrypted-key.pem");
const baseTlsBody = {
name: "Docker TLS check",
type: "docker" as const,
url: "tcp://host:2376",
};
const fullTlsBody = { ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: CLIENT_KEY };
const issuesFor = (result: ReturnType<typeof createMonitorBodyValidation.safeParse>) =>
result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
describe("createMonitorBodyValidation", () => {
it("accepts a CA, client certificate, and matching key", () => {
const parsed = createMonitorBodyValidation.parse(fullTlsBody);
expect(parsed.dockerTlsCa).toBe(CA);
expect(parsed.dockerTlsCert).toBe(CLIENT_CERT);
expect(parsed.dockerTlsKey).toBe(CLIENT_KEY);
});
it("does not require TLS fields for a socket url", () => {
expect(createMonitorBodyValidation.safeParse({ ...baseTlsBody, url: "unix:///var/run/docker.sock" }).success).toBe(true);
});
it("requires the client certificate, key, and CA for a TLS url", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse(baseTlsBody));
expect(issues).toEqual(
expect.arrayContaining([
{ path: "dockerTlsCert", message: "TLS certificate is required for a TLS Docker host" },
{ path: "dockerTlsKey", message: "TLS key is required for a TLS Docker host" },
{ path: "dockerTlsCa", message: "CA certificate is required unless TLS errors are ignored" },
])
);
});
it("makes the CA optional when TLS errors are ignored", () => {
const result = createMonitorBodyValidation.safeParse({
...baseTlsBody,
ignoreTlsErrors: true,
dockerTlsCert: CLIENT_CERT,
dockerTlsKey: CLIENT_KEY,
});
expect(result.success).toBe(true);
});
it("still requires the client certificate and key when TLS errors are ignored", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...baseTlsBody, ignoreTlsErrors: true }));
expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsKey"]));
expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsCa");
});
it("rejects a CA that is not a PEM certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: "not a certificate" }));
expect(issues).toEqual([{ path: "dockerTlsCa", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]);
});
it("rejects a client certificate that is not a PEM certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCert: "not a certificate" }));
expect(issues).toEqual([{ path: "dockerTlsCert", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]);
});
it("rejects a key that is not a PEM private key", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: "not a key" }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Private key is not valid PEM" }]);
});
it("rejects an encrypted private key", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: ENCRYPTED_KEY }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Encrypted private keys are not supported; remove the passphrase first" }]);
});
it("rejects a key that does not match the client certificate", () => {
const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: OTHER_KEY }));
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]);
});
it("accepts a CA bundle with more than one certificate", () => {
const result = createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: `${CA}\n${CLIENT_CERT}` });
expect(result.success).toBe(true);
});
});
describe("editMonitorBodyValidation", () => {
it("accepts a TLS url without a key so a stored key is kept", () => {
const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT });
expect(result.success).toBe(true);
});
it("accepts a blank key so a stored key is kept", () => {
const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: "" });
expect(result.success).toBe(true);
});
it("still requires the client certificate and CA", () => {
const issues = issuesFor(editMonitorBodyValidation.safeParse(baseTlsBody));
expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsCa"]));
expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsKey");
});
it("validates a replacement key against the client certificate", () => {
const issues = issuesFor(
editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: OTHER_KEY })
);
expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]);
});
});
describe("importMonitorsBodyValidation", () => {
it("strips TLS credentials from imported monitors instead of validating them", () => {
const parsed = importMonitorsBodyValidation.parse({ monitors: [fullTlsBody] });
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCa");
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCert");
expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsKey");
});
});
});