264 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|