diff --git a/server/src/api/controllers/proxyController.ts b/server/src/api/controllers/proxyController.ts new file mode 100644 index 000000000..0ca72ed95 --- /dev/null +++ b/server/src/api/controllers/proxyController.ts @@ -0,0 +1,79 @@ +import { Request, Response, RequestHandler } from "express"; +import { catchAsync } from "@/utils/catchAsync.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createProxyBodyValidation, + editProxyBodyValidation, + getProxyByIdParamValidation, + editProxyParamValidation, + deleteProxyParamValidation, +} from "@/api/validation/index.js"; + +export interface IProxiesController { + createProxy: RequestHandler; + getProxyById: RequestHandler; + getProxiesByTeamId: RequestHandler; + editProxy: RequestHandler; + deleteProxy: RequestHandler; +} + +class ProxyController implements IProxiesController { + constructor(private proxiesService: IProxiesService) {} + + createProxy = catchAsync(async (req: Request, res: Response) => { + const validatedBody = createProxyBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const proxy = await this.proxiesService.createProxy(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy created successfully", + data: proxy, + }); + }); + getProxyById = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = getProxyByIdParamValidation.parse(req.params); + const proxy = await this.proxiesService.getProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy retrieved successfully", + data: proxy, + }); + }); + + getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const proxies = await this.proxiesService.getProxiesByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + + editProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = editProxyParamValidation.parse(req.params); + const validatedBody = editProxyBodyValidation.parse(req.body); + const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Proxy updated successfully", + data: updatedProxy, + }); + }); + + deleteProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = deleteProxyParamValidation.parse(req.params); + await this.proxiesService.deleteProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy deleted successfully", + }); + }); +} + +export default ProxyController; diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts new file mode 100644 index 000000000..83bf6a318 --- /dev/null +++ b/server/src/api/routes/proxyRoutes.ts @@ -0,0 +1,12 @@ +import { IProxiesController } from "@/api/controllers/proxyController.js"; +import { Router } from "express"; + +export const createProxyRoutes = (proxyController: IProxiesController): Router => { + const router = Router(); + router.post("/", proxyController.createProxy); + router.get("/team", proxyController.getProxiesByTeamId); + router.get("/:id", proxyController.getProxyById); + router.delete("/:id", proxyController.deleteProxy); + router.patch("/:id", proxyController.editProxy); + return router; +}; diff --git a/server/src/api/validation/index.ts b/server/src/api/validation/index.ts index 55c586769..9246395e4 100644 --- a/server/src/api/validation/index.ts +++ b/server/src/api/validation/index.ts @@ -22,3 +22,4 @@ export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; export * from "./tagValidation.js"; +export * from "./proxyValidation.js"; diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index d8b23a8e3..65a64ed10 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -10,6 +10,7 @@ import { MonitorStatuses, MonitorTypes, PageSpeedStrategies, + ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; @@ -100,6 +101,16 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole } }; +const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => { + if (body.proxyMode === "custom" && !body.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -110,6 +121,8 @@ export const createMonitorBodyValidation = z statusWindowThreshold: z.number().min(1).max(100).default(60), url: z.string().min(1, "URL is required"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), port: z.number().optional(), isActive: z.boolean().optional(), @@ -140,7 +153,8 @@ export const createMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const editMonitorBodyValidation = z .object({ @@ -156,6 +170,8 @@ export const editMonitorBodyValidation = z customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), + proxyMode: z.enum(ProxyModes).optional(), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -180,7 +196,8 @@ export const editMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -215,6 +232,8 @@ const importedMonitorSchema = z statusWindowThreshold: z.number().min(1).max(100).default(60), type: z.enum(MonitorTypes, "Invalid monitor type"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -253,7 +272,8 @@ const importedMonitorSchema = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -285,6 +305,8 @@ export const monitorResponseSchema = z statusWindowSize: z.number(), statusWindowThreshold: z.number(), ignoreTlsErrors: z.boolean(), + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean(), jsonPath: z.string().optional(), expectedValue: z.string().optional(), diff --git a/server/src/api/validation/proxyValidation.ts b/server/src/api/validation/proxyValidation.ts new file mode 100644 index 000000000..cab0ec2ea --- /dev/null +++ b/server/src/api/validation/proxyValidation.ts @@ -0,0 +1,45 @@ +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; +import { z } from "zod"; + +//**************************************** +// Proxy Validations +//**************************************** + +export const createProxyBodyValidation = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export const editProxyBodyValidation = createProxyBodyValidation.extend({ + clearPassword: z.boolean().optional(), + clearUsername: z.boolean().optional(), +}); + +export const getProxyByIdParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const editProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const deleteProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); + +export const proxyResponseSchema = z + .object({ + id: z.string(), + teamId: z.string(), + name: z.string(), + protocol: z.enum(ProxyProtocols), + host: z.string(), + port: z.number(), + username: z.string().optional(), + hasPassword: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); diff --git a/server/src/api/validation/settingsValidation.ts b/server/src/api/validation/settingsValidation.ts index d118cbd4f..50c75b043 100644 --- a/server/src/api/validation/settingsValidation.ts +++ b/server/src/api/validation/settingsValidation.ts @@ -40,5 +40,16 @@ export const updateAppSettingsBodyValidation = z temperature: z.number().min(1).max(150).optional(), }) .optional(), + globalProxyEnabled: z.boolean().optional(), + globalProxyId: z.string().nullable().optional(), }) - .strip(); + .strip() + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 5b7d3c3f7..4f683babf 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -12,6 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js import TagsController from "../api/controllers/tagController.js"; import DiagnosticController from "../api/controllers/diagnosticController.js"; import IncidentController from "../api/controllers/incidentController.js"; +import ProxyController from "@/api/controllers/proxyController.js"; import { ApiServices } from "@/config/services.api.js"; export interface InitializedControllers { @@ -29,6 +30,7 @@ export interface InitializedControllers { tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; + proxyController: ProxyController; } export const initializeControllers = (apiServices: ApiServices): InitializedControllers => { return { @@ -46,5 +48,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont tagController: new TagsController(apiServices.tagsService), diagnosticController: new DiagnosticController(apiServices.diagnosticService), incidentController: new IncidentController(apiServices.incidentService), + proxyController: new ProxyController(apiServices.proxiesService), }; }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index 810594354..6704fa83b 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -19,6 +19,7 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js"; import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js"; import { createTagRoutes } from "@/api/routes/tagRoutes.js"; import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js"; +import { createProxyRoutes } from "@/api/routes/proxyRoutes.js"; export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => { const verifyJWT = createVerifyJWT(apiServices.settingsService); @@ -37,6 +38,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const tagRoutes = createTagRoutes(controllers.tagController); const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = createIncidentRoutes(controllers.incidentController); + const proxyRoutes = createProxyRoutes(controllers.proxyController); app.use("/api/v1/auth", authApiLimiter, authRoutes); app.use("/api/v1/monitors", verifyJWT, monitorRoutes); @@ -52,4 +54,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/tags", verifyJWT, tagRoutes); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes); app.use("/api/v1/incidents", verifyJWT, incidentRoutes); + app.use("/api/v1/proxies", verifyJWT, proxyRoutes); }; diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index ae16814fd..5ce76aa3d 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta import { ITagsService, TagsService } from "@/domain/tags/tag.service.js"; import { IUserService, UserService } from "@/domain/users/user.service.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; +import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { SharedServices } from "@/config/services.shared.js"; // Third-party import jwt from "jsonwebtoken"; import crypto from "crypto"; import { games } from "gamedig"; -import { SharedServices } from "@/config/services.shared.js"; - export interface ApiServices extends SharedServices { worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only) userService: IUserService; @@ -23,6 +23,7 @@ export interface ApiServices extends SharedServices { statusPageService: IStatusPageService; tagsService: ITagsService; diagnosticService: IDiagnosticService; + proxiesService: IProxiesService; } export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => { @@ -45,6 +46,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A teamsRepository, maintenanceWindowsRepository, jobsRepository, + proxiesRepository, } = shared; const userService = new UserService({ @@ -94,7 +96,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); - + const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService); return { ...shared, worker: jobScheduler, @@ -105,5 +107,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A statusPageService, tagsService, diagnosticService, + proxiesService, }; }; diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index b278f6eab..7ea873c4b 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -52,6 +52,7 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; // Mongo repository implementations import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; @@ -70,6 +71,7 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; +import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js"; // Shared infrastructure + business services that both the API and the worker process construct. export interface SharedServices { @@ -103,6 +105,7 @@ export interface SharedServices { incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; + proxiesRepository: IProxiesRepository; } export const buildShared = async ({ @@ -139,6 +142,7 @@ export const buildShared = async ({ const tagsRepository = new MongoTagsRepository(); const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + const proxiesRepository = new MongoProxiesRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -221,6 +225,7 @@ export const buildShared = async ({ incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, }; return sharedServices; }; diff --git a/server/src/db/migration/0011_backfillMonitorProxyMode.ts b/server/src/db/migration/0011_backfillMonitorProxyMode.ts new file mode 100644 index 000000000..e703ece34 --- /dev/null +++ b/server/src/db/migration/0011_backfillMonitorProxyMode.ts @@ -0,0 +1,27 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Backfill proxyMode on monitors created before the field existed. + * + * Mongoose defaults only apply to new documents, and monitorResponseSchema + * requires proxyMode on every monitor read. + */ +export async function backfillMonitorProxyMode(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:BackfillMonitorProxyMode"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" }); + + const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } }); + + logger.info({ + service: SERVICE_NAME, + message: `Backfilled proxyMode on ${result.modifiedCount} monitors`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 24c330ef5..94829241d 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -9,6 +9,7 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import { slimRecentChecks } from "./0010_slimRecentChecks.js"; +import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -27,6 +28,7 @@ const migrations: MigrationEntry[] = [ { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, { name: "0010_slimRecentChecks", execute: slimRecentChecks }, + { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/src/domain/app-settings/app-settings.model.ts b/server/src/domain/app-settings/app-settings.model.ts index da766fa83..3dd3c3abe 100644 --- a/server/src/domain/app-settings/app-settings.model.ts +++ b/server/src/domain/app-settings/app-settings.model.ts @@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema( singleton: { type: Boolean, required: true, unique: true, default: true }, version: { type: Number, default: 1 }, globalThresholds: { type: thresholdsSchema }, + globalProxyEnabled: { type: Boolean, default: false }, + globalProxyId: { type: String, default: null }, }, { timestamps: true } ); diff --git a/server/src/domain/app-settings/app-settings.repository.mongo.ts b/server/src/domain/app-settings/app-settings.repository.mongo.ts index d88141ce5..d80289a7b 100644 --- a/server/src/domain/app-settings/app-settings.repository.mongo.ts +++ b/server/src/domain/app-settings/app-settings.repository.mongo.ts @@ -28,6 +28,8 @@ class MongoSettingsRepository implements ISettingsRepository { singleton: doc.singleton, version: doc.version ?? 1, globalThresholds: doc.globalThresholds ?? undefined, + globalProxyEnabled: doc.globalProxyEnabled ?? false, + globalProxyId: doc.globalProxyId ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/domain/app-settings/app-settings.type.ts b/server/src/domain/app-settings/app-settings.type.ts index 8233ced63..e4d2f9559 100644 --- a/server/src/domain/app-settings/app-settings.type.ts +++ b/server/src/domain/app-settings/app-settings.type.ts @@ -49,6 +49,8 @@ export interface Settings { singleton: boolean; version: number; globalThresholds?: SettingsThresholds; + globalProxyEnabled: boolean; + globalProxyId?: string | null; createdAt: string; updatedAt: string; } diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index ffd4238d0..15b277efb 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; -import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, ILighthouseAudit, @@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit & { createdAt: Dat type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; @@ -22,6 +22,7 @@ type MonitorDocumentBase = Omit< tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; + proxyId?: Types.ObjectId; }; interface MonitorDocument extends MonitorDocumentBase { @@ -170,6 +171,16 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + proxyMode: { + type: String, + enum: ProxyModes, + default: "inherit", + }, + proxyId: { + type: Schema.Types.ObjectId, + ref: "Proxy", + }, + useAdvancedMatching: { type: Boolean, default: false, diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index 5528c97cf..1ce2b8fa8 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -53,6 +53,7 @@ export interface IMonitorsRepository { // counts findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise; + findMonitorCountByProxyId(proxyId: string): Promise; // other findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 33c90dd59..6cf2e0ecf 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -172,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository { return count; }; + findMonitorCountByProxyId = async (proxyId: string): Promise => { + const count = await MonitorModel.countDocuments({ proxyId }); + return count; + }; + updateById = async (monitorId: string, teamId: string, patch: Partial) => { const updatedMonitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, @@ -418,6 +423,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + proxyMode: doc.proxyMode ?? "inherit", + proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined, useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 0fb1ed397..59d91ced9 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -41,6 +41,9 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; + export const UptimeDetailsSupportedTypes = [ "http", "ping", @@ -81,6 +84,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; diff --git a/server/src/domain/proxies/proxy.model.ts b/server/src/domain/proxies/proxy.model.ts new file mode 100644 index 000000000..fbb0b1294 --- /dev/null +++ b/server/src/domain/proxies/proxy.model.ts @@ -0,0 +1,38 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js"; +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; + +type ProxyDocumentBase = Omit; + +interface ProxyDocument extends ProxyDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const ProxySchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + protocol: { type: String, enum: ProxyProtocols, required: true }, + host: { type: String, required: true }, + port: { type: Number, required: true }, + username: { type: String, required: false }, + password: { type: String, required: false }, + }, + { timestamps: true } +); + +ProxySchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const ProxyModel = model("Proxy", ProxySchema); + +export type { ProxyDocument }; +export { ProxyModel }; +export default ProxyModel; diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts new file mode 100644 index 000000000..a235d0df4 --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -0,0 +1,13 @@ +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxiesRepository { + // create + create(proxyData: Partial): Promise; + // read + findById(proxyId: string, teamId: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise; + // delete + deleteById(proxyId: string, teamId: string): Promise; +} diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts new file mode 100644 index 000000000..3ff810587 --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -0,0 +1,95 @@ +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; +import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; +import { AppError } from "@/utils/AppError.js"; +import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { UpdateQuery } from "mongoose"; + +const SERVICE_NAME = "ProxiesRepository"; + +class MongoProxiesRepository implements IProxiesRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toEntity = (doc: ProxyDocument): Proxy => { + return { + id: toStringId(doc._id), + teamId: toStringId(doc.teamId), + name: doc.name, + protocol: doc.protocol, + host: doc.host, + port: doc.port, + username: doc.username, + password: doc.password, + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + async create(proxyData: Partial): Promise { + try { + const proxy = await ProxyModel.create({ ...proxyData }); + return this.toEntity(proxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findById(proxyId: string, teamId: string): Promise { + const proxy = await ProxyModel.findOne({ _id: proxyId, teamId }); + if (!proxy) { + throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(proxy); + } + + async findByTeamId(teamId: string): Promise { + const proxies = await ProxyModel.find({ teamId }); + return proxies.map(this.toEntity); + } + + async updateById( + proxyId: string, + teamId: string, + patch: Partial, + options?: { unsetPassword?: boolean; unsetUsername?: boolean } + ): Promise { + try { + const update: UpdateQuery = { + $set: { + ...patch, + }, + }; + if (options?.unsetPassword) { + delete update.$set?.password; + update.$unset = { ...update.$unset, password: 1 }; + } + if (options?.unsetUsername) { + delete update.$set?.username; + update.$unset = { ...update.$unset, username: 1 }; + } + const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true }); + if (!updatedProxy) { + throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedProxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async deleteById(proxyId: string, teamId: string): Promise { + const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId }); + if (!deletedProxy) { + throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedProxy); + } +} + +export default MongoProxiesRepository; diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts new file mode 100644 index 000000000..265544d8c --- /dev/null +++ b/server/src/domain/proxies/proxy.service.ts @@ -0,0 +1,92 @@ +import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { AppError } from "@/utils/AppError.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; + +export interface IProxiesService { + createProxy(proxy: Partial, teamId: string): Promise; + getProxy(proxyId: string, teamId: string): Promise; + getProxiesByTeamId(teamId: string): Promise; + updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; + deleteProxy(proxyId: string, teamId: string): Promise; +} + +const SERVICE_NAME = "ProxiesService"; +export class ProxiesService implements IProxiesService { + constructor( + private proxiesRepository: IProxiesRepository, + private monitorsRepository: IMonitorsRepository, + private settingsService: ISettingsService + ) {} + + private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) }); + + createProxy = async (proxyData: Partial, teamId: string): Promise => { + proxyData.teamId = teamId; + if (!proxyData.password) { + delete proxyData.password; + } + if (!proxyData.username) { + delete proxyData.username; + } + const raw = await this.proxiesRepository.create(proxyData); + return this.toResponse(raw); + }; + + getProxy = async (proxyId: string, teamId: string): Promise => { + const raw = await this.proxiesRepository.findById(proxyId, teamId); + return this.toResponse(raw); + }; + + getProxiesByTeamId = async (teamId: string): Promise => { + const raw = await this.proxiesRepository.findByTeamId(teamId); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; + }; + + updateProxy = async ( + proxyId: string, + teamId: string, + patch: Partial & { clearPassword?: boolean; clearUsername?: boolean } + ): Promise => { + // Empty/absent password and username don't delete, clearPassword / clearUsername does + const { clearPassword, clearUsername, ...proxyPatch } = patch; + if (!proxyPatch.password) { + delete proxyPatch.password; + } + if (!proxyPatch.username) { + delete proxyPatch.username; + } + const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, { + unsetPassword: clearPassword === true, + unsetUsername: clearUsername === true, + }); + return this.toResponse(raw); + }; + deleteProxy = async (proxyId: string, teamId: string): Promise => { + // Make sure proxy is not in use + const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId); + if (monitorsUsingProxy > 0) { + throw new AppError({ + message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`, + service: SERVICE_NAME, + status: 409, + }); + } + + // Make sure this is not set as a global proxy + const settings = await this.settingsService.getDBSettings(); + if (settings.globalProxyId === proxyId) { + throw new AppError({ + message: "Proxy is set as the global proxy in settings", + service: SERVICE_NAME, + status: 409, + }); + } + const raw = await this.proxiesRepository.deleteById(proxyId, teamId); + return this.toResponse(raw); + }; +} diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts new file mode 100644 index 000000000..dd098dee3 --- /dev/null +++ b/server/src/domain/proxies/proxy.type.ts @@ -0,0 +1,17 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts new file mode 100644 index 000000000..038754f28 --- /dev/null +++ b/server/test/unit/services/proxiesService.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createProxiesRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + findMonitorCountByProxyId: jest.fn(), +}); + +const createSettingsService = () => ({ + getDBSettings: jest.fn(), +}); + +const createService = () => { + const proxiesRepository = createProxiesRepo(); + const monitorsRepository = createMonitorsRepo(); + const settingsService = createSettingsService(); + + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null }); + + const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any); + + return { service, proxiesRepository, monitorsRepository, settingsService }; +}; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + username: "user", + password: "secret", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Proxy; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("ProxiesService", () => { + describe("createProxy", () => { + it("sets teamId and delegates to repository", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1"); + + expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" })); + }); + + it("omits password from the response and reports hasPassword", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.createProxy({ name: "Egress proxy" }, "team-1"); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("strips blank credentials before creating", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1"); + + const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial; + expect(created).not.toHaveProperty("username"); + expect(created).not.toHaveProperty("password"); + }); + }); + + describe("getProxy", () => { + it("delegates to repository and masks the password", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("reports hasPassword false when none is stored", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined })); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(result.hasPassword).toBe(false); + }); + }); + + describe("getProxiesByTeamId", () => { + it("masks every proxy in the list", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]); + + const result = await service.getProxiesByTeamId("team-1"); + + expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("updateProxy", () => { + it("keeps the stored password when the patch has a blank one", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch).not.toHaveProperty("password"); + expect(patch).not.toHaveProperty("username"); + expect(patch.name).toBe("Renamed"); + }); + + it("passes a new password through", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" })); + + await service.updateProxy("proxy-1", "team-1", { password: "next" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch.password).toBe("next"); + }); + + it("maps clear flags to unset options and keeps them out of the patch", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true }); + + const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0]; + expect(patch).not.toHaveProperty("clearPassword"); + expect(patch).not.toHaveProperty("clearUsername"); + expect(options).toEqual({ unsetPassword: true, unsetUsername: true }); + }); + + it("does not unset when clear flags are absent", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3]; + expect(options).toEqual({ unsetPassword: false, unsetUsername: false }); + }); + + it("masks the updated proxy in the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + }); + + describe("deleteProxy", () => { + it("deletes when unreferenced and masks the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.deleteProxy("proxy-1", "team-1"); + + expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + }); + + it("rejects with 409 when monitors reference the proxy", async () => { + const { service, proxiesRepository, monitorsRepository } = createService(); + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + + it("rejects with 409 when the proxy is the global proxy", async () => { + const { service, proxiesRepository, settingsService } = createService(); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" }); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index d6998367a..4de4538e1 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => { }); }); }); + +describe("monitorValidation — proxy fields", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("defaults proxyMode to inherit", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + + expect(parsed.proxyMode).toBe("inherit"); + expect(parsed.proxyId).toBeUndefined(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyMode).toBe("custom"); + expect(parsed.proxyId).toBe("proxy-1"); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow(); + }); + + it("rejects an unknown proxyMode", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow(); + }); + + it("allows a stray proxyId on non-custom modes", () => { + for (const proxyMode of ["inherit", "none"] as const) { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" }); + expect(parsed.proxyMode).toBe(proxyMode); + } + }); + }); + + describe("editMonitorBodyValidation", () => { + it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => { + const parsed = editMonitorBodyValidation.parse({ name: "Renamed" }); + + expect(parsed.proxyMode).toBeUndefined(); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyId).toBe("proxy-1"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("defaults proxyMode to inherit on import", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] }); + + expect(parsed.monitors[0].proxyMode).toBe("inherit"); + }); + + it("rejects an imported monitor with custom mode and no proxyId", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/proxyValidation.test.ts b/server/test/unit/validation/proxyValidation.test.ts new file mode 100644 index 000000000..2046f16df --- /dev/null +++ b/server/test/unit/validation/proxyValidation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@jest/globals"; +import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts"; + +const validBody = { + name: "Egress proxy", + protocol: "http" as const, + host: "proxy.internal", + port: 3128, +}; + +describe("proxyValidation", () => { + describe("createProxyBodyValidation", () => { + it("accepts a minimal valid body", () => { + const parsed = createProxyBodyValidation.parse(validBody); + + expect(parsed).toEqual(validBody); + }); + + it("accepts optional credentials", () => { + const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" }); + + expect(parsed.username).toBe("user"); + expect(parsed.password).toBe("secret"); + }); + + it("rejects an empty name", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow(); + }); + + it("rejects an empty host", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow(); + }); + + it("rejects a protocol outside the tuple", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow(); + }); + + it("accepts the port boundaries", () => { + expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1); + expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535); + }); + + it("rejects out-of-range and non-integer ports", () => { + for (const port of [0, -1, 65536, 3128.5, "3128"]) { + expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow(); + } + }); + }); + + describe("editProxyBodyValidation", () => { + it("accepts the create shape without clear flags", () => { + const parsed = editProxyBodyValidation.parse(validBody); + + expect(parsed.clearPassword).toBeUndefined(); + expect(parsed.clearUsername).toBeUndefined(); + }); + + it("accepts clearPassword and clearUsername", () => { + const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true }); + + expect(parsed.clearPassword).toBe(true); + expect(parsed.clearUsername).toBe(true); + }); + + it("rejects non-boolean clear flags", () => { + expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow(); + }); + }); + + describe("proxyResponseSchema", () => { + const validResponse = { + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + hasPassword: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + + it("accepts a masked proxy", () => { + expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow(); + }); + + it("requires hasPassword", () => { + const { hasPassword: _hasPassword, ...withoutFlag } = validResponse; + + expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow(); + }); + + it("does not declare a password field", () => { + expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password"); + }); + }); +});