diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 50fa62ec3..0ae463911 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -45,6 +45,7 @@ export const getMonitorDefaults = ( matchMethod: data?.matchMethod || "", expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", + customUpCodes: data?.customUpCodes || [], }; break; case "ping": @@ -138,6 +139,7 @@ export const getMonitorDefaults = ( matchMethod: "", expectedValue: "", jsonPath: "", + customUpCodes: [], }; } diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 94bed9355..465b9d15d 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { useEffect } from "react"; import { logger } from "@/Utils/logger"; +import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -1090,6 +1091,77 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.advanced.description")} rightContent={ + { + const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => + field.value?.includes(option.id) + ); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={( + _: unknown, + newValue: typeof ALL_HTTP_STATUS_CODES + ) => { + field.onChange(newValue?.map((v) => v.id) || []); + }} + fieldLabel={t( + "pages.createMonitor.form.advanced.option.customUpCodes.label" + )} + /> + + {t( + "pages.createMonitor.form.advanced.option.customUpCodes.description" + )} + + {selectedOptions.length > 0 && ( + + {selectedOptions.map((option, index) => ( + + {option.name} + { + field.onChange( + field.value?.filter((id) => id !== option.id) + ); + }} + aria-label={t( + "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" + )} + > + + + {index < selectedOptions.length - 1 && } + + ))} + + )} + + ); + }} + /> { if (code < 400) return "neutral"; return "negative"; }; + +export interface HttpStatusCodeOption { + id: number; + name: string; +} + +/** + * Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts + */ +export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [ + { id: 100, name: "100 - Continue" }, + { id: 101, name: "101 - Switching Protocols" }, + { id: 102, name: "102 - Processing" }, + { id: 103, name: "103 - Early Hints" }, + { id: 200, name: "200 - OK" }, + { id: 201, name: "201 - Created" }, + { id: 202, name: "202 - Accepted" }, + { id: 203, name: "203 - Non-Authoritative Information" }, + { id: 204, name: "204 - No Content" }, + { id: 205, name: "205 - Reset Content" }, + { id: 206, name: "206 - Partial Content" }, + { id: 207, name: "207 - Multi-Status" }, + { id: 208, name: "208 - Already Reported" }, + { id: 226, name: "226 - IM Used" }, + { id: 300, name: "300 - Multiple Choices" }, + { id: 301, name: "301 - Moved Permanently" }, + { id: 302, name: "302 - Found" }, + { id: 303, name: "303 - See Other" }, + { id: 304, name: "304 - Not Modified" }, + { id: 305, name: "305 - Use Proxy" }, + { id: 307, name: "307 - Temporary Redirect" }, + { id: 308, name: "308 - Permanent Redirect" }, + { id: 400, name: "400 - Bad Request" }, + { id: 401, name: "401 - Unauthorized" }, + { id: 402, name: "402 - Payment Required" }, + { id: 403, name: "403 - Forbidden" }, + { id: 404, name: "404 - Not Found" }, + { id: 405, name: "405 - Method Not Allowed" }, + { id: 406, name: "406 - Not Acceptable" }, + { id: 407, name: "407 - Proxy Authentication Required" }, + { id: 408, name: "408 - Request Timeout" }, + { id: 409, name: "409 - Conflict" }, + { id: 410, name: "410 - Gone" }, + { id: 411, name: "411 - Length Required" }, + { id: 412, name: "412 - Precondition Failed" }, + { id: 413, name: "413 - Payload Too Large" }, + { id: 414, name: "414 - URI Too Long" }, + { id: 415, name: "415 - Unsupported Media Type" }, + { id: 416, name: "416 - Range Not Satisfiable" }, + { id: 417, name: "417 - Expectation Failed" }, + { id: 418, name: "418 - I'm a Teapot" }, + { id: 419, name: "419 - Page Expired (Laravel)" }, + { id: 420, name: "420 - Enhance Your Calm (Twitter)" }, + { id: 421, name: "421 - Misdirected Request" }, + { id: 422, name: "422 - Unprocessable Entity" }, + { id: 423, name: "423 - Locked" }, + { id: 424, name: "424 - Failed Dependency" }, + { id: 425, name: "425 - Too Early" }, + { id: 426, name: "426 - Upgrade Required" }, + { id: 428, name: "428 - Precondition Required" }, + { id: 429, name: "429 - Too Many Requests" }, + { id: 431, name: "431 - Request Header Fields Too Large" }, + { id: 440, name: "440 - Login Time-out (IIS)" }, + { id: 449, name: "449 - Retry With (IIS)" }, + { id: 451, name: "451 - Unavailable For Legal Reasons" }, + { id: 460, name: "460 - Client Closed Connection (AWS ELB)" }, + { id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" }, + { id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" }, + { id: 499, name: "499 - Client Closed Request (NGINX)" }, + { id: 500, name: "500 - Internal Server Error" }, + { id: 501, name: "501 - Not Implemented" }, + { id: 502, name: "502 - Bad Gateway" }, + { id: 503, name: "503 - Service Unavailable" }, + { id: 504, name: "504 - Gateway Timeout" }, + { id: 505, name: "505 - HTTP Version Not Supported" }, + { id: 506, name: "506 - Variant Also Negotiates" }, + { id: 507, name: "507 - Insufficient Storage" }, + { id: 508, name: "508 - Loop Detected" }, + { id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" }, + { id: 510, name: "510 - Not Extended" }, + { id: 511, name: "511 - Network Authentication Required" }, + { id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" }, + { id: 521, name: "521 - Web Server Is Down (Cloudflare)" }, + { id: 522, name: "522 - Connection Timed Out (Cloudflare)" }, + { id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" }, + { id: 524, name: "524 - A Timeout Occurred (Cloudflare)" }, + { id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" }, + { id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" }, + { id: 527, name: "527 - Railgun Error (Cloudflare)" }, + { id: 529, name: "529 - Site is overloaded" }, + { id: 530, name: "530 - Site is frozen (Cloudflare)" }, + { id: 561, name: "561 - Unauthorized (AWS ELB)" }, +]; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 80d0d77c4..e0c4eae07 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { GeoContinents } from "@/Types/GeoCheck"; import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor"; +import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -53,6 +54,11 @@ const geoCheckFields = { }; // HTTP monitor schema +const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id)); +const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), { + message: "Must be a valid HTTP status code", +}); + const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, @@ -64,6 +70,10 @@ const httpSchema = baseSchema.extend({ .register(monitorStepRegistry, { step: 2 }), expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }), jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }), + customUpCodes: z + .array(httpStatusCode) + .optional() + .register(monitorStepRegistry, { step: 2 }), ...geoCheckFields, }); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 25554dc14..6e65aea62 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -505,6 +505,11 @@ "equal": "Equal", "include": "Include", "regex": "Regex" + }, + "customUpCodes": { + "label": "Accepted status codes", + "description": "Add any non-2xx codes that should also be considered successful.", + "removeAriaLabel": "Remove status code" } }, "title": "Advanced settings" diff --git a/server/src/domain/monitors/monitor.types.ts b/server/src/domain/monitors/monitor.types.ts index 37d477704..b614d3191 100644 --- a/server/src/domain/monitors/monitor.types.ts +++ b/server/src/domain/monitors/monitor.types.ts @@ -4,7 +4,29 @@ import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-chec export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import http from "node:http"; -export const HttpStatusCodes = Object.keys(http.STATUS_CODES).map(Number); +export const HttpStatusCodes = [ + ...Object.keys(http.STATUS_CODES).map(Number), + 419, // Page Expired (Laravel) + 420, // Enhance Your Calm (Twitter) + 440, // Login Time-out (IIS) + 449, // Retry With (IIS) + 460, // Client Closed Connection (AWS ELB) + 463, // X-Forwarded-For Too Large (AWS ELB) + 497, // HTTP Request Sent to HTTPS Port (NGINX) + 499, // Client Closed Request (NGINX) + 509, // Bandwidth Limit Exceeded (Apache) + 520, // Web Server Returned an Unknown Error (Cloudflare) + 521, // Web Server Is Down (Cloudflare) + 522, // Connection Timed Out (Cloudflare) + 523, // Origin Is Unreachable (Cloudflare) + 524, // A Timeout Occurred (Cloudflare) + 525, // SSL Handshake Failed (Cloudflare) + 526, // Invalid SSL Certificate (Cloudflare) + 527, // Railgun Error (Cloudflare) + 529, // Site is overloaded + 530, // Site is frozen (Cloudflare) + 561, // Unauthorized (AWS ELB) +]; export const HttpStatusCodeSet = new Set(HttpStatusCodes); export type HttpStatusCode = number; diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index f7d2b0b99..ccda0c5d7 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -221,10 +221,10 @@ export class StatusService implements IStatusService { // Build the status patch — computed against the projected window const patch: Partial = {}; - // Resolve "initializing" up-front, incidents should be created on initialization && down + // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing") { + if (monitor.status === "initializing" || monitor.status === "maintenance") { newStatus = status === true ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index ad3975909..3365ea93b 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -49,7 +49,8 @@ export class CheckProducer implements ICheckProducer { method: "produce", }); if (monitor.status !== "maintenance") { - await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" }); + // Clear the status window to avoid incidents being created on next check + await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance", statusWindow: [] }); } return null; } diff --git a/server/test/unit/services/checkPipeline.test.ts b/server/test/unit/services/checkPipeline.test.ts index 008e879fa..645d58787 100644 --- a/server/test/unit/services/checkPipeline.test.ts +++ b/server/test/unit/services/checkPipeline.test.ts @@ -20,6 +20,114 @@ const activeWindow = () => { return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 }; }; +// ── CheckPipeline ──────────────────────────────────────────────────────────── + +const createCheckPipeline = (overrides?: Record) => { + const defaults = { + logger: createMockLogger(), + networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + statusService: { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), + }, + checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, + buffer: { addToBuffer: jest.fn() }, + monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) }, + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, + monitorStatusPolicy: new MonitorStatusPolicy(), + ...overrides, + }; + const checkProducer = new CheckProducer( + defaults.monitorsRepository as any, + defaults.maintenanceWindowsRepository as any, + defaults.checkService as any, + defaults.networkService as any, + defaults.buffer as any, + defaults.logger as any + ); + const checkEvaluator = new CheckEvaluator(defaults.statusService as any, defaults.monitorStatusPolicy as any); + const pipeline = new CheckPipeline(checkProducer, checkEvaluator); + return { pipeline, defaults }; +}; + +describe("CheckPipeline", () => { + it("throws when monitor id is missing", async () => { + const { pipeline } = createCheckPipeline(); + await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + it("skips and sets maintenance status when in maintenance window", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + const result = await pipeline.run(makeMonitor({ status: "up" })); + + expect(result).toBeNull(); + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] }); + }); + + it("does not re-write status when already in maintenance", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + await pipeline.run(makeMonitor({ status: "maintenance" })); + + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("throws when network response is null", async () => { + const { pipeline } = createCheckPipeline({ + networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, + }); + await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response"); + }); + + it("returns null and warns when toCheck returns null", async () => { + const { pipeline, defaults } = createCheckPipeline({ + checkService: { toCheck: jest.fn().mockReturnValue(null) }, + }); + + const result = await pipeline.run(makeMonitor()); + + expect(result).toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); + expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); + }); + + it("buffers the check, updates status, and returns an evaluation", async () => { + const status = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const { pipeline, defaults } = createCheckPipeline({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) }, + }); + + const result = await pipeline.run(makeMonitor()); + + expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" }); + expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); + expect(result).toMatchObject({ + monitor: statusChange.monitor, + status, + check: { id: "check-1" }, + statusChange, + decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }), + }); + }); + + it("proceeds with the check when no maintenance window is active", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) }, + }); + + await pipeline.run(makeMonitor()); + + expect(defaults.networkService.requestStatus).toHaveBeenCalled(); + }); +}); + // ── GeoChecksPipeline ────────────────────────────────────────────────────────── const createGeoPipeline = (overrides?: Record) => { diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index f011c183c..99db9072a 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -555,6 +555,55 @@ describe("StatusService", () => { expect(result.prevStatus).toBe("initializing"); }); + it("leaves 'maintenance' for 'up' on a passing check after the window closes (empty statusWindow)", async () => { + // On entry to a maintenance window the producer clears statusWindow, so the first + // check after the window closes sees an empty window and lands in the warmup + // early-return branch. A passing check must move the monitor out of 'maintenance' + // to 'up' without flagging a status change (no incident). + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("leaves 'maintenance' for 'down' on a failing check after the window closes (empty statusWindow)", async () => { + // Mirror of the recovery case: a failing first check after the window closes must + // flip the monitor straight to 'down' and surface statusChanged so an incident opens, + // rather than leave it stuck in 'maintenance'. + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("unsticks from 'maintenance' to 'up' on a passing check even with a full window (regression: stuck in maintenance)", async () => { + // Guards the up-front override directly: even if a full window survives (e.g. the + // entry-clear is bypassed), computeReachability only transitions out to 'up' from + // 'down', so without the override the monitor would stay stuck in 'maintenance'. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "maintenance", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.monitor.status).toBe("up"); + }); + it("does not flip status on subsequent checks during warmup once 'initializing' has been left (regression: down→up incident resolves once window fills)", async () => { // After a 'initializing'→'down' transition on check #1, monitor.status is now 'down' // and statusWindow has one false. Subsequent up checks during warmup must leave diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index c08b8be84..08f516f99 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -243,6 +243,14 @@ describe("monitorValidation — customUpCodes", () => { expect(parsed.customUpCodes).toEqual([200, 301, 404, 503]); }); + it("accepts non-standard HTTP status codes (Cloudflare, AWS ELB, etc.)", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }); + expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]); + }); + it("rejects invalid HTTP status codes", () => { expect(() => createMonitorBodyValidation.parse({ @@ -259,6 +267,33 @@ describe("monitorValidation — customUpCodes", () => { ).toThrow(); }); + it("rejects fractional or floating-point status codes", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [200.5], + }) + ).toThrow(); + }); + + it("rejects status codes below 100", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [99], + }) + ).toThrow(); + }); + + it("rejects valid-looking but mathematically unsupported status codes (e.g. 599)", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [599], // Not in Node's default list nor our expanded list + }) + ).toThrow(); + }); + it("defaults to an empty array when not provided", () => { const parsed = createMonitorBodyValidation.parse(baseHttpBody); expect(parsed.customUpCodes).toEqual([]); @@ -273,6 +308,13 @@ describe("monitorValidation — customUpCodes", () => { expect(parsed.customUpCodes).toEqual([200, 201]); }); + it("accepts non-standard HTTP status codes on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }); + expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]); + }); + it("rejects invalid HTTP status codes on edits", () => { expect(() => editMonitorBodyValidation.parse({ @@ -295,6 +337,20 @@ describe("monitorValidation — customUpCodes", () => { expect(parsed.monitors[0].customUpCodes).toEqual([404]); }); + it("accepts non-standard HTTP status codes on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }, + ], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([ + 419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561, + ]); + }); + it("rejects invalid HTTP status codes on imported HTTP monitors", () => { expect(() => importMonitorsBodyValidation.parse({