From e964234f544ca34cc57954e3455a8059e2b3aa13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:42:46 -0700 Subject: [PATCH] add tests and migration --- .../0012_migrateDockerMonitorUrls.ts | 36 ++++++++ server/src/db/migration/index.ts | 2 + .../test/unit/services/checkService.test.ts | 27 ++++++ .../test/unit/services/monitorService.test.ts | 92 ++++++++++++++++++- .../unit/validation/monitorValidation.test.ts | 75 +++++++++++++++ 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 server/src/db/migration/0012_migrateDockerMonitorUrls.ts diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts new file mode 100644 index 000000000..75f3d5acf --- /dev/null +++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts @@ -0,0 +1,36 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Rewrite docker monitor urls from the old container-name semantics to host urls. + * + * The old docker provider interpreted monitor.url as a container name/ID and only + * ever talked to the local socket, so every pre-existing docker monitor's host is + * definitionally unix:///var/run/docker.sock. The container name is preserved in + * the description. Urls already in host form (unix://, ssh://, or an absolute + * socket path) are left untouched, which makes the migration idempotent. + */ +export async function migrateDockerMonitorUrls(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" }); + + const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [ + { + $set: { + description: { + $concat: [{ $ifNull: ["$description", ""] }, " (was container: ", "$url", ")"], + }, + url: "unix:///var/run/docker.sock", + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Migrated ${result.modifiedCount} docker monitors to host urls` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error migrating docker monitor urls: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 94829241d..c4233948c 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -10,6 +10,7 @@ import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvalua import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import { slimRecentChecks } from "./0010_slimRecentChecks.js"; import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; +import { migrateDockerMonitorUrls } from "./0012_migrateDockerMonitorUrls.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -29,6 +30,7 @@ const migrations: MigrationEntry[] = [ { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, { name: "0010_slimRecentChecks", execute: slimRecentChecks }, { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, + { name: "0012_migrateDockerMonitorUrls", execute: migrateDockerMonitorUrls }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts index 80b941b6f..4ced65947 100644 --- a/server/test/unit/services/checkService.test.ts +++ b/server/test/unit/services/checkService.test.ts @@ -252,6 +252,33 @@ describe("CheckService", () => { expect(check!.errors).toBeUndefined(); }); }); + + describe("docker type", () => { + it("extracts containers and containerSummary from payload", () => { + const payload = { + containers: [ + { id: "abc", name: "web", image: "nginx:latest", state: "running", status: "Up 3 hours", health: "healthy" }, + { id: "def", name: "db", image: "mongo:8.0", state: "exited", status: "Exited (0)", health: "none" }, + ], + summary: { total: 2, running: 1, stopped: 1, unhealthy: 0 }, + } as any; + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload } as any)); + + expect(check!.containers).toHaveLength(2); + expect(check!.containers![0]).toMatchObject({ id: "abc", state: "running", health: "healthy" }); + expect(check!.containerSummary).toEqual({ total: 2, running: 1, stopped: 1, unhealthy: 0 }); + }); + + it("handles a null payload gracefully (down check)", () => { + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload: null } as any)); + + expect(check).toBeDefined(); + expect(check!.containers).toBeUndefined(); + expect(check!.containerSummary).toBeUndefined(); + }); + }); }); // ── toStatusResponse round-trip (Phase 2 §5 guard) ───────────────────────── diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index fcfdf533d..c725fcd10 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -237,8 +237,8 @@ describe("MonitorService", () => { expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); }); - it("supports all uptime monitor types: ping, docker, port, game, grpc, websocket", async () => { - for (const monitorType of ["ping", "docker", "port", "game", "grpc", "websocket"] as const) { + it("supports all uptime monitor types: ping, port, game, grpc, websocket, dns", async () => { + for (const monitorType of ["ping", "port", "game", "grpc", "websocket", "dns"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); const checksRepository = createChecksRepositoryMock(); const monitorStatsRepository = createMonitorStatsRepositoryMock(); @@ -314,6 +314,27 @@ describe("MonitorService", () => { ); }); + it("throws 400 for unsupported monitor type (docker)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 0 }, + upChecks: { totalChecks: 0 }, + aggregate: [], + latest: null, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + it("uses different date ranges correctly", async () => { for (const dateRange of ["recent", "day", "week", "month", "all"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); @@ -398,6 +419,73 @@ describe("MonitorService", () => { }); }); + describe("getDockerDetailsById", () => { + it("returns docker details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 10 }, + upChecks: { totalChecks: 9 }, + aggregate: [{ _id: "2024-01-01T00:00:00Z", avgResponseTime: 12, upCount: 9, totalCount: 10, avgRunning: 3, avgTotal: 4, avgUnhealthy: 0 }], + latest: { + containers: [], + summary: { total: 4, running: 3, stopped: 1, unhealthy: 0 }, + checkedAt: "2024-01-01T00:00:00Z", + }, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats).toHaveProperty("aggregateData"); + expect(result.stats).toHaveProperty("upChecks"); + expect(result.stats).toHaveProperty("aggregate"); + expect(result.stats).toHaveProperty("latest"); + expect(result.stats.latest?.summary).toMatchObject({ total: 4, running: 3, stopped: 1, unhealthy: 0 }); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for docker details" + ); + }); + + it("throws 500 when checksData monitorType is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load docker stats for this monitor" + ); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index ea2257072..2b6f994d7 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -565,3 +565,78 @@ describe("monitorValidation — proxy fields", () => { }); }); }); + +describe("monitorValidation — Docker host url", () => { + const baseDockerBody = { + name: "Docker host check", + type: "docker" as const, + url: "unix:///var/run/docker.sock", + }; + const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; + + describe("createMonitorBodyValidation", () => { + it("accepts the default unix socket url", () => { + const parsed = createMonitorBodyValidation.parse(baseDockerBody); + expect(parsed.url).toBe("unix:///var/run/docker.sock"); + }); + + it("accepts a bare absolute socket path", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url: "/run/docker.sock" }); + expect(parsed.url).toBe("/run/docker.sock"); + }); + + it("accepts an ssh url with a private key and retains the key", () => { + for (const url of ["ssh://deploy@host", "ssh://deploy@host:2222"]) { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url, sshPrivateKey: PRIVATE_KEY }); + expect(parsed.sshPrivateKey).toBe(PRIVATE_KEY); + } + }); + + it("rejects container names and unsupported engine urls", () => { + for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "unix://relative/path", ""]) { + expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); + } + }); + + it("rejects an ssh url without a user", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: "ssh://host", sshPrivateKey: PRIVATE_KEY })).toThrow(); + }); + + it("rejects an ssh url without a private key, attributing the issue to sshPrivateKey", () => { + const result = createMonitorBodyValidation.safeParse({ ...baseDockerBody, url: "ssh://deploy@host" }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.path.includes("sshPrivateKey"))).toBe(true); + }); + + it("does not apply docker url rules to other monitor types", () => { + const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" }); + expect(parsed.sshPrivateKey).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("allows a docker edit without a url (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ type: "docker", name: "renamed" }); + expect(parsed.url).toBeUndefined(); + }); + + it("rejects a docker edit with an invalid url", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); + }); + + it("rejects a docker edit switching to ssh without a key", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("accepts an imported docker monitor with a socket url", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseDockerBody] }); + expect(parsed.monitors[0].url).toBe("unix:///var/run/docker.sock"); + }); + + it("rejects an imported docker monitor with a container-name url", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseDockerBody, url: "my-container" }] })).toThrow(); + }); + }); +});