diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index e6bffddd8..5b982c004 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -291,6 +291,10 @@ const MonitorSchema = new Schema( ref: "Tag", }, ], + customUpCodes: { + type: [Number], + default: [], + }, secret: { type: String, }, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 3d1f9c3a5..28979890f 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -456,6 +456,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, tags: tagIds, + customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -520,6 +521,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, tags: tagIds, + customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 95b945f2a..5f557506f 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -89,7 +89,7 @@ export class GeoChecksService implements IGeoChecksService { } // Step 2: Poll for results - const results = await this.globalPingService.pollForResults(measurementId); + const results = await this.globalPingService.pollForResults(measurementId, undefined, monitor.customUpCodes ?? []); if (results.length === 0) { // No successful results (all locations timed out or failed) diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 41f96b286..4d7a67113 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -1,8 +1,9 @@ import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; +import { supportsGeoCheck, type HttpStatusCode } from "@/types/monitor.js"; import { MonitorType } from "@/types/index.js"; import type { ILogger } from "@/utils/logger.js"; import got from "got"; +import { isStatusUp } from "@/service/infrastructure/network/utils.js"; const SERVICE_NAME = "GlobalPingService"; const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1"; @@ -62,7 +63,7 @@ interface GlobalPingProbeResult { export interface IGlobalPingService { readonly serviceName: string; createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise; - pollForResults(measurementId: string, timeoutMs?: number): Promise; + pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise; } export class GlobalPingService implements IGlobalPingService { @@ -119,7 +120,11 @@ export class GlobalPingService implements IGlobalPingService { } } - async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise { + async pollForResults( + measurementId: string, + timeoutMs: number = MAX_POLL_TIMEOUT_MS, + customUpCodes: HttpStatusCode[] = [] + ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -132,7 +137,7 @@ export class GlobalPingService implements IGlobalPingService { const measurement = response.body; if (measurement.status === "finished") { - const results = this.transformResults(measurement.results || []); + const results = this.transformResults(measurement.results || [], customUpCodes); this.logger.debug({ message: `GlobalPing measurement completed: ${measurementId}`, service: SERVICE_NAME, @@ -174,7 +179,7 @@ export class GlobalPingService implements IGlobalPingService { return []; } - private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] { + private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: HttpStatusCode[] = []): GeoCheckResult[] { const successfulResults: GeoCheckResult[] = []; for (const probeResult of probeResults) { @@ -205,7 +210,7 @@ export class GlobalPingService implements IGlobalPingService { successfulResults.push({ location, - status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300, + status: isStatusUp(probeResult.result.statusCode, customUpCodes), statusCode: probeResult.result.statusCode, timings, }); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 247965cbf..9ac3813af 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -6,7 +6,7 @@ import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; import { Agent as HttpAgent } from "http"; import { Monitor, MonitorType } from "@/types/monitor.js"; -import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR, isStatusUp } from "@/service/infrastructure/network/utils.js"; import CacheableLookup from "cacheable-lookup"; export class HttpProvider implements IStatusProvider { @@ -40,19 +40,91 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } - private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { - if (error instanceof HTTPError || error instanceof RequestError) { + private buildResponse( + monitor: Monitor, + opts: { + body: string; + contentType: string; + statusCode: number; + statusUp: boolean; + message: string; + responseTime: number; + timings?: MonitorStatusResponse["timings"]; + } + ): MonitorStatusResponse { + const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts; + const isJson = contentType.includes("application/json"); + + if (monitor.jsonPath && !isJson) { return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, - code: error.response?.statusCode ?? NETWORK_ERROR, + code: statusCode, + message: "Response is not JSON", + responseTime, + timings, + payload: body as unknown as T, + }; + } + + let payload: T; + if (isJson) { + try { + payload = JSON.parse(body) as T; + } catch { + payload = body as unknown as T; + } + } else { + payload = body as unknown as T; + } + + const matchResult = this.advancedMatcher.validate(payload, monitor); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: statusUp && matchResult.ok, + code: statusCode, + message: matchResult.ok ? message : matchResult.message, + responseTime, + timings, + payload, + extracted: matchResult.extracted, + }; + } + + private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + if (error instanceof HTTPError || error instanceof RequestError) { + const statusCode = error.response?.statusCode; + const statusUp = isStatusUp(statusCode, monitor.customUpCodes); + const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0; + + if (!statusUp) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: statusCode ?? NETWORK_ERROR, + message: error.message, + responseTime, + timings: error.timings, + payload: null as T, + }; + } + + return this.buildResponse(monitor, { + body: (error.response?.body ?? "") as string, + contentType: error.response?.headers?.["content-type"] || "", + statusCode: statusCode ?? NETWORK_ERROR, + statusUp, message: error.message, - responseTime: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0, + responseTime, timings: error.timings, - payload: null as T, - }; + }); } return { @@ -68,7 +140,7 @@ export class HttpProvider implements IStatusProvider { } async handle(monitor: Monitor): Promise> { - const { url, secret, jsonPath, ignoreTlsErrors } = monitor; + const { url, secret, ignoreTlsErrors } = monitor; if (!url) { throw new Error("URL is required for HTTP monitor"); @@ -85,47 +157,17 @@ export class HttpProvider implements IStatusProvider { try { const response = await this.got(url, options); - const contentType = response.headers["content-type"] || ""; - const isJson = contentType.includes("application/json"); - - if (jsonPath && !isJson) { - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: response.statusCode, - message: "Response is not JSON", - responseTime: response.timings.phases.firstByte ?? 0, - timings: response.timings, - payload: response.body as unknown as T, - }; - } - - let payload: T; - if (isJson) { - try { - payload = JSON.parse(response.body) as T; - } catch { - payload = response.body as unknown as T; - } - } else { - payload = response.body as unknown as T; - } + const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes); - const matchResult = this.advancedMatcher.validate(payload, monitor); - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: response.ok && matchResult.ok, - code: response.statusCode, - message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, + return this.buildResponse(monitor, { + body: response.body, + contentType: response.headers["content-type"] || "", + statusCode: response.statusCode, + statusUp, + message: response.statusMessage ?? "OK", responseTime: response.timings.phases.firstByte ?? 0, timings: response.timings, - payload, - extracted: matchResult.extracted, - }; + }); } catch (error: unknown) { return this.handleHttpError(error, monitor); } diff --git a/server/src/service/infrastructure/network/utils.ts b/server/src/service/infrastructure/network/utils.ts index ddb962f47..994e19d9d 100644 --- a/server/src/service/infrastructure/network/utils.ts +++ b/server/src/service/infrastructure/network/utils.ts @@ -1,3 +1,5 @@ +import type { HttpStatusCode } from "@/types/monitor.js"; + export const timeRequest = async (operation: () => Promise): Promise<{ response: T | null; responseTime: number; error: unknown }> => { const start = process.hrtime.bigint(); try { @@ -12,3 +14,8 @@ export const timeRequest = async (operation: () => Promise): Promise<{ res export const NETWORK_ERROR = 5000; export const PING_ERROR = 5001; + +export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => { + if (statusCode === undefined) return false; + return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode); +}; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 0708297f3..3c543616c 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -2,6 +2,11 @@ import type { CheckSnapshot } from "@/types/check.js"; export type { CheckSnapshot } from "@/types/check.js"; import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js"; export type { GeoContinent } from "@/types/geoCheck.js"; +import http from "node:http"; + +export const HttpStatusCodes = Object.keys(http.STATUS_CODES).map(Number); +export const HttpStatusCodeSet = new Set(HttpStatusCodes); +export type HttpStatusCode = number; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; @@ -60,6 +65,7 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; tags: string[]; + customUpCodes: HttpStatusCode[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index d5164b8ef..2d70830b7 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; +import { DnsRecordTypes, HttpStatusCodeSet, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; + +const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -81,6 +83,7 @@ export const createMonitorBodyValidation = z tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -110,6 +113,7 @@ export const editMonitorBodyValidation = z interval: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -180,6 +184,7 @@ const importedMonitorSchema = z uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), tags: z.array(z.string()).default([]), + customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), @@ -241,6 +246,7 @@ export const monitorResponseSchema = z matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), tags: z.array(z.string()), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 2bcab27a3..1addcba6f 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -56,6 +56,7 @@ const makeMonitor = (overrides?: Partial): Monitor => useAdvancedMatching: false, matchMethod: undefined, expectedValue: undefined, + customUpCodes: [], ...overrides, }) as Monitor; @@ -253,14 +254,13 @@ describe("HttpProvider", () => { expect(result.extracted).toBe("value"); }); - it("sets status to false when response.ok is false even if matcher passes", async () => { + 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()); - // status = response.ok && matchResult.ok expect(result.status).toBe(false); }); }); @@ -345,4 +345,92 @@ describe("HttpProvider", () => { 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"); + }); + }); }); diff --git a/server/test/unit/providers/network/utils.test.ts b/server/test/unit/providers/network/utils.test.ts index 56d8e7f9c..84427152e 100644 --- a/server/test/unit/providers/network/utils.test.ts +++ b/server/test/unit/providers/network/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import { timeRequest, NETWORK_ERROR, PING_ERROR, isStatusUp } from "../../../../src/service/infrastructure/network/utils.ts"; describe("network utils", () => { describe("timeRequest", () => { @@ -41,4 +41,40 @@ describe("network utils", () => { expect(PING_ERROR).toBe(5001); }); }); + + describe("isStatusUp", () => { + it("returns true for 2xx status codes", () => { + expect(isStatusUp(200)).toBe(true); + expect(isStatusUp(201)).toBe(true); + expect(isStatusUp(204)).toBe(true); + expect(isStatusUp(299)).toBe(true); + }); + + it("returns false for non-2xx status codes without customUpCodes", () => { + expect(isStatusUp(100)).toBe(false); + expect(isStatusUp(301)).toBe(false); + expect(isStatusUp(404)).toBe(false); + expect(isStatusUp(500)).toBe(false); + }); + + it("returns true when status code is in customUpCodes", () => { + expect(isStatusUp(301, [301])).toBe(true); + expect(isStatusUp(404, [200, 404])).toBe(true); + expect(isStatusUp(401, [401, 403])).toBe(true); + }); + + it("returns false when status code is not in customUpCodes", () => { + expect(isStatusUp(500, [401, 403])).toBe(false); + }); + + it("returns false when statusCode is undefined", () => { + expect(isStatusUp(undefined)).toBe(false); + expect(isStatusUp(undefined, [200])).toBe(false); + }); + + it("defaults customUpCodes to empty array", () => { + expect(isStatusUp(200)).toBe(true); + expect(isStatusUp(404)).toBe(false); + }); + }); }); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 057ff6ae1..04cf2537e 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -222,3 +222,93 @@ describe("monitorValidation — strategy gating", () => { }); }); }); + +describe("monitorValidation — customUpCodes", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("accepts valid HTTP status codes", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [200, 301, 404, 503], + }); + expect(parsed.customUpCodes).toEqual([200, 301, 404, 503]); + }); + + it("rejects invalid HTTP status codes", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [5000], + }) + ).toThrow(); + + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [-1], + }) + ).toThrow(); + }); + + it("defaults to an empty array when not provided", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + expect(parsed.customUpCodes).toEqual([]); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("accepts valid HTTP status codes on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + customUpCodes: [200, 201], + }); + expect(parsed.customUpCodes).toEqual([200, 201]); + }); + + it("rejects invalid HTTP status codes on edits", () => { + expect(() => + editMonitorBodyValidation.parse({ + customUpCodes: [9999], + }) + ).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains valid HTTP status codes on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [404], + }, + ], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([404]); + }); + + it("rejects invalid HTTP status codes on imported HTTP monitors", () => { + expect(() => + importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [5000], + }, + ], + }) + ).toThrow(); + }); + + it("defaults to an empty array when not provided on import", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [baseHttpBody], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([]); + }); + }); +});