diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 2dfee1239..6dea5b856 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -3,6 +3,7 @@ import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js"; import { DnsRecordTypes, + HttpMethods, HttpStatusCodeSet, MonitorMatchMethods, MonitorStatuses, @@ -70,6 +71,16 @@ const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.R } }; +const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boolean; jsonPath?: string }, ctx: z.RefinementCtx) => { + if (body.method === "HEAD" && (body.useAdvancedMatching === true || (body.jsonPath ?? "") !== "")) { + ctx.addIssue({ + code: "custom", + path: ["method"], + message: "HEAD requests have no response body, so they cannot use advanced matching or a JSON path", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -95,6 +106,7 @@ export const createMonitorBodyValidation = z jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional(), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), strategy: z.enum(PageSpeedStrategies).optional(), @@ -107,7 +119,8 @@ export const createMonitorBodyValidation = z dnsRecordType: z.enum(DnsRecordTypes).optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const editMonitorBodyValidation = z .object({ @@ -127,6 +140,7 @@ export const editMonitorBodyValidation = z jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional(), port: z.number().min(1).max(65535).optional(), cpuAlertThreshold: z.number().optional(), memoryAlertThreshold: z.number().optional(), @@ -144,7 +158,8 @@ export const editMonitorBodyValidation = z dnsRecordType: z.enum(DnsRecordTypes).optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -184,6 +199,7 @@ const importedMonitorSchema = z jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional().default("GET"), url: z.string().min(1, "URL is required"), port: z.number().optional(), isActive: z.boolean().default(true), @@ -215,7 +231,8 @@ const importedMonitorSchema = z updatedAt: z.string().optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -251,6 +268,7 @@ export const monitorResponseSchema = z jsonPath: z.string().optional(), expectedValue: z.string().optional(), matchMethod: z.enum(MonitorMatchMethods).optional(), + method: z.enum(HttpMethods), notifications: z.array(z.string()), tags: z.array(z.string()), customUpCodes: z.array(httpStatusCode).optional(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 417599d2c..8a095658c 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js"; -import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/domain/monitors/monitor.types.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js"; import type { CheckAudits, CheckCaptureInfo, @@ -221,6 +221,12 @@ const MonitorSchema = new Schema( description: { type: String, }, + method: { + type: String, + enum: HttpMethods, + default: "GET", + required: true, + }, status: { type: String, enum: MonitorStatuses, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 0ce0d48a6..d55fdd62d 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -536,6 +536,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, + method: doc.method, status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, @@ -602,6 +603,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, + method: doc.method, status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, diff --git a/server/src/domain/monitors/monitor.types.ts b/server/src/domain/monitors/monitor.types.ts index b614d3191..22090f68c 100644 --- a/server/src/domain/monitors/monitor.types.ts +++ b/server/src/domain/monitors/monitor.types.ts @@ -62,6 +62,9 @@ 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 HttpMethods = ["GET", "HEAD"] as const; +export type HttpMethod = (typeof HttpMethods)[number]; + export const MAX_RECENT_CHECKS = 50; export interface Monitor { @@ -70,6 +73,7 @@ export interface Monitor { teamId: string; name: string; description?: string; + method: HttpMethod; status: MonitorStatus; statusWindow: boolean[]; statusWindowSize: number; diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index 9f638dbd6..2f08dab00 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -53,6 +53,22 @@ export class HttpProvider implements IStatusProvider { } ): MonitorStatusResponse { const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts; + + // Return early for HEAD requests, no body to parse + if (monitor.method === "HEAD") { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: statusUp, + code: statusCode, + message, + responseTime, + timings, + payload: body as T | string, + }; + } + const isJson = contentType.includes("application/json"); if (monitor.jsonPath && !isJson) { @@ -155,6 +171,8 @@ export class HttpProvider implements IStatusProvider { https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, }; + options.method = monitor.method; + try { const response = await this.got(url, options); const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 10b66a93a..7556f7595 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -433,4 +433,65 @@ describe("HttpProvider", () => { 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(); + }); + }); }); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 08f516f99..d7a68b00b 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -227,6 +227,57 @@ describe("monitorValidation — strategy gating", () => { }); }); +describe("monitorValidation — HEAD method gating", () => { + const baseHead = { + name: "HEAD check", + type: "http" as const, + url: "https://example.com", + method: "HEAD" as const, + }; + + describe("createMonitorBodyValidation", () => { + it("accepts a HEAD monitor with no body matching", () => { + const parsed = createMonitorBodyValidation.parse(baseHead); + expect(parsed.method).toBe("HEAD"); + }); + + it("accepts GET together with advanced matching", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHead, + method: "GET", + useAdvancedMatching: true, + jsonPath: "status", + }); + expect(parsed.method).toBe("GET"); + }); + + it("rejects HEAD combined with advanced matching", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHead, useAdvancedMatching: true })).toThrow(); + }); + + it("rejects HEAD combined with a non-empty jsonPath", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "status" })).toThrow(); + }); + + it("allows HEAD with an empty-string jsonPath", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "" }); + expect(parsed.method).toBe("HEAD"); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("rejects HEAD combined with advanced matching", () => { + expect(() => editMonitorBodyValidation.parse({ method: "HEAD", useAdvancedMatching: true })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("rejects HEAD combined with a jsonPath on imported monitors", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHead, jsonPath: "status" }] })).toThrow(); + }); + }); +}); + describe("monitorValidation — customUpCodes", () => { const baseHttpBody = { name: "HTTP check",