add tests and migration

This commit is contained in:
Alex Holliday committed 2026-08-28 14:42:46 -07:00
1 parent e60bfd18ff
commit e964234f54
5 files changed
+230 -2

No files matched your search

@@ -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<void> {
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;
}
}
+2
View File
@@ -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) => {
@@ -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) ─────────────────────────
@@ -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();
@@ -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();
});
});
});