From 4e31499f78df18e459ee193b665dbe6040d0dfde Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Mon, 1 Jun 2026 00:03:03 +0530 Subject: [PATCH 1/6] feat(backend): add customUpCodes support for HTTP monitors Adds a customUpCodes field to the monitor schema to allow users to specify which HTTP status codes should be treated as UP instead of DOWN. Includes core logic in HttpProvider and unit tests. Refs #3657 --- server/src/db/models/Monitor.ts | 4 ++ .../monitors/MongoMonitorsRepository.ts | 2 + .../src/service/business/geoChecksService.ts | 2 +- .../infrastructure/globalPingService.ts | 11 ++-- .../infrastructure/network/HttpProvider.ts | 6 +- server/src/types/monitor.ts | 1 + server/src/validation/monitorValidation.ts | 4 ++ .../providers/network/httpProvider.test.ts | 64 +++++++++++++++++++ 8 files changed, 86 insertions(+), 8 deletions(-) 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..6050cc5eb 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -62,7 +62,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?: number[]): Promise; } export class GlobalPingService implements IGlobalPingService { @@ -119,7 +119,7 @@ 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: number[] = []): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -132,7 +132,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 +174,7 @@ export class GlobalPingService implements IGlobalPingService { return []; } - private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] { + private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: number[] = []): GeoCheckResult[] { const successfulResults: GeoCheckResult[] = []; for (const probeResult of probeResults) { @@ -205,7 +205,8 @@ export class GlobalPingService implements IGlobalPingService { successfulResults.push({ location, - status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300, + status: + customUpCodes.includes(probeResult.result.statusCode) || (probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300), statusCode: probeResult.result.statusCode, timings, }); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index bbc0934f8..2ae177819 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -31,11 +31,13 @@ export class HttpProvider implements IStatusProvider { private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { + const isCustomUp = error instanceof HTTPError && (monitor.customUpCodes ?? []).includes(error.response?.statusCode); + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: false, + status: isCustomUp, code: error.response?.statusCode ?? NETWORK_ERROR, message: error.message, responseTime: error.timings?.phases?.total ?? 0, @@ -106,7 +108,7 @@ export class HttpProvider implements IStatusProvider { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response.ok && matchResult.ok, + status: ((monitor.customUpCodes ?? []).includes(response.statusCode) || response.ok) && matchResult.ok, code: response.statusCode, message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, responseTime: response.timings.phases.total ?? 0, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 0708297f3..415b7dff0 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -60,6 +60,7 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; tags: string[]; + customUpCodes: number[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index d5164b8ef..53a8ec6ca 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -81,6 +81,7 @@ export const createMonitorBodyValidation = z tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(z.number()).default([]), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -110,6 +111,7 @@ export const editMonitorBodyValidation = z interval: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(z.number()).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -180,6 +182,7 @@ const importedMonitorSchema = z uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), tags: z.array(z.string()).default([]), + customUpCodes: z.array(z.number()).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), @@ -241,6 +244,7 @@ export const monitorResponseSchema = z matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), tags: z.array(z.string()), + customUpCodes: z.array(z.number()).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 1b814d7b2..afd3710d9 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -345,4 +345,68 @@ 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"); + }); + }); }); From 87a6640c1d007aabe28d7f5001aa27f46e290c13 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Mon, 1 Jun 2026 13:21:30 +0530 Subject: [PATCH 2/6] fix(backend): validate customUpCodes against standard HTTP status codes Addresses reviewer feedback by enforcing strict validation on the array. Previously, the Zod schema accepted any arbitrary number, which could allow invalid or self-defined HTTP codes (e.g., 5000) to be saved to the database. Refs #3657 --- server/src/validation/monitorValidation.ts | 11 ++- .../unit/validation/monitorValidation.test.ts | 90 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 53a8ec6ca..22a1a241e 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -2,6 +2,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 http from "node:http"; + +const httpStatusCode = z.number().refine((code) => code.toString() in http.STATUS_CODES, { message: "Must be a valid HTTP status code" }); export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -81,7 +84,7 @@ export const createMonitorBodyValidation = z tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), - customUpCodes: z.array(z.number()).default([]), + 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(), @@ -111,7 +114,7 @@ export const editMonitorBodyValidation = z interval: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), - customUpCodes: z.array(z.number()).optional(), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -182,7 +185,7 @@ const importedMonitorSchema = z uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), tags: z.array(z.string()).default([]), - customUpCodes: z.array(z.number()).default([]), + customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), @@ -244,7 +247,7 @@ export const monitorResponseSchema = z matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), tags: z.array(z.string()), - customUpCodes: z.array(z.number()).optional(), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), 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([]); + }); + }); +}); From 372fc9869bd01af902dba50eeefe04fd7d5d612a Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Wed, 3 Jun 2026 23:27:10 +0530 Subject: [PATCH 3/6] refactor(monitor): address PR feedback for customUpCodes Add HttpStatusCode types and simplify provider logic --- .../src/service/infrastructure/globalPingService.ts | 12 ++++++++---- .../service/infrastructure/network/HttpProvider.ts | 10 ++++++---- server/src/types/monitor.ts | 7 ++++++- server/src/validation/monitorValidation.ts | 5 ++--- .../test/unit/providers/network/httpProvider.test.ts | 1 + 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 6050cc5eb..e261c7b19 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -1,5 +1,5 @@ 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"; @@ -62,7 +62,7 @@ interface GlobalPingProbeResult { export interface IGlobalPingService { readonly serviceName: string; createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise; - pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: number[]): Promise; + pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise; } export class GlobalPingService implements IGlobalPingService { @@ -119,7 +119,11 @@ export class GlobalPingService implements IGlobalPingService { } } - async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS, customUpCodes: number[] = []): Promise { + async pollForResults( + measurementId: string, + timeoutMs: number = MAX_POLL_TIMEOUT_MS, + customUpCodes: HttpStatusCode[] = [] + ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -174,7 +178,7 @@ export class GlobalPingService implements IGlobalPingService { return []; } - private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: number[] = []): GeoCheckResult[] { + private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: HttpStatusCode[] = []): GeoCheckResult[] { const successfulResults: GeoCheckResult[] = []; for (const probeResult of probeResults) { diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 2ae177819..971c4e6c2 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -31,14 +31,15 @@ export class HttpProvider implements IStatusProvider { private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { - const isCustomUp = error instanceof HTTPError && (monitor.customUpCodes ?? []).includes(error.response?.statusCode); + const statusCode = error.response?.statusCode; + const statusUp = statusCode !== undefined && monitor.customUpCodes.includes(statusCode); return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: isCustomUp, - code: error.response?.statusCode ?? NETWORK_ERROR, + status: statusUp, + code: statusCode ?? NETWORK_ERROR, message: error.message, responseTime: error.timings?.phases?.total ?? 0, timings: error.timings, @@ -104,11 +105,12 @@ export class HttpProvider implements IStatusProvider { } const matchResult = this.advancedMatcher.validate(payload, monitor); + const statusUp = monitor.customUpCodes.includes(response.statusCode) || response.ok; return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: ((monitor.customUpCodes ?? []).includes(response.statusCode) || response.ok) && matchResult.ok, + status: statusUp && matchResult.ok, code: response.statusCode, message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, responseTime: response.timings.phases.total ?? 0, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 415b7dff0..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,7 +65,7 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; tags: string[]; - customUpCodes: number[]; + customUpCodes: HttpStatusCode[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 22a1a241e..2d70830b7 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,10 +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 http from "node:http"; +import { DnsRecordTypes, HttpStatusCodeSet, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; -const httpStatusCode = z.number().refine((code) => code.toString() in http.STATUS_CODES, { message: "Must be a valid HTTP status code" }); +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"), diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index afd3710d9..9252493e0 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; From 5628c202f706d337ed0edb2ab1e5a07ae2637f8b Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Thu, 4 Jun 2026 22:43:43 +0530 Subject: [PATCH 4/6] refactor: extract isStatusUp utility to decouple from got's response.ok and modified utils test accordingly --- .../infrastructure/globalPingService.ts | 4 +- .../infrastructure/network/HttpProvider.ts | 6 +-- .../service/infrastructure/network/utils.ts | 7 ++++ .../providers/network/httpProvider.test.ts | 3 +- .../test/unit/providers/network/utils.test.ts | 38 ++++++++++++++++++- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index e261c7b19..4d7a67113 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -3,6 +3,7 @@ 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"; @@ -209,8 +210,7 @@ export class GlobalPingService implements IGlobalPingService { successfulResults.push({ location, - status: - customUpCodes.includes(probeResult.result.statusCode) || (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 971c4e6c2..73cdd6faf 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -5,7 +5,7 @@ import { HttpStatusPayload } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; 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 { @@ -32,7 +32,7 @@ export class HttpProvider implements IStatusProvider { private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; - const statusUp = statusCode !== undefined && monitor.customUpCodes.includes(statusCode); + const statusUp = isStatusUp(statusCode, monitor.customUpCodes); return { monitorId: monitor.id, @@ -105,7 +105,7 @@ export class HttpProvider implements IStatusProvider { } const matchResult = this.advancedMatcher.validate(payload, monitor); - const statusUp = monitor.customUpCodes.includes(response.statusCode) || response.ok; + const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes); return { monitorId: monitor.id, teamId: monitor.teamId, 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/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 9252493e0..ea24b9872 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -254,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); }); }); 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); + }); + }); }); From dd16ac060acddb4721e8600e538acbd80b73584e Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Fri, 5 Jun 2026 00:35:30 +0530 Subject: [PATCH 5/6] fix: run advanced matcher on HTTP errors with custom up codes When customUpCodes mark an HTTPError as up, parse the response body, apply advancedMatcher.validate, and set status to statusUp && matchResult.ok. Return parsed payload and extracted values to match the success path. --- .../infrastructure/network/HttpProvider.ts | 56 +++++++++++++++++-- .../providers/network/httpProvider.test.ts | 24 ++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 73cdd6faf..500f93771 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -33,17 +33,65 @@ export class HttpProvider implements IStatusProvider { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; const statusUp = isStatusUp(statusCode, monitor.customUpCodes); + const responseTime = 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, + }; + } + + const { jsonPath } = monitor; + const body = error.response?.body ?? ""; + const contentType = error.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: statusCode ?? NETWORK_ERROR, + message: "Response is not JSON", + responseTime, + timings: error.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, + status: statusUp && matchResult.ok, code: statusCode ?? NETWORK_ERROR, - message: error.message, - responseTime: error.timings?.phases?.total ?? 0, + message: matchResult.ok ? error.message : matchResult.message, + responseTime, timings: error.timings, - payload: null as T, + payload, + extracted: matchResult.extracted, }; } diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index ea24b9872..bd6d05b43 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -408,5 +408,29 @@ describe("HttpProvider", () => { 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"); + }); }); }); From f637b01631a3c7389e96cd6b7c840229b9ace1e2 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Fri, 5 Jun 2026 22:43:00 +0530 Subject: [PATCH 6/6] refactor: introduced helper method to remove redundant code --- .../infrastructure/network/HttpProvider.ts | 154 ++++++++---------- .../notificationMessageBuilder.ts | 2 +- server/src/types/network.ts | 2 +- 3 files changed, 74 insertions(+), 84 deletions(-) diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 5046208c1..a31bdf770 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -40,6 +40,62 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } + 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: statusCode, + message: "Response is not JSON", + responseTime, + timings, + payload: body, + }; + } + + let payload: T | string; + if (isJson) { + try { + payload = JSON.parse(body) as T; + } catch { + payload = body; + } + } else { + payload = body; + } + + 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; @@ -60,50 +116,15 @@ export class HttpProvider implements IStatusProvider { }; } - const { jsonPath } = monitor; - const body = error.response?.body ?? ""; - const contentType = error.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: statusCode ?? NETWORK_ERROR, - message: "Response is not JSON", - responseTime, - timings: error.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 ?? NETWORK_ERROR, - message: matchResult.ok ? error.message : matchResult.message, + 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, timings: error.timings, - payload, - extracted: matchResult.extracted, - }; + }); } return { @@ -119,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"); @@ -136,48 +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 matchResult = this.advancedMatcher.validate(payload, monitor); const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes); - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: statusUp && 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/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index bc1d7ac82..2035439c2 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -186,7 +186,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { const breaches: ThresholdBreach[] = []; // Check if this is a hardware monitor with threshold data - if (monitor.type !== "hardware" || !monitorStatusResponse.payload) { + if (monitor.type !== "hardware" || !monitorStatusResponse.payload || typeof monitorStatusResponse.payload === "string") { return breaches; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 19f063cc6..246422bf2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -35,7 +35,7 @@ export interface MonitorStatusResponse< code: number; message: string; responseTime?: number; - payload?: T | null; + payload?: T | string | null; timings?: GotTimings; first_byte_took?: number; body_read_took?: number;