From ce3c2f7583806cc65928d93a26e66378c0f5429a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 12:05:47 -0400 Subject: [PATCH] Fix #3314 --- .../routers/healthChecks/createHealthCheck.ts | 59 ++++++++----- .../routers/healthChecks/updateHealthCheck.ts | 31 ++++++- server/routers/target/createTarget.ts | 88 +++++++++++-------- server/routers/target/updateTarget.ts | 47 ++++++---- 4 files changed, 151 insertions(+), 74 deletions(-) diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index aa3706833..6f49f0f18 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -29,26 +29,40 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - siteId: z.number().int().positive(), - hcEnabled: z.boolean().default(false), - hcMode: z.string().default("http"), - hcHostname: z.string().optional(), - hcPort: z.number().int().min(1).max(65535).optional(), - hcPath: z.string().optional(), - hcScheme: z.string().optional(), - hcMethod: z.string().default("GET"), - hcInterval: z.number().int().positive().default(30), - hcUnhealthyInterval: z.number().int().positive().default(30), - hcTimeout: z.number().int().positive().default(1), - hcHeaders: z.string().optional().nullable(), - hcFollowRedirects: z.boolean().default(true), - hcStatus: z.number().int().optional().nullable(), - hcTlsServerName: z.string().optional(), - hcHealthyThreshold: z.number().int().positive().default(1), - hcUnhealthyThreshold: z.number().int().positive().default(1) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + siteId: z.number().int().positive(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(1), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateHealthCheckResponse = { targetHealthCheckId: number; @@ -57,7 +71,6 @@ const CreateHealthCheckResponseDataSchema = z.object({ targetHealthCheckId: z.number() }); - registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", @@ -78,7 +91,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + CreateHealthCheckResponseDataSchema + ) } } } diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index f08324f9b..4fb7a624b 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -105,7 +105,6 @@ const UpdateHealthCheckResponseDataSchema = z.object({ hcUnhealthyThreshold: z.number().nullable() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", @@ -126,7 +125,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(UpdateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + UpdateHealthCheckResponseDataSchema + ) } } } @@ -215,6 +216,32 @@ export async function updateHealthCheck( ) .limit(1); + if (!existingHealthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const nextHcEnabled = hcEnabled ?? existingHealthCheck.hcEnabled; + const nextHcHostname = + hcHostname !== undefined + ? hcHostname + : existingHealthCheck.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + if (name !== undefined) updateData.name = name; if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 2b3f472e8..289f47c76 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -33,41 +33,59 @@ const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const createTargetSchema = z.strictObject({ - siteId: z.int().positive(), - ip: z.string().refine(isTargetValid), - mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), - method: z.string().optional().nullable(), - port: z.int().min(1).max(65535), - enabled: z.boolean().default(true), - hcEnabled: z.boolean().optional(), - hcPath: z.string().min(1).optional().nullable(), - hcScheme: z.string().optional().nullable(), - hcMode: z.string().optional().nullable(), - hcHostname: z.string().optional().nullable(), - hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(1).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), - hcTimeout: z.int().positive().min(1).optional().nullable(), - hcHeaders: z - .array(z.strictObject({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcFollowRedirects: z.boolean().optional().nullable(), - hcMethod: z.string().min(1).optional().nullable(), - hcStatus: z.int().optional().nullable(), - hcTlsServerName: z.string().optional().nullable(), - hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), - hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), - path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional().nullable() -}); +const createTargetSchema = z + .strictObject({ + siteId: z.int().positive(), + ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), + method: z.string().optional().nullable(), + port: z.int().min(1).max(65535), + enabled: z.boolean().default(true), + hcEnabled: z.boolean().optional(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.int().positive().optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), + hcTimeout: z.int().positive().min(1).optional().nullable(), + hcHeaders: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.int().optional().nullable(), + hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z.int().min(1).max(1000).optional().nullable() + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname === null || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateTargetResponse = Target & TargetHealthCheck; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1bed7b982..52bf3e578 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,38 @@ export async function updateTarget( ); } + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + + const nextHcEnabled = parsedBody.data.hcEnabled ?? existingHc.hcEnabled; + const nextHcHostname = + parsedBody.data.hcHostname !== undefined + ? parsedBody.data.hcHostname + : existingHc.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const nextMode = parsedBody.data.mode === null ? undefined : parsedBody.data.mode; @@ -218,21 +250,6 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); - const [existingHc] = await trx - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); - - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } - let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);