From 0d720710194a2fe3d1cb13ba5bbac49fbf5c0808 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:11 -0700 Subject: [PATCH 01/12] add dns types --- server/src/types/monitor.ts | 4 +++- server/src/types/network.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 2c2f9c08f..6e2747e1a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js"; import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js"; export type { GeoContinent } from "@/types/geoCheck.js"; -export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; @@ -54,6 +54,8 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: string; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 6c7172724..d87b62395 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -116,6 +116,14 @@ export interface WebSocketStatusPayload { connected: boolean; } +export interface DNSStatusPayload { + hostname: string; + dnsServer: string; + recordType: string; + resolved: boolean; + results: unknown; +} + export interface MonitorPayloadMap { ping: PingStatusPayload; http: HttpStatusPayload; From c6d86d78b852bc20f01d9577031e74dfcceae1d1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:25 -0700 Subject: [PATCH 02/12] Add DNS provider --- .../infrastructure/network/DNSProvider.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 server/src/service/infrastructure/network/DNSProvider.ts diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts new file mode 100644 index 000000000..c2cf21705 --- /dev/null +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -0,0 +1,96 @@ +import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { Resolver } from "dns/promises"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +const SERVICE_NAME = "DNSProvider"; + +export class DNSProvider implements IStatusProvider { + readonly type = "dns"; + constructor(private resolver: Resolver) {} + supports(type: MonitorType) { + return type === "dns"; + } + + async handle(monitor: Monitor): Promise> { + try { + const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor; + if (!dnsServer) { + throw new Error("DNS server is required for DNS monitoring"); + } + + const resolver = new Resolver(); + resolver.setServers([dnsServer]); + + const { + response: results, + responseTime, + error, + } = await timeRequest(() => { + switch (dnsRecordType.toUpperCase()) { + case "A": + return resolver.resolve4(hostname); + case "AAAA": + return resolver.resolve6(hostname); + case "CNAME": + return resolver.resolveCname(hostname); + case "MX": + return resolver.resolveMx(hostname); + case "TXT": + return resolver.resolveTxt(hostname); + case "NS": + return resolver.resolveNs(hostname); + default: + return resolver.resolve(hostname, dnsRecordType); + } + }); + + if (error) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: error instanceof Error ? error.message : String(error), + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: false, + results, + }, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: true, + code: 200, + message: "Success", + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: true, + results, + }, + }; + } catch (error: unknown) { + const originalMessage = error instanceof Error ? error.message : String(error); + throw new AppError({ + message: originalMessage || "Error performing DNS check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + } + } +} From fda9d9edfe2f4a6c58305beb80d0af7b6fb3d533 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:31 -0700 Subject: [PATCH 03/12] add tests --- .../providers/network/dnsProvider.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 server/test/unit/providers/network/dnsProvider.test.ts diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts new file mode 100644 index 000000000..92c874836 --- /dev/null +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolverInstance = { + setServers: jest.fn(), + resolve4: jest.fn<() => Promise>(), + resolve6: jest.fn<() => Promise>(), + resolveCname: jest.fn<() => Promise>(), + resolveMx: jest.fn<() => Promise>(), + resolveTxt: jest.fn<() => Promise>(), + resolveNs: jest.fn<() => Promise>(), + resolve: jest.fn<() => Promise>(), +}; + +const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); + +jest.unstable_mockModule("dns/promises", () => ({ + Resolver: ResolverMock, +})); + +const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "dns", + url: "example.com", + dnsServer: "8.8.8.8", + dnsRecordType: "A", + ...overrides, + }) as Monitor; + +beforeEach(() => { + ResolverMock.mockClear(); + mockResolverInstance.setServers.mockClear(); + mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); + mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); + mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]); + mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]); + mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]); + mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]); + mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]); +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DNSProvider", { + create: () => new DNSProvider({} as any), + supportedType: "dns", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DNSProvider", () => { + it("returns success with results and payload on resolve", async () => { + mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: true, + code: 200, + message: "Success", + payload: { + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: true, + results: ["1.2.3.4"], + }, + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("calls setServers with the configured dnsServer", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); + expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); + }); + + it("defaults to A record when dnsRecordType is not provided", async () => { + const provider = new DNSProvider({} as any); + const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + expect(result.payload?.recordType).toBe("A"); + }); + + it("dispatches AAAA records to resolve6", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); + expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches CNAME records to resolveCname", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); + expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches MX records to resolveMx", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "MX" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches TXT records to resolveTxt", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); + expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches NS records to resolveNs", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "NS" })); + expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); + }); + + it("falls back to generic resolve for unrecognized record types", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); + expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); + }); + + it("treats record type case-insensitively", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "mx" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { + mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: false, + code: NETWORK_ERROR, + message: "ENOTFOUND", + payload: expect.objectContaining({ + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: false, + results: null, + }), + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("stringifies non-Error rejections in failure response", async () => { + mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("DNS bombed"); + }); + + it("throws AppError when dnsServer is missing", async () => { + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); + }); + + it("falls back to default message when underlying error has empty message", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw new Error(""); + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); + }); + + it("stringifies non-Error throws from outside timeRequest", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw 42; + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); From 0797008ac9d9dc12f73f5a85aa1c268ca4ab8d42 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:02 -0700 Subject: [PATCH 04/12] add dns record type --- server/src/types/monitor.ts | 6 +++++- server/src/types/network.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 6e2747e1a..09797f2ca 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; + +export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; +export type DnsRecordType = (typeof DnsRecordTypes)[number]; + export const MAX_RECENT_CHECKS = 50; export interface Monitor { @@ -55,7 +59,7 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dnsServer?: string; - dnsRecordType?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index d87b62395..3269806d8 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -134,6 +134,7 @@ export interface MonitorPayloadMap { game: GameStatusPayload; grpc: GrpcStatusPayload; websocket: WebSocketStatusPayload; + dns: DNSStatusPayload; unknown: unknown; } From 06ef850577779cb2bd5f917fc445b92624bab647 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:33 -0700 Subject: [PATCH 05/12] update validation to allow for dnsServer and dnsRecord --- server/src/validation/monitorValidation.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 57099e7ff..0960e8099 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -78,6 +78,8 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const editMonitorBodyValidation = z.object({ @@ -107,6 +109,8 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const pauseMonitorParamValidation = z.object({ @@ -160,6 +164,8 @@ const importedMonitorSchema = z.object({ geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), }); @@ -211,6 +217,8 @@ export const monitorResponseSchema = z geoCheckEnabled: z.boolean(), geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), userId: z.string(), createdAt: z.string(), From cb9cc101c09a96a9ba9e95994c393f4bdf01b7ce Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:18 -0700 Subject: [PATCH 06/12] add dnsServer and dnsRecordType to monitor model --- server/src/db/models/Monitor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad..e1a2f4f91 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -351,6 +351,13 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dnsServer: { + type: String, + }, + dnsRecordType: { + type: String, + enum: DnsRecordTypes, + }, recentChecks: { type: [checkSnapshotSchema], default: [], From 2a7bde95749b4ba841d3dec4584ecbe492d26db1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:44 -0700 Subject: [PATCH 07/12] update toEntity methods to extract dnsServer and dnsRecord --- server/src/repositories/monitors/MongoMonitorsRepository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..93846aeec 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -418,6 +418,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -477,6 +479,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; From 0c36c8e6251d1ea193ba407b12f6e9bfec205875 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:58 -0700 Subject: [PATCH 08/12] tests --- server/jest.config.ts | 1 + server/src/config/services.ts | 4 + .../unit/validation/monitorValidation.test.ts | 86 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 server/test/unit/validation/monitorValidation.test.ts diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b6cbb3a30..c00f91c72 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js"; +import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js"; // Third-party +import { Resolver } from "dns/promises"; import axios from "axios"; import got from "got"; import ping from "ping"; @@ -226,6 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); + const dnsProvider = new DNSProvider(new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, @@ -237,6 +240,7 @@ export const initializeServices = async ({ gameProvider, grpcProvider, webSocketProvider, + dnsProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts new file mode 100644 index 000000000..5bd178f51 --- /dev/null +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@jest/globals"; +import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts"; + +const baseDnsBody = { + name: "DNS check", + type: "dns" as const, + url: "example.com", +}; + +describe("monitorValidation — DNS fields", () => { + describe("createMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on a DNS monitor", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }); + + expect(parsed.dnsServer).toBe("8.8.8.8"); + expect(parsed.dnsRecordType).toBe("A"); + }); + + it("accepts every supported DNS record type", () => { + for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "1.1.1.1", + dnsRecordType: recordType, + }); + expect(parsed.dnsRecordType).toBe(recordType); + } + }); + + it("rejects unknown DNS record types", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "BOGUS", + }) + ).toThrow(); + }); + + it("treats DNS fields as optional (other monitor types still validate)", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + + expect(parsed.dnsServer).toBeUndefined(); + expect(parsed.dnsRecordType).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + dnsServer: "1.1.1.1", + dnsRecordType: "MX", + }); + + expect(parsed.dnsServer).toBe("1.1.1.1"); + expect(parsed.dnsRecordType).toBe("MX"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains dnsServer and dnsRecordType on imported DNS monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported DNS", + type: "dns", + url: "example.com", + dnsServer: "8.8.4.4", + dnsRecordType: "TXT", + }, + ], + }); + + expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4"); + expect(parsed.monitors[0].dnsRecordType).toBe("TXT"); + }); + }); +}); From 172d36ba1d9706849383788a9c2f4875088d8614 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:47 -0700 Subject: [PATCH 09/12] use injected Resolver --- .../infrastructure/network/DNSProvider.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index c2cf21705..4ad212e62 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -21,8 +21,7 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - const resolver = new Resolver(); - resolver.setServers([dnsServer]); + this.resolver.setServers([dnsServer]); const { response: results, @@ -31,19 +30,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return resolver.resolve4(hostname); + return this.resolver.resolve4(hostname); case "AAAA": - return resolver.resolve6(hostname); + return this.resolver.resolve6(hostname); case "CNAME": - return resolver.resolveCname(hostname); + return this.resolver.resolveCname(hostname); case "MX": - return resolver.resolveMx(hostname); + return this.resolver.resolveMx(hostname); case "TXT": - return resolver.resolveTxt(hostname); + return this.resolver.resolveTxt(hostname); case "NS": - return resolver.resolveNs(hostname); + return this.resolver.resolveNs(hostname); default: - return resolver.resolve(hostname, dnsRecordType); + return this.resolver.resolve(hostname, dnsRecordType); } }); From 4b4fae91c287efdab5d5054bd5919cf90a77c3b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:56 -0700 Subject: [PATCH 10/12] use enums for validation --- server/src/types/network.ts | 3 ++- server/src/validation/monitorValidation.ts | 14 ++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 3269806d8..19f063cc6 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -8,6 +8,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, + DnsRecordType, Monitor, MonitorMatchMethod, MonitorType, @@ -119,7 +120,7 @@ export interface WebSocketStatusPayload { export interface DNSStatusPayload { hostname: string; dnsServer: string; - recordType: string; + recordType: DnsRecordType; resolved: boolean; results: unknown; } diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 0960e8099..882c7353a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), }); @@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ filter: z.union([z.string(), z.literal("")]).optional(), field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), explain: booleanCoercion.optional(), }); From 0f662e1a65a213f5b1a82e94ba29cf4752bfdda3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:04:18 -0700 Subject: [PATCH 11/12] update tests --- .../providers/network/dnsProvider.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index 92c874836..ba38fa5d2 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -52,7 +52,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider({} as any), + create: () => new DNSProvider(mockResolverInstance as any), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +63,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -88,63 +88,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -170,7 +170,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -179,7 +179,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +188,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +197,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); }); From fc711e8e24333dc272e0ceba11d50803f455c5ee Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:15:45 -0700 Subject: [PATCH 12/12] per call resolver --- server/src/config/services.ts | 2 +- .../infrastructure/network/DNSProvider.ts | 21 +++++----- .../providers/network/dnsProvider.test.ts | 42 ++++++++----------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c00f91c72..309f55242 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -228,7 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); - const dnsProvider = new DNSProvider(new Resolver()); + const dnsProvider = new DNSProvider(() => new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index 4ad212e62..64e473e88 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -1,6 +1,6 @@ import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; -import { Resolver } from "dns/promises"; +import type { Resolver } from "dns/promises"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; @@ -9,7 +9,7 @@ const SERVICE_NAME = "DNSProvider"; export class DNSProvider implements IStatusProvider { readonly type = "dns"; - constructor(private resolver: Resolver) {} + constructor(private createResolver: () => Resolver) {} supports(type: MonitorType) { return type === "dns"; } @@ -21,7 +21,8 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - this.resolver.setServers([dnsServer]); + const resolver = this.createResolver(); + resolver.setServers([dnsServer]); const { response: results, @@ -30,19 +31,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return this.resolver.resolve4(hostname); + return resolver.resolve4(hostname); case "AAAA": - return this.resolver.resolve6(hostname); + return resolver.resolve6(hostname); case "CNAME": - return this.resolver.resolveCname(hostname); + return resolver.resolveCname(hostname); case "MX": - return this.resolver.resolveMx(hostname); + return resolver.resolveMx(hostname); case "TXT": - return this.resolver.resolveTxt(hostname); + return resolver.resolveTxt(hostname); case "NS": - return this.resolver.resolveNs(hostname); + return resolver.resolveNs(hostname); default: - return this.resolver.resolve(hostname, dnsRecordType); + return resolver.resolve(hostname, dnsRecordType); } }); diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index ba38fa5d2..3fcea5113 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; import type { Monitor } from "../../../../src/types/index.ts"; +import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -16,13 +17,7 @@ const mockResolverInstance = { resolve: jest.fn<() => Promise>(), }; -const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); - -jest.unstable_mockModule("dns/promises", () => ({ - Resolver: ResolverMock, -})); - -const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); +const createResolver = () => mockResolverInstance as any; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -38,7 +33,6 @@ const makeMonitor = (overrides?: Partial): Monitor => }) as Monitor; beforeEach(() => { - ResolverMock.mockClear(); mockResolverInstance.setServers.mockClear(); mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); @@ -52,7 +46,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider(mockResolverInstance as any), + create: () => new DNSProvider(createResolver), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +57,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -88,63 +82,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -170,7 +164,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -179,7 +173,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +182,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +191,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); });