Files
Checkmate/server/test/unit/providers/network/dnsProvider.test.ts
T
Craig Lawson 3dc529733a Address the inline review on the egress check pipeline
Distinguish "the peer answered" from "nothing answered", widen attribution to
every check that leaves the instance, make the maintenance exit explicit, and
fix the recovery poll cadence.

Providers can now say outright whether the peer answered, via peerResponded on
MonitorStatusResponse. Previously the producer inferred it from an HTTP status
code, which two providers do not report:

- GrpcProvider: NOT_SERVING shares NETWORK_ERROR with a connection failure, and
  gRPC codes 0-16 are not HTTP statuses, so PERMISSION_DENIED, UNIMPLEMENTED
  and the rest were all read as transport failures. Only DEADLINE_EXCEEDED and
  UNAVAILABLE, or a failure carrying no gRPC code, now count as unreachable.
- DNSProvider: every resolver error became NETWORK_ERROR, so an authoritative
  NXDOMAIN, SERVFAIL or REFUSED was read as unreachable. The c-ares codes where
  the server replied are now separated from timeout and connection refused.

Left unset, the HTTP-status heuristic still applies, so other providers are
unchanged.

Attribution now follows whether the check leaves the instance, not the monitor
type. A hardware check is an ordinary outbound HTTP request to the Capture
agent, and a Docker daemon can be reached over TCP, so both are attributable;
only a Docker daemon on a unix socket is local and exempt.

Leaving a maintenance window is now explicit in the producer, alongside the
entry it already owned. It was a side effect of evaluating a check, which the
degraded-egress short-circuit skips, so a monitor whose window ended during an
outage kept reporting "maintenance" for the length of it.

The recovery poll interval is fixed at 5 seconds and no longer configurable.
While degraded, checks are flagged from stored state without probing, so the
interval is the window in which a recovered instance still discards its checks;
the only effect of raising it was a longer blind window. egressPollIntervalSeconds
is removed from the settings, the validator and the OpenAPI spec.

🔧
2026-09-17 22:06:34 +01: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/network/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");
});
});