Merge pull request #3859 from bluewave-labs/feat/outbound-proxy-server-crud
feat: outbound proxy server crud
This commit is contained in:
27 files changed
+881
-9
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -22,3 +22,4 @@ export * from "./notificationValidation.js";
|
||||
export * from "./userValidation.js";
|
||||
export * from "./incidentValidation.js";
|
||||
export * from "./tagValidation.js";
|
||||
export * from "./proxyValidation.js";
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
||||
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 }
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface Settings {
|
||||
singleton: boolean;
|
||||
version: number;
|
||||
globalThresholds?: SettingsThresholds;
|
||||
globalProxyEnabled: boolean;
|
||||
globalProxyId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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<CheckSnapshot, "createdAt"> & { 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<MonitorDocument>(
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
proxyMode: {
|
||||
type: String,
|
||||
enum: ProxyModes,
|
||||
default: "inherit",
|
||||
},
|
||||
proxyId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Proxy",
|
||||
},
|
||||
|
||||
useAdvancedMatching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface IMonitorsRepository {
|
||||
|
||||
// counts
|
||||
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
|
||||
findMonitorCountByProxyId(proxyId: string): Promise<number>;
|
||||
|
||||
// other
|
||||
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
|
||||
|
||||
@@ -172,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return count;
|
||||
};
|
||||
|
||||
findMonitorCountByProxyId = async (proxyId: string): Promise<number> => {
|
||||
const count = await MonitorModel.countDocuments({ proxyId });
|
||||
return count;
|
||||
};
|
||||
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ProxyEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
|
||||
|
||||
interface ProxyDocument extends ProxyDocumentBase {
|
||||
_id: Types.ObjectId;
|
||||
teamId: Types.ObjectId;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const ProxySchema = new Schema<ProxyDocument>(
|
||||
{
|
||||
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<ProxyDocument>("Proxy", ProxySchema);
|
||||
|
||||
export type { ProxyDocument };
|
||||
export { ProxyModel };
|
||||
export default ProxyModel;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Proxy } from "@/domain/proxies/proxy.type.js";
|
||||
|
||||
export interface IProxiesRepository {
|
||||
// create
|
||||
create(proxyData: Partial<Proxy>): Promise<Proxy>;
|
||||
// read
|
||||
findById(proxyId: string, teamId: string): Promise<Proxy>;
|
||||
findByTeamId(teamId: string): Promise<Proxy[]>;
|
||||
// update
|
||||
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
|
||||
// delete
|
||||
deleteById(proxyId: string, teamId: string): Promise<Proxy>;
|
||||
}
|
||||
@@ -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<Proxy>): Promise<Proxy> {
|
||||
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<Proxy> {
|
||||
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<Proxy[]> {
|
||||
const proxies = await ProxyModel.find({ teamId });
|
||||
return proxies.map(this.toEntity);
|
||||
}
|
||||
|
||||
async updateById(
|
||||
proxyId: string,
|
||||
teamId: string,
|
||||
patch: Partial<Proxy>,
|
||||
options?: { unsetPassword?: boolean; unsetUsername?: boolean }
|
||||
): Promise<Proxy> {
|
||||
try {
|
||||
const update: UpdateQuery<ProxyDocument> = {
|
||||
$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<Proxy> {
|
||||
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;
|
||||
@@ -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<Proxy>, teamId: string): Promise<ProxyResponse>;
|
||||
getProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
getProxiesByTeamId(teamId: string): Promise<ProxyResponse[]>;
|
||||
updateProxy(proxyId: string, teamId: string, patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }): Promise<ProxyResponse>;
|
||||
deleteProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
|
||||
}
|
||||
|
||||
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<Proxy>, teamId: string): Promise<ProxyResponse> => {
|
||||
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<ProxyResponse> => {
|
||||
const raw = await this.proxiesRepository.findById(proxyId, teamId);
|
||||
return this.toResponse(raw);
|
||||
};
|
||||
|
||||
getProxiesByTeamId = async (teamId: string): Promise<ProxyResponse[]> => {
|
||||
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<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }
|
||||
): Promise<ProxyResponse> => {
|
||||
// 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<ProxyResponse> => {
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
@@ -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<Proxy, "password"> & { hasPassword: boolean };
|
||||
@@ -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>): 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<Proxy>;
|
||||
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<Proxy>;
|
||||
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<Proxy>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user