Files
2026-09-25 11:39:16 -07:00

264 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import { DNSProvider } from "../../../../src/service/networkProviders/DNSProvider.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
const mockResolverInstance = {
setServers: jest.fn(),
resolve4: jest.fn<() => Promise<string[]>>(),
resolve6: jest.fn<() => Promise<string[]>>(),
resolveCname: jest.fn<() => Promise<string[]>>(),
resolveMx: jest.fn<() => Promise<unknown[]>>(),
resolveTxt: jest.fn<() => Promise<string[][]>>(),
resolveNs: jest.fn<() => Promise<string[]>>(),
resolve: jest.fn<() => Promise<unknown>>(),
};
const createResolver = () => mockResolverInstance as any;
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "dns",
url: "example.com",
dnsServer: "8.8.8.8",
dnsRecordType: "A",
...overrides,
}) as Monitor;
beforeEach(() => {
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(createResolver),
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(createResolver);
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(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(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(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "AAAA" }));
expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com");
});
it("dispatches CNAME records to resolveCname", async () => {
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(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "MX" }));
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
});
it("dispatches TXT records to resolveTxt", async () => {
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(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(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(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(createResolver);
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(createResolver);
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(createResolver);
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(createResolver);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
});
it("strips scheme from url before querying the resolver", async () => {
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" }));
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca");
expect(result.payload?.hostname).toBe("www.google.ca");
});
it("strips port and path from url before querying the resolver", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ url: "example.com:53/some/path" }));
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
});
it("passes a bare hostname through unchanged", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ url: "example.com" }));
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
});
// ── Peer reachability ────────────────────────────────────────────────────
// The egress check must only blame the instance's own connectivity when nothing answered. Every DNS failure
// carries NETWORK_ERROR, so `peerResponded` is the only thing separating an authoritative answer from silence.
it.each([
["ENOTFOUND", "NXDOMAIN: the server answered that the name does not exist"],
["ENODATA", "the server answered with no record of that type"],
["ESERVFAIL", "the server answered with a failure"],
["EREFUSED", "the server declined to answer"],
["EFORMERR", "the server rejected the query as malformed"],
["ENOTIMP", "the server does not implement the query"],
["EBADRESP", "the server replied, badly"],
])("reports the peer as having answered for %s", async (code) => {
const error = Object.assign(new Error("dns failure"), { code });
mockResolverInstance.resolve4.mockRejectedValueOnce(error);
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.peerResponded).toBe(true);
});
it.each([["ETIMEOUT"], ["ECONNREFUSED"], ["ECANCELLED"], [undefined]])("reports the peer as unreachable for %s", async (code) => {
const error = code ? Object.assign(new Error("dns failure"), { code }) : new Error("dns failure");
mockResolverInstance.resolve4.mockRejectedValueOnce(error);
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.peerResponded).toBe(false);
});
it("reports the peer as having answered on success", async () => {
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result.peerResponded).toBe(true);
});
it("stringifies non-Error throws from outside timeRequest", async () => {
mockResolverInstance.setServers.mockImplementationOnce(() => {
throw 42;
});
const provider = new DNSProvider(createResolver);
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
});
});