Files
Checkmate/server/test/unit/providers/network/httpProvider.test.ts
T
2026-07-29 12:33:56 -07:00

498 lines
17 KiB
TypeScript

import { 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";
// ── Mocks ────────────────────────────────────────────────────────────────────
jest.unstable_mockModule("cacheable-lookup", () => ({
default: jest.fn().mockImplementation(() => ({})),
}));
const mockGot = jest.fn();
// got instance returned by got.extend()
mockGot.mockImplementation(() => Promise.resolve());
(mockGot as any).extend = jest.fn().mockReturnValue(mockGot);
jest.unstable_mockModule("got", () => ({
type: { Got: {} },
HTTPError: class HTTPError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "HTTPError";
this.response = response;
this.timings = timings;
}
},
RequestError: class RequestError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "RequestError";
this.response = response;
this.timings = timings;
}
},
}));
const { HttpProvider } = await import("../../../../src/service/network/HttpProvider.ts");
const gotModule = await import("got");
const { HTTPError, RequestError } = gotModule;
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
url: "https://example.com",
secret: undefined,
jsonPath: undefined,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: undefined,
expectedValue: undefined,
customUpCodes: [],
...overrides,
}) as Monitor;
const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({
validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }),
});
const makeGotResponse = (overrides?: Record<string, any>) => ({
ok: true,
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "text/html" },
body: "<html></html>",
timings: { phases: { firstByte: 100, total: 120 } },
...overrides,
});
const createProvider = (matcher?: any) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher);
return { provider, advancedMatcher };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("HttpProvider", {
create: () => {
mockGot.mockResolvedValue(makeGotResponse());
return createProvider().provider;
},
supportedType: "http",
unsupportedType: "ping",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("HttpProvider", () => {
// ── Success paths ────────────────────────────────────────────────────
describe("success responses", () => {
it("returns success for a standard HTML response", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 120,
})
);
});
it("parses JSON body when content-type is application/json", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: '{"status":"ok"}',
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual({ status: "ok" });
});
it("returns raw body when JSON parsing fails", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: "not-json",
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("not-json");
});
it("returns raw body for non-JSON content", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "<html>test</html>" }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("<html>test</html>");
});
it("passes Authorization header when secret is set", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: "my-token" }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: { Authorization: "Bearer my-token" },
})
);
});
it("passes undefined headers when no secret", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: undefined }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: undefined,
})
);
});
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("defaults statusMessage to 'OK' when undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("OK");
});
it("uses empty string for content-type when header is missing", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: {} }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
// Should treat as non-JSON
expect(result.payload).toBe("<html></html>");
});
});
// ── jsonPath + non-JSON response ─────────────────────────────────────
describe("jsonPath validation", () => {
it("returns failure when jsonPath is set but response is not JSON", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.status).toBe(false);
expect(result.message).toBe("Response is not JSON");
});
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "text/html" },
timings: { phases: { total: undefined } },
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.responseTime).toBe(0);
});
});
// ── AdvancedMatcher integration ──────────────────────────────────────
describe("advanced matching", () => {
it("uses matcher result for status and message", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ useAdvancedMatching: true }));
expect(result.status).toBe(false);
expect(result.message).toBe("Mismatch");
expect(result.extracted).toBe("value");
});
it("sets status to false when status code is non-2xx even if matcher passes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: true, message: "Success" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
});
// ── Error handling ───────────────────────────────────────────────────
describe("error handling", () => {
it("handles HTTPError with response and timings", async () => {
const err = new HTTPError("Not Found");
(err as any).response = { statusCode: 404 };
(err as any).timings = { phases: { total: 50 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: 404,
message: "Not Found",
responseTime: 50,
})
);
});
it("handles RequestError with response and timings", async () => {
const err = new RequestError("ECONNREFUSED");
(err as any).response = { statusCode: undefined };
(err as any).timings = { phases: { total: undefined } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => {
const err = new HTTPError("Timeout");
(err as any).response = undefined;
(err as any).timings = undefined;
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles generic Error (non-HTTPError/RequestError)", async () => {
mockGot.mockRejectedValue(new Error("DNS lookup failed"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "DNS lookup failed",
responseTime: 0,
})
);
});
it("handles non-Error thrown values", async () => {
mockGot.mockRejectedValue("string error");
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("string error");
expect(result.code).toBe(NETWORK_ERROR);
});
it("throws when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
});
});
// ── customUpCodes ────────────────────────────────────────────────────
describe("customUpCodes", () => {
it("returns status true when HTTPError status code is in customUpCodes", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401 };
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [401] }));
expect(result.status).toBe(true);
expect(result.code).toBe(401);
});
it("returns status false when HTTPError status code is not in customUpCodes", async () => {
const err = new HTTPError("Internal Server Error");
(err as any).response = { statusCode: 500 };
(err as any).timings = { phases: { total: 40 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [401, 403] }));
expect(result.status).toBe(false);
expect(result.code).toBe(500);
});
it("returns status false when customUpCodes is empty", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401 };
(err as any).timings = { phases: { total: 25 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [] }));
expect(result.status).toBe(false);
expect(result.code).toBe(401);
});
it("treats non-2xx success response as up when status code is in customUpCodes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301, statusMessage: "Moved Permanently" }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ customUpCodes: [301] }));
expect(result.status).toBe(true);
expect(result.code).toBe(301);
});
it("does not override matcher failure even when status code is in customUpCodes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: false, message: "Body mismatch" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ customUpCodes: [301] }));
expect(result.status).toBe(false);
expect(result.message).toBe("Body mismatch");
});
it("does not override matcher failure on HTTPError when status code is in customUpCodes", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = {
statusCode: 401,
body: '{"status":"down"}',
headers: { "content-type": "application/json" },
};
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const matcher = createMockMatcher({
ok: false,
message: "Body mismatch",
extracted: "down",
});
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ customUpCodes: [401], useAdvancedMatching: true }));
expect(result.status).toBe(false);
expect(result.message).toBe("Body mismatch");
expect(result.payload).toEqual({ status: "down" });
expect(result.extracted).toBe("down");
});
});
// ── HTTP method (GET / HEAD) ─────────────────────────────────────────
describe("request method", () => {
it("passes GET to got when method is GET", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ method: "GET" }));
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" }));
});
it("passes HEAD to got when method is HEAD", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "" }));
const { provider } = createProvider();
await provider.handle(makeMonitor({ method: "HEAD" }));
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" }));
});
it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} }));
// A matcher that would fail — proving HEAD never reaches it
const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" });
const { provider, advancedMatcher } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" }));
expect(result.status).toBe(true);
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
it("bases HEAD status on the status code (down when not an up code)", async () => {
const err = new HTTPError("Internal Server Error");
(err as any).response = { statusCode: 500, body: "", headers: {} };
(err as any).timings = { phases: { total: 40 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ method: "HEAD" }));
expect(result.status).toBe(false);
expect(result.code).toBe(500);
});
it("respects customUpCodes for HEAD requests without consulting the matcher", async () => {
const err = new HTTPError("Unauthorized");
(err as any).response = { statusCode: 401, body: "", headers: {} };
(err as any).timings = { phases: { total: 30 } };
mockGot.mockRejectedValue(err);
const { provider, advancedMatcher } = createProvider();
const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true }));
expect(result.status).toBe(true);
expect(result.code).toBe(401);
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
});
});