diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 681a3da6c..609db4792 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -31,6 +31,7 @@ export const useStatusPageForm = ({ const defaults: StatusPageFormData = { companyName: data?.companyName || "", url: data?.url || generateDefaultUrl(), + customDomain: data?.customDomain ?? null, timezone: resolveTimezone(data?.timezone), type: data?.type || ["uptime"], color: data?.color || "#13715B", diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 921ca8bdb..8dd119b72 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -151,6 +151,11 @@ const CreateStatusPage = () => { fd.append("isPublished", String(data.isPublished)); if (data.companyName) fd.append("companyName", data.companyName); if (data.url) fd.append("url", data.url); + if (data.customDomain !== undefined && data.customDomain !== null) { + fd.append("customDomain", data.customDomain); + } else { + fd.append("customDomain", ""); + } if (data.timezone) fd.append("timezone", data.timezone); if (data.color) fd.append("color", data.color); fd.append("showCharts", String(data.showCharts)); @@ -285,6 +290,27 @@ const CreateStatusPage = () => { /> )} /> + ( + + )} + /> } /> diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index ba3a5940d..93c29d188 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { type StatusPage } from "@/Types/StatusPage"; +import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl"; interface HeaderStatusPageControlsProps { isAdmin: boolean; @@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({ { window.open( - `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`, + getStatusPagePublicUrl(statusPage), "_blank", "noopener,noreferrer" ); diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index f2ac1a83e..6aed8817b 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -15,6 +15,11 @@ import { type StatusPageResponse, type StatusPageTheme, } from "@/Types/StatusPage"; +import { + buildStatusPageApiPath, + getStatusPagePublicUrl, + isCustomDomainHost, +} from "@/Utils/statusPageUrl"; import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { @@ -68,9 +73,14 @@ const StatusPageView = () => { const isAdmin = useIsAdmin(); const location = useLocation(); - const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const onCustomDomainHost = isCustomDomainHost(); + const isPublic = + onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); - const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; + const apiUrl = buildStatusPageApiPath({ + url, + useCustomDomain: onCustomDomainHost, + }); const { data, isLoading, error } = useGet( apiUrl, @@ -131,7 +141,7 @@ const StatusPageView = () => { ); } - const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + const publicUrl = getStatusPagePublicUrl(statusPage); return ( { if (row.isPublished) { e.stopPropagation(); - const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`; - window.open(url, "_blank", "noopener,noreferrer"); + window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer"); } }; @@ -70,7 +71,9 @@ export const StatusPagesTable = ({ content: t("pages.statusPages.table.headers.url"), render: (row) => { const content = row.isPublished - ? `/${row.url}` + ? row.customDomain + ? getStatusPagePublicPath(row) + : `/${row.url}` : t("pages.statusPages.table.unpublished"); return ( { + if (isCustomDomainHost()) { + return ( + + } + /> + } + /> + + ); + } + return ( { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return "https:"; + } + + try { + return new URL(source).protocol; + } catch { + return "https:"; + } +}; + +const buildCustomDomainOrigin = (customDomain: string): string => + `${getClientOriginProtocol()}//${customDomain}`; + +export const getAppHostname = (): string | null => { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return null; + } + + try { + return new URL(source).hostname.toLowerCase(); + } catch { + return null; + } +}; + +export const isCustomDomainHost = (): boolean => { + if (typeof window === "undefined") { + return false; + } + + const appHostname = getAppHostname(); + if (!appHostname) { + return false; + } + + return window.location.hostname.toLowerCase() !== appHostname; +}; + +export const getStatusPagePublicPath = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const getStatusPagePublicUrl = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const buildStatusPageApiPath = (options: { + url?: string; + useCustomDomain?: boolean; +}): string | null => { + const query = "type=uptime&type=infrastructure"; + + if (options.useCustomDomain && typeof window !== "undefined") { + const domain = encodeURIComponent(window.location.hostname); + return `/status-page/resolve?domain=${domain}&${query}`; + } + + if (options.url) { + return `/status-page/${options.url}?${query}`; + } + + return null; +}; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 3533fe017..6d4e2fd6e 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -9,6 +9,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage" // field definitions. export const statusPageStepRegistry = z.registry<{ step: number }>(); +const statusPageHostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +const normalizeCustomDomainInput = (raw: string | null): string | null => { + if (raw === null) { + return null; + } + + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + return withoutScheme.split("/")[0]?.split(":")[0] ?? null; +}; + export const statusPageSchema = z.object({ companyName: z .string() @@ -22,6 +39,12 @@ export const statusPageSchema = z.object({ /^[a-z0-9-]+$/, "URL can only contain lowercase letters, numbers, and hyphens" ), + customDomain: z + .union([z.string(), z.null()]) + .transform(normalizeCustomDomainInput) + .refine((domain) => domain === null || statusPageHostnameRegex.test(domain), { + message: "Enter a valid domain name (e.g. status.example.com)", + }), timezone: z.string().optional(), type: z .array(z.enum(["uptime", "infrastructure"])) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b2a643f8b..0fc0541f8 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1509,6 +1509,11 @@ "url": { "label": "Your status page address", "placeholder": "my-status-page" + }, + "customDomain": { + "label": "Custom domain", + "placeholder": "status.example.com", + "helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain." } } }, diff --git a/server/openapi.json b/server/openapi.json index ae34fba04..bfef875f1 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10448,6 +10448,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10655,6 +10666,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10928,6 +10950,102 @@ } } }, + "/status-page/resolve": { + "get": { + "tags": ["status-page"], + "summary": "Resolve a published status page by custom domain", + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "required": true, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "timeFrame", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "domain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "404": { + "description": "Status page not found" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/status-page/{url}": { "get": { "tags": ["status-page"], diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts index d0df87972..7ecc6e6d7 100644 --- a/server/openapi/routes/statusPage.ts +++ b/server/openapi/routes/statusPage.ts @@ -1,6 +1,11 @@ import { registry } from "../registry.js"; import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; -import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/api/validation/statusPageValidation.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + resolveStatusPageQueryValidation, +} from "@/api/validation/statusPageValidation.js"; const tags = ["status-page"]; @@ -33,6 +38,15 @@ registry.registerPath({ responses: { "200": okUnknown, ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/status-page/resolve", + tags, + summary: "Resolve a published status page by custom domain", + request: { query: resolveStatusPageQueryValidation }, + responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, +}); + registry.registerPath({ method: "get", path: "/status-page/{url}", diff --git a/server/src/api/controllers/queueController.ts b/server/src/api/controllers/queueController.ts index 0561ba6f0..0d3e2d1d0 100755 --- a/server/src/api/controllers/queueController.ts +++ b/server/src/api/controllers/queueController.ts @@ -1,4 +1,4 @@ -import { IJobQueue } from "@/service/job-queues/job-queue.interface.js"; +import { IWorker } from "@/worker/worker.interface.js"; import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js"; import { Request, Response, NextFunction } from "express"; @@ -13,9 +13,9 @@ export interface IJobQueueController { class JobQueueController implements IJobQueueController { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: IJobQueue; - constructor(jobQueue: IJobQueue) { - this.jobQueue = jobQueue; + private worker: IWorker; + constructor(worker: IWorker) { + this.worker = worker; } get serviceName() { @@ -24,7 +24,7 @@ class JobQueueController implements IJobQueueController { getMetrics = async (req: Request, res: Response, next: NextFunction) => { try { - const metrics = await this.jobQueue.getMetrics(); + const metrics = await this.worker.getMetrics(); res.status(200).json({ success: true, msg: "Queue metrics fetched successfully", @@ -38,7 +38,7 @@ class JobQueueController implements IJobQueueController { getJobs = async (req: Request, res: Response, next: NextFunction) => { try { const pagination = getQueueJobsQueryValidation.parse(req.query); - const { jobs, count } = await this.jobQueue.getJobs(pagination); + const { jobs, count } = await this.worker.getJobs(pagination); return res.status(200).json({ success: true, msg: "Queue jobs fetched successfully", @@ -52,8 +52,8 @@ class JobQueueController implements IJobQueueController { getAllMetrics = async (req: Request, res: Response, next: NextFunction) => { try { const pagination = getQueueJobsQueryValidation.parse(req.query); - const { jobs, count } = await this.jobQueue.getJobs(pagination); - const metrics = await this.jobQueue.getMetrics(); + const { jobs, count } = await this.worker.getJobs(pagination); + const metrics = await this.worker.getMetrics(); return res.status(200).json({ success: true, msg: "Queue metrics fetched successfully", @@ -66,7 +66,7 @@ class JobQueueController implements IJobQueueController { flushQueue = async (req: Request, res: Response, next: NextFunction) => { try { - const result = await this.jobQueue.flushQueues(); + const result = await this.worker.flushQueues(); return res.status(200).json({ success: true, msg: "Queue flushed successfully", diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 5771fa5e4..394d76e23 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -5,6 +5,7 @@ import { getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation, + resolveStatusPageQueryValidation, } from "@/api/validation/statusPageValidation.js"; import { AppError } from "@/utils/AppError.js"; import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; @@ -12,6 +13,8 @@ import { IStatusPageService } from "@/domain/status-pages/status-page.service.js import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { NormalizeData } from "@/utils/dataUtils.js"; +import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js"; +import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; const SERVICE_NAME = "statusPageController"; @@ -20,6 +23,7 @@ export interface IStatusPageController { createStatusPage(req: Request, res: Response, next: NextFunction): Promise; updateStatusPage(req: Request, res: Response, next: NextFunction): Promise; getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise; + resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise; getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise; deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise; } @@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController { return StatusPageController.SERVICE_NAME; } + private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => { + if (!statusPage.isPublished) { + const teamId = requireTeamId(req?.user?.teamId); + if (statusPage.teamId !== teamId) { + throw new AppError({ message: "Forbidden", status: 403 }); + } + } + + const settings = await this.settingsService.getDBSettings(); + const showURL = settings.showURL; + + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); + const sortedMonitors = [...monitors].sort((a, b) => { + const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + const normalizedMonitors = sortedMonitors.map((monitor) => { + const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); + if (!showURL) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { url, port, secret, notifications, ...rest } = monitor; + return { ...rest, checks: normalizedChecks }; + } + return { ...monitor, checks: normalizedChecks }; + }); + + return { + statusPage, + monitors: normalizedMonitors, + }; + }; + createStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { createStatusPageBodyValidation.parse(req.body); @@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController { } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); + const data = await this.buildStatusPagePayload(statusPage, req); - if (!statusPage.isPublished) { - const teamId = requireTeamId(req?.user?.teamId); - if (statusPage.teamId !== teamId) { - throw new AppError({ message: "Forbidden", status: 403 }); - } - } - - const settings = await this.settingsService.getDBSettings(); - const showURL = settings.showURL; - - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); - // Sort monitors according to the order in statusPage.monitors - const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); - const sortedMonitors = [...monitors].sort((a, b) => { - const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return orderA - orderB; - }); - - const normalizedMonitors = sortedMonitors.map((monitor) => { - const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); - if (!showURL) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { url, port, secret, notifications, ...rest } = monitor; - return { ...rest, checks: normalizedChecks }; - } - return { ...monitor, checks: normalizedChecks }; - }); return res.status(200).json({ success: true, msg: "Status page retrieved successfully", - data: { - statusPage, - monitors: normalizedMonitors, - }, + data, }); } catch (error) { next(error); } }; + + resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => { + try { + resolveStatusPageQueryValidation.parse(req.query); + + const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); + if (!domain) { + throw new AppError({ message: "Domain is required", status: 400 }); + } + + const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain); + if (!statusPage.isPublished) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const data = await this.buildStatusPagePayload(statusPage, req); + + return res.status(200).json({ + success: true, + msg: "Status page retrieved successfully", + data, + }); + } catch (error) { + next(error); + } + }; + getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); diff --git a/server/src/api/middleware/handleErrors.ts b/server/src/api/middleware/handleErrors.ts index ff591484f..239349b90 100755 --- a/server/src/api/middleware/handleErrors.ts +++ b/server/src/api/middleware/handleErrors.ts @@ -1,11 +1,15 @@ import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; import { logger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; +// Multer throws MulterError, send 413 Payload Too Large / 400 +const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400); + const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => { - const status = error instanceof AppError ? error.status || 500 : 500; + const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500; const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; - const service = error instanceof AppError ? error.service : "unknownService"; + const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService"; const method = error instanceof AppError ? error.method : "unknownMethod"; logger.error({ message: message, diff --git a/server/src/api/middleware/statusPageCorsOrigin.ts b/server/src/api/middleware/statusPageCorsOrigin.ts new file mode 100644 index 000000000..338c43472 --- /dev/null +++ b/server/src/api/middleware/statusPageCorsOrigin.ts @@ -0,0 +1,68 @@ +import type { CorsOptions } from "cors"; +import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; + +const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; +const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000; + +type CustomDomainCorsCacheEntry = { + allowed: boolean; + expiresAt: number; +}; + +const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number => + allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS; + +export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => { + const cache = new Map(); + + const cacheOriginResult = (customDomain: string, allowed: boolean) => { + cache.set(customDomain, { + allowed, + expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed), + }); + }; + + return (origin, callback) => { + if (!origin) { + callback(null, true); + return; + } + + if (origin === clientHost) { + callback(null, true); + return; + } + + let customDomain: string | null = null; + try { + customDomain = normalizeStatusPageDomain(new URL(origin).hostname); + } catch { + callback(null, false); + return; + } + + if (!customDomain) { + callback(null, false); + return; + } + + const cached = cache.get(customDomain); + if (cached && cached.expiresAt > Date.now()) { + callback(null, cached.allowed ? origin : false); + return; + } + + statusPagesRepository + .findByCustomDomain(customDomain) + .then((statusPage) => { + const allowed = statusPage.isPublished; + cacheOriginResult(customDomain, allowed); + callback(null, allowed ? origin : false); + }) + .catch(() => { + cacheOriginResult(customDomain, false); + callback(null, false); + }); + }; +}; diff --git a/server/src/api/middleware/statusPagePublicApiPath.ts b/server/src/api/middleware/statusPagePublicApiPath.ts new file mode 100644 index 000000000..a4b5e42d1 --- /dev/null +++ b/server/src/api/middleware/statusPagePublicApiPath.ts @@ -0,0 +1,24 @@ +const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page"; + +export const isPublicStatusPageApiPath = (method: string, path: string): boolean => { + if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) { + return false; + } + + if (method !== "GET" && method !== "OPTIONS") { + return false; + } + + const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? ""; + const slug = subPath.replace(/^\//, ""); + + if (slug === "resolve") { + return true; + } + + if (!slug || slug === "team" || slug.includes("/")) { + return false; + } + + return true; +}; diff --git a/server/src/api/middleware/upload.ts b/server/src/api/middleware/upload.ts new file mode 100644 index 000000000..c3f257884 --- /dev/null +++ b/server/src/api/middleware/upload.ts @@ -0,0 +1,27 @@ +import multer from "multer"; +import { AppError } from "@/utils/AppError.js"; +import { ImageMimeTypes, MAX_IMAGE_SIZE_BYTES } from "@/types/upload.js"; + +// Reusable multer instance for handling image uploads +const imageUpload = multer({ + limits: { + fileSize: MAX_IMAGE_SIZE_BYTES, + files: 1, + }, + fileFilter: (_req, file, cb) => { + if (!(ImageMimeTypes as readonly string[]).includes(file.mimetype)) { + cb( + new AppError({ + status: 415, + message: "File must be a valid image (jpeg, jpg, or png)", + service: "uploadMiddleware", + method: "fileFilter", + }) + ); + return; + } + cb(null, true); + }, +}); + +export { imageUpload }; diff --git a/server/src/api/routes/authRoute.ts b/server/src/api/routes/authRoute.ts index 22055ae40..3b202a502 100644 --- a/server/src/api/routes/authRoute.ts +++ b/server/src/api/routes/authRoute.ts @@ -1,10 +1,8 @@ import { Router, RequestHandler } from "express"; -import { isAllowed } from "../middleware/isAllowed.js"; -import multer from "multer"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { imageUpload } from "@/api/middleware/upload.js"; import { IAuthController } from "@/api/controllers/authController.js"; -const upload = multer(); - class AuthRoutes { private router: Router; private authController: IAuthController; @@ -16,7 +14,7 @@ class AuthRoutes { } initRoutes(verifyJWT: RequestHandler) { - this.router.post("/register", upload.single("profileImage"), this.authController.registerUser); + this.router.post("/register", imageUpload.single("profileImage"), this.authController.registerUser); this.router.post("/login", this.authController.loginUser); this.router.post("/recovery/request", this.authController.requestRecovery); @@ -26,13 +24,19 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); - this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser); + this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), imageUpload.single("profileImage"), this.authController.createUser); this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById); this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); - this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); + this.router.patch( + "/user", + verifyJWT, + imageUpload.single("profileImage"), + isAllowed(["admin", "superadmin", "user"]), + this.authController.editUser + ); this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser); } diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index db195b6f9..816c7212a 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,8 +1,7 @@ import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; import { isAllowed } from "@/api/middleware/isAllowed.js"; -import multer from "multer"; -const upload = multer(); +import { imageUpload } from "@/api/middleware/upload.js"; class StatusPageRoutes { private router: Router; @@ -17,9 +16,10 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); - this.router.put("/:id", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); + this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); + this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage); } diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index c27ca3f15..cb2ab4ed9 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; //**************************************** // Status Page Validations @@ -15,6 +16,20 @@ export const getStatusPageQueryValidation = z.object({ timeFrame: z.coerce.number().optional(), }); +export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ + domain: z.string().optional(), +}); + +const customDomainValidation = z.preprocess( + (val) => { + if (val === undefined || val === null) { + return undefined; + } + return normalizeStatusPageDomain(String(val)); + }, + z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional() +); + export const createStatusPageBodyValidation = z .object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), @@ -22,6 +37,7 @@ export const createStatusPageBodyValidation = z url: z.string().regex(/^[a-zA-Z0-9_-]+$/, { message: "URL can only contain letters, numbers, underscores, and hyphens", }), + customDomain: customDomainValidation, timezone: z.string().optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), diff --git a/server/src/app.ts b/server/src/app.ts index e397b798c..907c0f77d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,6 @@ import express from "express"; import path from "path"; -import cors from "cors"; +import cors, { type CorsOptions } from "cors"; import helmet from "helmet"; import compression from "compression"; import cookieParser from "cookie-parser"; @@ -12,6 +12,8 @@ import { setupRoutes } from "@/config/routes.js"; import { InitializedServices } from "@/config/services.js"; import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; +import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; +import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; export const createApp = ({ services, @@ -28,17 +30,23 @@ export const createApp = ({ }) => { const allowedOrigin = envSettings.clientHost; const app = express(); + const defaultCorsOptions: CorsOptions = { + origin: allowedOrigin, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], + credentials: true, + }; + const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository); app.use(generalApiLimiter); - app.use( - cors({ - origin: allowedOrigin, - methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], - credentials: true, - }) - ); + app.use((req, res, next) => { + const corsOptions = isPublicStatusPageApiPath(req.method, req.path) + ? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin } + : defaultCorsOptions; + + return cors(corsOptions)(req, res, next); + }); app.use(express.static(frontendPath)); diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 09dc6651b..cdbb876b8 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -39,7 +39,7 @@ export const initializeControllers = (services: InitializedServices): Initialize geoCheckController: new GeoCheckController(services.geoChecksService), inviteController: new InviteController(services.inviteService), maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService), - queueController: new QueueController(services.jobQueue), + queueController: new QueueController(services.worker), logController: new LogController(services.logger), statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService), notificationController: new NotificationController(services.notificationsService, services.monitorsRepository), diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 9aed9c968..8d872201e 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -28,12 +28,13 @@ import { IUserService, UserService } from "@/domain/users/user.service.js"; import { BufferService, IBufferService } from "@/service/bufferService.js"; import { EmailService, IEmailService } from "@/service/emailService.js"; import { GlobalPingService } from "@/service/globalPingService.js"; -import { QueueHelper } from "@/service/job-queues/job-queue.helper.js"; -import { IJobQueue } from "@/service/job-queues/job-queue.interface.js"; -import { LessSimpleQueue } from "@/service/job-queues/job-queue.less-simple.js"; -import { SuperSimpleQueue } from "@/service/job-queues/job-queue.sumper-simple.js"; +import { WorkerHelper } from "@/worker/worker.helper.js"; +import { IWorker } from "@/worker/worker.interface.js"; +import { LessSimpleWorker } from "@/worker/worker.less-simple.js"; +import { SuperSimpleQueue } from "@/worker/worker.super-simple.js"; import { INetworkService, NetworkService } from "@/service/networkService.js"; import { IStatusService, StatusService } from "@/service/statusService.js"; +import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; // Network providers import { PingProvider } from "@/service/network/PingProvider.js"; @@ -102,6 +103,10 @@ import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; import { type QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import { NotificationReactor } from "@/worker/reactors/reactor.notification.js"; +import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; +import { IncidentReactor } from "@/worker/reactors/reactor.incident.js"; +import { CheckPipeline, GeoChecksPipeline } from "@/worker/worker.check-pipeline.js"; export type InitializedServices = { settingsService: ISettingsService; @@ -110,7 +115,7 @@ export type InitializedServices = { emailService: IEmailService; bufferService: IBufferService; statusService: IStatusService; - jobQueue: IJobQueue; + worker: IWorker; userService: IUserService; checkService: ICheckService; geoChecksService: IGeoChecksService; @@ -309,23 +314,39 @@ export const initializeServices = async ({ const tagsService = new TagsService(tagsRepository, monitorsRepository); - const queueHelper = new QueueHelper( - logger, + const monitorStatusPolicy = new MonitorStatusPolicy(); + // reactors and dispatcher + const notificationReactor = new NotificationReactor(notificationsService); + const incidentReactor = new IncidentReactor(incidentService); + const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]); + + // pipelines + const checkPipeline = new CheckPipeline( + monitorsRepository, + maintenanceWindowsRepository, + checkService, networkService, + bufferService, + monitorStatusPolicy, statusService, - notificationsService, + logger + ); + + const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger); + + const workerHelper = new WorkerHelper( + logger, checkService, settingsService, - bufferService, - incidentService, - maintenanceWindowsRepository, monitorsRepository, teamsRepository, monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksService, - geoChecksRepository + geoChecksRepository, + reactorDispatcher, + checkPipeline, + geoCheckPipeline ); if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") { @@ -337,13 +358,13 @@ export const initializeServices = async ({ }); } - let jobQueue: IJobQueue; + let worker: IWorker; switch (envSettings.queueType) { case "lessSimpleQueue": - jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode); + worker = await LessSimpleWorker.create(logger, workerHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode); break; default: - jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository); + worker = await SuperSimpleQueue.create(logger, workerHelper, monitorsRepository); } // Business services @@ -353,7 +374,7 @@ export const initializeServices = async ({ settingsService, logger, jwt, - jobQueue, + worker, monitorsRepository, usersRepository, invitesRepository, @@ -373,7 +394,7 @@ export const initializeServices = async ({ maintenanceWindowsRepository, }); const monitorService = new MonitorService({ - jobQueue, + worker, logger, games, monitorsRepository, @@ -393,7 +414,7 @@ export const initializeServices = async ({ emailService, bufferService, statusService, - jobQueue, + worker, userService, checkService, geoChecksService, diff --git a/server/src/domain/incidents/incident.service.ts b/server/src/domain/incidents/incident.service.ts index d588086b2..311fad567 100644 --- a/server/src/domain/incidents/incident.service.ts +++ b/server/src/domain/incidents/incident.service.ts @@ -8,7 +8,7 @@ import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.i import type { IUsersRepository } from "@/domain/users/user.repository.interface.js"; import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js"; import type { User } from "@/domain/users/user.type.js"; -import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import type { ILogger } from "@/utils/logger.js"; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index eaa40dcfe..a72d5a50d 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -21,7 +21,7 @@ import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-r import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.js"; import type { ImportedMonitor } from "@/api/validation/monitorValidation.js"; -import { IJobQueue } from "@/service/job-queues/job-queue.interface.js"; +import { IWorker } from "@/worker/worker.interface.js"; import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "MonitorService"; @@ -94,7 +94,7 @@ export interface IMonitorService { export class MonitorService implements IMonitorService { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: IJobQueue; + private worker: IWorker; private logger: ILogger; private games: GamesMap; private monitorsRepository: IMonitorsRepository; @@ -105,7 +105,7 @@ export class MonitorService implements IMonitorService { private incidentsRepository: IIncidentsRepository; constructor({ - jobQueue, + worker, logger, games, monitorsRepository, @@ -115,7 +115,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository, incidentsRepository, }: { - jobQueue: IJobQueue; + worker: IWorker; logger: ILogger; games: GamesMap; monitorsRepository: IMonitorsRepository; @@ -125,7 +125,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; }) { - this.jobQueue = jobQueue; + this.worker = worker; this.logger = logger; this.games = games; this.monitorsRepository = monitorsRepository; @@ -171,7 +171,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" }); } - this.jobQueue.addJob(monitor.id, monitor); + this.worker.addJob(monitor.id, monitor); }; createMonitors = async (monitors: Array): Promise => { @@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" }); } - await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); + await Promise.all(createdMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor))); return createdMonitors; }; @@ -196,7 +196,7 @@ export class MonitorService implements IMonitorService { })); const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]); - await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); + await Promise.all(demoMonitors.map((monitor) => this.worker.addJob(monitor.id, monitor))); return demoMonitors; }; @@ -428,7 +428,7 @@ export class MonitorService implements IMonitorService { editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial }) => { const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body); - await this.jobQueue.updateJob(editedMonitor); + await this.worker.updateJob(editedMonitor); return editedMonitor; }; @@ -448,7 +448,7 @@ export class MonitorService implements IMonitorService { // If notifications were updated, we should update the jobs in the queue if (modifiedCount > 0) { const monitors = await this.monitorsRepository.findByIds(monitorIds); - await Promise.all(monitors.map((monitor) => this.jobQueue.updateJob(monitor))); + await Promise.all(monitors.map((monitor) => this.worker.updateJob(monitor))); } return modifiedCount; @@ -457,9 +457,9 @@ export class MonitorService implements IMonitorService { pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId); if (monitor.isActive) { - await this.jobQueue.resumeJob(monitor); + await this.worker.resumeJob(monitor); } else { - await this.jobQueue.pauseJob(monitor); + await this.worker.pauseJob(monitor); } return monitor; }; @@ -478,9 +478,9 @@ export class MonitorService implements IMonitorService { const results = await Promise.allSettled( monitors.map(async (monitor) => { if (monitor.isActive) { - await this.jobQueue.resumeJob(monitor); + await this.worker.resumeJob(monitor); } else { - await this.jobQueue.pauseJob(monitor); + await this.worker.pauseJob(monitor); } }) ); @@ -541,7 +541,7 @@ export class MonitorService implements IMonitorService { }); }); - await this.jobQueue.deleteJob(monitor); + await this.worker.deleteJob(monitor); return monitor; }; @@ -550,7 +550,7 @@ export class MonitorService implements IMonitorService { await Promise.all( monitors.map(async (monitor) => { try { - await this.jobQueue.deleteJob(monitor); + await this.worker.deleteJob(monitor); await this.checksRepository.deleteByMonitorId(monitor.id); await this.geoChecksRepository.deleteByMonitorId(monitor.id); await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id); diff --git a/server/src/domain/notifications/notification.message-builder.ts b/server/src/domain/notifications/notification.message-builder.ts index 9d3714ae7..b11d99a94 100644 --- a/server/src/domain/notifications/notification.message-builder.ts +++ b/server/src/domain/notifications/notification.message-builder.ts @@ -1,6 +1,6 @@ import type { Monitor } from "@/domain/monitors/monitor.types.js"; import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { NotificationMessage, NotificationType, diff --git a/server/src/domain/notifications/notification.service.ts b/server/src/domain/notifications/notification.service.ts index ea6a9265c..7fc6e3543 100644 --- a/server/src/domain/notifications/notification.service.ts +++ b/server/src/domain/notifications/notification.service.ts @@ -5,7 +5,7 @@ import type { NotificationMessage } from "@/domain/notifications/notification.ty import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js"; import { INotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; -import type { MonitorActionDecision } from "@/service/job-queues/job-queue.helper.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index da80e3e3b..4b3eccd0a 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -5,6 +5,7 @@ export interface IStatusPagesRepository { create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // single fetch findByUrl(url: string): Promise; + findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch // update diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index b7f5f984b..2dedca4ef 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -3,6 +3,7 @@ import { type StatusPageDocument, StatusPageModel } from "@/domain/status-pages/ import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { @@ -47,6 +48,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { type: doc.type, companyName: doc.companyName, url: doc.url, + customDomain: doc.customDomain ?? null, timezone: doc.timezone ?? undefined, color: doc.color, monitors: this.mapIdArray(doc.monitors), @@ -74,10 +76,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }; create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const { logo, ...restData } = data; + const { logo, customDomain, ...restData } = data; void logo; + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); const statusPage = new StatusPageModel({ ...restData, + ...(normalizedCustomDomain ? { customDomain: normalizedCustomDomain } : {}), userId, teamId, }); @@ -102,6 +106,21 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { // Get status page }; + findByCustomDomain = async (customDomain: string): Promise => { + const normalizedDomain = normalizeStatusPageDomain(customDomain); + if (!normalizedDomain) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const statusPage = await StatusPageModel.findOne({ + customDomain: normalizedDomain, + }); + if (!statusPage) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + return this.toEntity(statusPage); + }; + findByTeamId = async (teamId: string): Promise => { const statusPages = await StatusPageModel.find({ teamId }); return this.mapDocuments(statusPages); @@ -113,19 +132,37 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { image: Express.Multer.File | undefined, patch: Partial & { removeLogo?: string } ): Promise => { - const { logo, removeLogo, ...restPatch } = patch; + const { logo, removeLogo, customDomain, ...restPatch } = patch; void logo; - const updateData: StatusPageUpdateData = { ...restPatch }; + const setData: StatusPageUpdateData = { ...restPatch }; + const unsetData: Record = {}; + + if (customDomain !== undefined) { + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); + if (normalizedCustomDomain) { + setData.customDomain = normalizedCustomDomain; + } else { + unsetData.customDomain = 1; + } + } if (image) { - updateData.logo = { + setData.logo = { data: image.buffer as Buffer, contentType: image.mimetype, }; } else if (removeLogo === "true") { - updateData.logo = null; + setData.logo = null; } - const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, { + const updateQuery = + unsetData.customDomain !== undefined + ? { + $set: setData, + $unset: unsetData, + } + : setData; + + const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateQuery, { new: true, }); diff --git a/server/src/domain/status-pages/status-page.model.ts b/server/src/domain/status-pages/status-page.model.ts index 421d5df17..3337fe998 100644 --- a/server/src/domain/status-pages/status-page.model.ts +++ b/server/src/domain/status-pages/status-page.model.ts @@ -65,6 +65,11 @@ const StatusPageSchema = new Schema( required: true, default: "", }, + customDomain: { + type: String, + lowercase: true, + trim: true, + }, timezone: { type: String, }, @@ -128,6 +133,14 @@ const StatusPageSchema = new Schema( { timestamps: true } ); +StatusPageSchema.index( + { customDomain: 1 }, + { + unique: true, + partialFilterExpression: { customDomain: { $type: "string" } }, + } +); + const StatusPageModel = model("StatusPage", StatusPageSchema); export type { StatusPageDocument }; diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 4cfdc2dfa..6be395e49 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -1,10 +1,13 @@ import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/domain/status-pages/status-page.type.js"; +import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; + getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -19,6 +22,30 @@ export class StatusPageService implements IStatusPageService { this.settingsService = settingsService; } + private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { + if (!customDomain) { + return; + } + + const clientHost = normalizeStatusPageDomain(this.settingsService.getSettings().clientHost); + if (clientHost && customDomain === clientHost) { + throw new AppError({ + message: "Custom domain cannot match the Checkmate instance host", + status: 400, + }); + } + }; + + private normalizeCustomDomainInput = (data: Partial): Partial => { + if (!("customDomain" in data)) { + return data; + } + + const customDomain = normalizeStatusPageDomain(data.customDomain); + this.assertCustomDomainAllowed(customDomain); + return { ...data, customDomain }; + }; + private withoutThemeFields = (data: Partial): Partial => { const { theme: _theme, themeMode: _themeMode, ...rest } = data; return rest; @@ -42,7 +69,8 @@ export class StatusPageService implements IStatusPageService { image: Express.Multer.File | undefined, data: Partial ): Promise => { - const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const created = await this.statusPagesRepository.create(userId, teamId, image, normalizedData); return this.normalizeTheme(created); }; @@ -51,13 +79,19 @@ export class StatusPageService implements IStatusPageService { return this.normalizeTheme(statusPage); }; + getStatusPageByCustomDomain = async (customDomain: string): Promise => { + const statusPage = await this.statusPagesRepository.findByCustomDomain(customDomain); + return this.normalizeTheme(statusPage); + }; + getStatusPagesByTeamId = async (teamId: string): Promise => { const statusPages = await this.statusPagesRepository.findByTeamId(teamId); return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData); return this.normalizeTheme(updated); }; diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index 0ef4d72a9..db08b7398 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -26,6 +26,7 @@ export interface StatusPage { type: StatusPageType[]; companyName: string; url: string; + customDomain?: string | null; timezone?: string; color: string; monitors: string[]; diff --git a/server/src/domain/users/user.service.ts b/server/src/domain/users/user.service.ts index 4edacfbc5..33d95f166 100644 --- a/server/src/domain/users/user.service.ts +++ b/server/src/domain/users/user.service.ts @@ -8,12 +8,12 @@ import type { User } from "@/domain/users/user.type.js"; import { canManageRole, type UserRole } from "@/domain/users/user.type.js"; import bcrypt from "bcryptjs"; import { AppError } from "@/utils/AppError.js"; -import { IJobQueue } from "@/service/job-queues/job-queue.interface.js"; import { IEmailService } from "@/service/emailService.js"; import { EnvConfig, ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; import jwt from "jsonwebtoken"; import crypto from "crypto"; +import { IWorker } from "@/worker/worker.interface.js"; type CryptoType = typeof crypto; type JwtType = typeof jwt; const SERVICE_NAME = "userService"; @@ -53,7 +53,7 @@ export class UserService implements IUserService { private settingsService: ISettingsService; private logger: ILogger; private jwt: JwtType; - private jobQueue: IJobQueue; + private worker: IWorker; private crypto: CryptoType; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; @@ -68,7 +68,7 @@ export class UserService implements IUserService { settingsService, logger, jwt, - jobQueue, + worker, monitorsRepository, usersRepository, invitesRepository, @@ -81,7 +81,7 @@ export class UserService implements IUserService { settingsService: ISettingsService; logger: ILogger; jwt: JwtType; - jobQueue: IJobQueue; + worker: IWorker; monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; @@ -93,7 +93,7 @@ export class UserService implements IUserService { this.settingsService = settingsService; this.logger = logger; this.jwt = jwt; - this.jobQueue = jobQueue; + this.worker = worker; this.crypto = crypto; this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; @@ -330,7 +330,7 @@ export class UserService implements IUserService { if (roles.includes("superadmin")) { const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); if (monitors) { - await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor))); + await Promise.all(monitors.map((monitor) => this.worker.deleteJob(monitor))); } } // 6. Delete the user by id diff --git a/server/src/service/emailService.ts b/server/src/service/emailService.ts index ca153392d..c156cc18b 100755 --- a/server/src/service/emailService.ts +++ b/server/src/service/emailService.ts @@ -63,7 +63,7 @@ export class EmailService implements IEmailService { init = () => { const loadTemplate = (templateName: string) => { try { - const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); + const templatePath = this.path.join(__dirname, `../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error: unknown) { diff --git a/server/src/service/job-queues/job-queue.helper.ts b/server/src/service/job-queues/job-queue.helper.ts deleted file mode 100644 index 69ecb86c6..000000000 --- a/server/src/service/job-queues/job-queue.helper.ts +++ /dev/null @@ -1,434 +0,0 @@ -const SERVICE_NAME = "JobQueueHelper"; -import type { Monitor } from "@/domain/monitors/monitor.types.js"; -import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; -import { AppError } from "@/utils/AppError.js"; -import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; -import { ICheckService } from "@/domain/checks/check.service.js"; -import { type IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; -import { IncidentService } from "@/domain/incidents/incident.service.js"; -import { INotificationsService } from "@/domain/notifications/notification.service.js"; -import { INetworkService } from "@/service/networkService.js"; -import { IStatusService } from "@/service/statusService.js"; -import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js"; -import { type StatusChangeResult } from "@/types/network.js"; -import { isWindowActive } from "@/utils/maintenanceWindow.js"; -import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; -import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; -import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; -import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; -import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; -import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; -import { ILogger } from "@/utils/logger.js"; -import { IBufferService } from "@/service/bufferService.js"; - -export interface IQueueHelper { - readonly serviceName: string; - getHeartbeatJob(): (monitor: Monitor) => Promise; - getHeartbeatGeoJob(): (monitor: Monitor) => Promise; - getCleanupOrphanedJob(): () => Promise; - getCleanupRetentionJob(): () => Promise; - isInMaintenanceWindow(monitorId: string, teamId: string): Promise; -} - -export interface MonitorActionDecision { - shouldCreateIncident: boolean; - shouldResolveIncident: boolean; - shouldSendNotification: boolean; - incidentReason: "status_down" | "threshold_breach" | null; - notificationReason: "status_change" | "threshold_breach" | null; - thresholdBreaches?: { - cpu?: boolean; - memory?: boolean; - disk?: boolean; - temp?: boolean; - }; -} - -export class QueueHelper implements IQueueHelper { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private networkService: INetworkService; - private statusService: IStatusService; - private notificationsService: INotificationsService; - private checkService: ICheckService; - private settingsService: ISettingsService; - private buffer: IBufferService; - private incidentService: IncidentService; - private maintenanceWindowsRepository: IMaintenanceWindowsRepository; - private monitorsRepository: IMonitorsRepository; - private teamsRepository: ITeamsRepository; - private monitorStatsRepository: IMonitorStatsRepository; - private checksRepository: IChecksRepository; - private incidentsRepository: IIncidentsRepository; - private geoChecksService: IGeoChecksService; - private geoChecksRepository: IGeoChecksRepository; - - constructor( - logger: ILogger, - networkService: INetworkService, - statusService: IStatusService, - notificationsService: INotificationsService, - checkService: ICheckService, - settingsService: ISettingsService, - buffer: IBufferService, - incidentService: IncidentService, - maintenanceWindowsRepository: IMaintenanceWindowsRepository, - monitorsRepository: IMonitorsRepository, - teamsRepository: ITeamsRepository, - monitorStatsRepository: IMonitorStatsRepository, - checksRepository: IChecksRepository, - incidentsRepository: IIncidentsRepository, - geoChecksService: IGeoChecksService, - geoChecksRepository: IGeoChecksRepository - ) { - this.logger = logger; - this.networkService = networkService; - this.statusService = statusService; - this.checkService = checkService; - this.settingsService = settingsService; - this.buffer = buffer; - this.notificationsService = notificationsService; - this.incidentService = incidentService; - this.maintenanceWindowsRepository = maintenanceWindowsRepository; - this.monitorsRepository = monitorsRepository; - this.teamsRepository = teamsRepository; - this.monitorStatsRepository = monitorStatsRepository; - this.checksRepository = checksRepository; - this.incidentsRepository = incidentsRepository; - this.geoChecksService = geoChecksService; - this.geoChecksRepository = geoChecksRepository; - } - - get serviceName() { - return QueueHelper.SERVICE_NAME; - } - - getHeartbeatJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); - } - - // Step 1. Check for maintenance window, if found, skip the check - - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window`, - service: SERVICE_NAME, - method: "getMonitorJob", - }); - if (monitor.status !== "maintenance") { - await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" }); - } - return; - } - - // Step 2. Request monitor status - const status = await this.networkService.requestStatus(monitor); - if (!status) { - throw new Error("No network response"); - } - - // Step 3. Build check - const check = this.checkService.buildCheck(status); - if (!check) { - this.logger.warn({ - message: `No check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getMonitorJob", - details: { code: status.code, message: status.message }, - }); - return; - } - // Step 4 Add check to buffer - this.buffer.addToBuffer(check); - // Step 4. Update monitor status - const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); - - // Step 5. Get decisions - const decision = this.evaluateMonitorAction(statusChangeResult); - - // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) - if (decision.shouldSendNotification) { - this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => { - this.logger.error({ - message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - }); - } - - // Step 7. Handle incidents - try { - await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status); - } catch (error: unknown) { - this.logger.warn({ - message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getCleanupOrphanedJob = () => { - return async () => { - try { - this.logger.info({ - message: "Starting cleanup of orphaned data", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Get all valid team IDs - const validTeamIds = await this.teamsRepository.findAllTeamIds(); - this.logger.debug({ - message: `Found ${validTeamIds.length} valid teams`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Remove orphaned monitors (monitors without a valid team) - const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); - if (deletedMonitorCount > 0) { - this.logger.info({ - message: `Deleted ${deletedMonitorCount} orphaned monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned monitorStats (stats without a valid monitor) - const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); - this.logger.debug({ - message: `Found ${allMonitorIds.length} valid monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedStatsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedStatsCount} orphaned monitor stats`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned checks - const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedChecksCount} orphaned checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned incidents - const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedIncidentsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedIncidentsCount} orphaned incidents`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned geo checks - const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedGeoChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - this.logger.info({ - message: "Cleanup of orphaned data completed", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getHeartbeatGeoJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - - // Step 1: Validate monitor eligibility - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); - } - - if (!monitor.geoCheckEnabled) { - return; - } - if (!supportsGeoCheck(monitor.type)) { - this.logger.debug({ - message: `Monitor ${monitorId} type does not support geo checks, skipping`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { - this.logger.warn({ - message: `No geo check locations configured for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 2: Check for maintenance window - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 3: Build geo check (handles API calls and polling) - const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); - if (!geoCheck) { - this.logger.warn({ - message: `No geo check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 4: Add geo check to buffer - this.buffer.addGeoCheckToBuffer(geoCheck); - - this.logger.debug({ - message: `Geo check job executed for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - stack: error instanceof Error ? error.stack : undefined, - }); - // Don't throw - geo check failures shouldn't crash the job scheduler - } - }; - }; - - async isInMaintenanceWindow(monitorId: string, teamId: string) { - const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); - const now = new Date(); - return maintenanceWindows.some((window) => isWindowActive(window, now)); - } - - getCleanupRetentionJob = () => { - return async () => { - try { - const settings = await this.settingsService.getDBSettings(); - - const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS - - if (checkTTL === CHECK_TTL_SENTINEL) { - this.logger.info({ - message: `Check TTL is set to unlimited, skipping cleanup`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - return; - } - const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; - const cutoffDate = new Date(Date.now() - checkTTLInMs); - const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); - this.logger.info({ - message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; - }; - - private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { - const { monitor, statusChanged, prevStatus } = statusChangeResult; - - // Initialize result - const decision: MonitorActionDecision = { - shouldCreateIncident: false, - shouldResolveIncident: false, - shouldSendNotification: false, - incidentReason: null, - notificationReason: null, - }; - - if (!statusChanged) { - return decision; - } - - if (monitor.status === "down") { - // Monitor went down (unreachable) - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (monitor.status === "breached") { - // Hardware monitor exceeded thresholds - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "threshold_breach"; - decision.notificationReason = "threshold_breach"; - } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { - // Monitor recovered from down or breached state - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - - return decision; - } -} diff --git a/server/src/shutdown.ts b/server/src/shutdown.ts index 3c1fc4ee7..38d1d5114 100644 --- a/server/src/shutdown.ts +++ b/server/src/shutdown.ts @@ -16,7 +16,7 @@ export const initShutdownListener = (server: Server | null, services: Initialize try { server?.close(); - await services.jobQueue.shutdown(); + await services.worker.shutdown(); await services.db.disconnect(); logger.info({ message: "Graceful shutdown complete" }); process.exit(0); diff --git a/server/src/types/upload.ts b/server/src/types/upload.ts new file mode 100644 index 000000000..355688839 --- /dev/null +++ b/server/src/types/upload.ts @@ -0,0 +1,5 @@ +// Shared constraints for user supplied image uploads +export const ImageMimeTypes = ["image/jpeg", "image/png", "image/jpg"] as const; +export type ImageMimeType = (typeof ImageMimeTypes)[number]; + +export const MAX_IMAGE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB diff --git a/server/src/utils/statusPageDomain.ts b/server/src/utils/statusPageDomain.ts new file mode 100644 index 000000000..ec730a806 --- /dev/null +++ b/server/src/utils/statusPageDomain.ts @@ -0,0 +1,29 @@ +export const normalizeStatusPageDomain = (value?: string | null): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + // Strip scheme, path, and port if a full URL was pasted. + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + const hostname = withoutScheme.split("/")[0]?.split(":")[0] ?? ""; + + return hostname || null; +}; + +export const resolveStatusPageDomainFromRequest = (hostHeader?: string | null, queryDomain?: string | null): string | null => { + const fromQuery = normalizeStatusPageDomain(queryDomain); + if (fromQuery) { + return fromQuery; + } + + if (!hostHeader) { + return null; + } + + return normalizeStatusPageDomain(hostHeader.split(":")[0]); +}; diff --git a/server/src/worker/reactors/reactor.dispatcher.ts b/server/src/worker/reactors/reactor.dispatcher.ts new file mode 100644 index 000000000..a4ffe757e --- /dev/null +++ b/server/src/worker/reactors/reactor.dispatcher.ts @@ -0,0 +1,39 @@ +import { ILogger } from "@/utils/logger.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; + +const SERVICE_NAME = "ReactorDispatcher"; + +export interface IReactorDispatcher { + dispatch(evaluation: MonitorEvaluation): Promise; +} + +export class ReactorDispatcher implements IReactorDispatcher { + constructor( + private logger: ILogger, + private reactors: IMonitorReactor[] + ) {} + + private logFailure(reactor: IMonitorReactor, evaluation: MonitorEvaluation, error: unknown) { + this.logger.error({ + service: SERVICE_NAME, + method: "dispatch", + message: `Reactor ${reactor.name} failed for monitor ${evaluation.monitor.id}: ${error instanceof Error ? error.message : String(error)}`, + stack: error instanceof Error ? error.stack : undefined, + }); + } + + dispatch = async (evaluation: MonitorEvaluation) => { + for (const reactor of this.reactors) { + if (reactor.blocking) { + try { + await reactor.react(evaluation); + } catch (error: unknown) { + this.logFailure(reactor, evaluation, error); + } + } else { + reactor.react(evaluation).catch((error: unknown) => this.logFailure(reactor, evaluation, error)); + } + } + }; +} diff --git a/server/src/worker/reactors/reactor.incident.ts b/server/src/worker/reactors/reactor.incident.ts new file mode 100644 index 000000000..3679a30cf --- /dev/null +++ b/server/src/worker/reactors/reactor.incident.ts @@ -0,0 +1,13 @@ +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IIncidentService } from "@/domain/incidents/incident.service.js"; + +export class IncidentReactor implements IMonitorReactor { + readonly name = "incident"; + readonly blocking = true; + constructor(private incidentService: IIncidentService) {} + + react = async (evaluation: MonitorEvaluation) => { + await this.incidentService.handleIncident(evaluation.monitor, evaluation.statusChange.code, evaluation.decision, evaluation.status); + }; +} diff --git a/server/src/worker/reactors/reactor.interface.ts b/server/src/worker/reactors/reactor.interface.ts new file mode 100644 index 000000000..f9ed6e436 --- /dev/null +++ b/server/src/worker/reactors/reactor.interface.ts @@ -0,0 +1,7 @@ +import { MonitorEvaluation } from "@/worker/worker.interface.js"; + +export interface IMonitorReactor { + readonly name: string; + readonly blocking: boolean; + react(evaluation: MonitorEvaluation): Promise; +} diff --git a/server/src/worker/reactors/reactor.notification.ts b/server/src/worker/reactors/reactor.notification.ts new file mode 100644 index 000000000..1daf528f4 --- /dev/null +++ b/server/src/worker/reactors/reactor.notification.ts @@ -0,0 +1,13 @@ +import { INotificationsService } from "@/domain/notifications/notification.service.js"; +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +export class NotificationReactor implements IMonitorReactor { + readonly name = "notification"; + readonly blocking = false; + constructor(private notificationService: INotificationsService) {} + + react = async (evaluation: MonitorEvaluation) => { + if (!evaluation.decision.shouldSendNotification) return; + await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision); + }; +} diff --git a/server/src/worker/worker.check-pipeline.ts b/server/src/worker/worker.check-pipeline.ts new file mode 100644 index 000000000..f0958fa20 --- /dev/null +++ b/server/src/worker/worker.check-pipeline.ts @@ -0,0 +1,187 @@ +import { Monitor } from "@/domain/monitors/monitor.types.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { ILogger } from "@/utils/logger.js"; +import { INetworkService } from "@/service/networkService.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { IBufferService } from "@/service/bufferService.js"; +import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; +import { IStatusService } from "@/service/statusService.js"; +import { AppError } from "@/utils/AppError.js"; +import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; +import { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; + +const SERVICE_NAME = "CheckPipeline"; + +export interface ICheckPipeline { + run(monitor: Monitor): Promise; // null = skipped +} + +export class CheckPipeline implements ICheckPipeline { + constructor( + private monitorsRepository: IMonitorsRepository, + private maintenanceWindowsRepository: IMaintenanceWindowsRepository, + private checkService: ICheckService, + private networkService: INetworkService, + private bufferService: IBufferService, + private monitorStatusPolicy: IMonitorStatusPolicy, + private statusService: IStatusService, + private logger: ILogger + ) {} + + private async isInMaintenanceWindow(monitorId: string, teamId: string) { + const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); + } + + run = async (monitor: Monitor): Promise => { + const monitorId = monitor.id; + if (!monitorId) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); + } + // **************************** + // Step 1: Acquire + // **************************** + + // Step 1a: Maintenance window gate - skip if in maintenance + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitor.id} is in maintenance window`, + service: SERVICE_NAME, + method: "runCheckPipeline", + }); + if (monitor.status !== "maintenance") { + await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" }); + } + return null; + } + + // Step 1b: Acquire status + const status = await this.networkService.requestStatus(monitor); + if (!status) { + throw new Error("No network response"); + } + + // **************************** + // Step 2: Record + // **************************** + + // Step 2a: Create & record a check, return null if fail + const check = this.checkService.buildCheck(status); + if (!check) { + this.logger.warn({ + message: `No check could be built for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runCHeckPipeline", + details: { code: status.code, message: status.message }, + }); + return null; + } + // Step 2b: Add to buffer + this.bufferService.addToBuffer(check); + + // **************************** + // Step 3: Evaluate and return result to reactors + // **************************** + const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); + + // Step 5. Get decisions and create an evaluation obj + const decision = this.monitorStatusPolicy.evaluate(statusChangeResult); + const evaluation: MonitorEvaluation = { + monitor: statusChangeResult.monitor, + status, + check, + statusChange: statusChangeResult, + decision, + }; + return evaluation; + }; +} + +export class GeoChecksPipeline implements ICheckPipeline { + constructor( + private maintenanceWindowsRepository: IMaintenanceWindowsRepository, + private geoChecksService: IGeoChecksService, + private bufferService: IBufferService, + private logger: ILogger + ) {} + + private async isInMaintenanceWindow(monitorId: string, teamId: string) { + const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); + } + + run = async (monitor: Monitor): Promise => { + // **************************** + // Step 1: Acquire + // **************************** + + // Step 1a: Guards - skip if unsupported or not enabled + + if (!monitor.id) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); + } + + if (!monitor.geoCheckEnabled) { + return null; + } + if (!supportsGeoCheck(monitor.type)) { + this.logger.debug({ + message: `Monitor ${monitor.id} type does not support geo checks, skipping`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: `No geo check locations configured for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // Step 1b: Maintenance window check + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitor.id} is in maintenance window, skipping geo check`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // **************************** + // Step 2: Record + // **************************** + + // Step 2a: Build geo check, return null if fail + const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); + if (!geoCheck) { + this.logger.warn({ + message: `No geo check could be built for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // Step 2b: Add to buffer + this.bufferService.addGeoCheckToBuffer(geoCheck); + + this.logger.debug({ + message: `Geo check job executed for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + }; +} diff --git a/server/src/worker/worker.helper.ts b/server/src/worker/worker.helper.ts new file mode 100644 index 000000000..29de9f2a3 --- /dev/null +++ b/server/src/worker/worker.helper.ts @@ -0,0 +1,243 @@ +const SERVICE_NAME = "JobQueueHelper"; +import type { Monitor } from "@/domain/monitors/monitor.types.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; +import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; +import { ILogger } from "@/utils/logger.js"; +import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; +import { ICheckPipeline } from "@/worker/worker.check-pipeline.js"; + +export interface IWorkerHelper { + readonly serviceName: string; + getHeartbeatJob(): (monitor: Monitor) => Promise; + getHeartbeatGeoJob(): (monitor: Monitor) => Promise; + getCleanupOrphanedJob(): () => Promise; + getCleanupRetentionJob(): () => Promise; +} + +export interface MonitorActionDecision { + shouldCreateIncident: boolean; + shouldResolveIncident: boolean; + shouldSendNotification: boolean; + incidentReason: "status_down" | "threshold_breach" | null; + notificationReason: "status_change" | "threshold_breach" | null; + thresholdBreaches?: { + cpu?: boolean; + memory?: boolean; + disk?: boolean; + temp?: boolean; + }; +} + +export class WorkerHelper implements IWorkerHelper { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private checkService: ICheckService; + private settingsService: ISettingsService; + private monitorsRepository: IMonitorsRepository; + private teamsRepository: ITeamsRepository; + private monitorStatsRepository: IMonitorStatsRepository; + private checksRepository: IChecksRepository; + private incidentsRepository: IIncidentsRepository; + private geoChecksRepository: IGeoChecksRepository; + private reactorDispatcher: IReactorDispatcher; + private checkPipeline: ICheckPipeline; + private geoCheckPipeline: ICheckPipeline; + + constructor( + logger: ILogger, + checkService: ICheckService, + settingsService: ISettingsService, + monitorsRepository: IMonitorsRepository, + teamsRepository: ITeamsRepository, + monitorStatsRepository: IMonitorStatsRepository, + checksRepository: IChecksRepository, + incidentsRepository: IIncidentsRepository, + geoChecksRepository: IGeoChecksRepository, + reactorDispatcher: IReactorDispatcher, + checkPipeline: ICheckPipeline, + geoCheckPipeline: ICheckPipeline + ) { + this.logger = logger; + this.checkService = checkService; + this.settingsService = settingsService; + this.monitorsRepository = monitorsRepository; + this.teamsRepository = teamsRepository; + this.monitorStatsRepository = monitorStatsRepository; + this.checksRepository = checksRepository; + this.incidentsRepository = incidentsRepository; + this.geoChecksRepository = geoChecksRepository; + this.reactorDispatcher = reactorDispatcher; + this.checkPipeline = checkPipeline; + this.geoCheckPipeline = geoCheckPipeline; + } + + get serviceName() { + return WorkerHelper.SERVICE_NAME; + } + + getHeartbeatJob = () => { + return async (monitor: Monitor) => { + try { + const evaluation = await this.checkPipeline.run(monitor); + if (evaluation) await this.reactorDispatcher.dispatch(evaluation); + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + + getHeartbeatGeoJob = () => { + return async (monitor: Monitor) => { + try { + await this.geoCheckPipeline.run(monitor); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + stack: error instanceof Error ? error.stack : undefined, + }); + // Don't throw - geo check failures shouldn't crash the job scheduler + } + }; + }; + + getCleanupOrphanedJob = () => { + return async () => { + try { + this.logger.info({ + message: "Starting cleanup of orphaned data", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Get all valid team IDs + const validTeamIds = await this.teamsRepository.findAllTeamIds(); + this.logger.debug({ + message: `Found ${validTeamIds.length} valid teams`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Remove orphaned monitors (monitors without a valid team) + const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); + if (deletedMonitorCount > 0) { + this.logger.info({ + message: `Deleted ${deletedMonitorCount} orphaned monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned monitorStats (stats without a valid monitor) + const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); + this.logger.debug({ + message: `Found ${allMonitorIds.length} valid monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedStatsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedStatsCount} orphaned monitor stats`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned checks + const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedChecksCount} orphaned checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned incidents + const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedIncidentsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedIncidentsCount} orphaned incidents`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned geo checks + const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedGeoChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + this.logger.info({ + message: "Cleanup of orphaned data completed", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + + getCleanupRetentionJob = () => { + return async () => { + try { + const settings = await this.settingsService.getDBSettings(); + + const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS + + if (checkTTL === CHECK_TTL_SENTINEL) { + this.logger.info({ + message: `Check TTL is set to unlimited, skipping cleanup`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + return; + } + const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; + const cutoffDate = new Date(Date.now() - checkTTLInMs); + const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); + this.logger.info({ + message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + }; + }; +} diff --git a/server/src/service/job-queues/job-queue.interface.ts b/server/src/worker/worker.interface.ts similarity index 59% rename from server/src/service/job-queues/job-queue.interface.ts rename to server/src/worker/worker.interface.ts index 3e8267738..5d1ccf97f 100644 --- a/server/src/service/job-queues/job-queue.interface.ts +++ b/server/src/worker/worker.interface.ts @@ -1,7 +1,18 @@ import { Monitor } from "@/domain/monitors/monitor.types.js"; import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js"; +import { Check } from "@/domain/checks/check.type.js"; +import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js"; +import { MonitorActionDecision } from "@/worker/worker.helper.js"; -export type QueueJobFailure = { +export type MonitorEvaluation = { + monitor: Monitor; + status: MonitorStatusResponse; // raw result from networkService.requestStatus + check: Check; + statusChange: StatusChangeResult; // from statusService.updateMonitorStatus + decision: MonitorActionDecision; // from MonitorStatusPolicy +}; + +export type WorkerJobFailure = { monitorId: string | number; monitorUrl: string | null; monitorType: string | null; @@ -10,17 +21,17 @@ export type QueueJobFailure = { failReason: string | null; }; -export type QueueMetrics = { +export type WorkerMetrics = { jobs: number; activeJobs: number; failingJobs: number; - jobsWithFailures: QueueJobFailure[]; + jobsWithFailures: WorkerJobFailure[]; totalRuns: number; totalFailures: number; workers: QueueWorker[]; }; -export type QueueJobSummary = { +export type WorkerJobSummary = { monitorId: string | number; monitorUrl: string | null; monitorType: string | null; @@ -41,17 +52,17 @@ export type QueueJobSummary = { repeat: number | null; }; -export type QueueJobsPagination = { +export type WorkerJobsPagination = { page?: number; rowsPerPage?: number; }; -export type QueueJobsPage = { - jobs: QueueJobSummary[]; +export type WorkerJobsPage = { + jobs: WorkerJobSummary[]; count: number; }; -export interface IJobQueue { +export interface IWorker { readonly serviceName: string; init(): Promise; addJob(monitorId: string, monitor: Monitor): Promise; @@ -60,7 +71,7 @@ export interface IJobQueue { resumeJob(monitor: Monitor): Promise; updateJob(monitor: Monitor): Promise; shutdown(): Promise; - getMetrics(): Promise; - getJobs(pagination: QueueJobsPagination): Promise; + getMetrics(): Promise; + getJobs(pagination: WorkerJobsPagination): Promise; flushQueues(): Promise<{ success: boolean }>; } diff --git a/server/src/service/job-queues/job-queue.less-simple.ts b/server/src/worker/worker.less-simple.ts similarity index 95% rename from server/src/service/job-queues/job-queue.less-simple.ts rename to server/src/worker/worker.less-simple.ts index ad12341c1..bd4752c6e 100644 --- a/server/src/service/job-queues/job-queue.less-simple.ts +++ b/server/src/worker/worker.less-simple.ts @@ -2,9 +2,9 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; import { ILogger } from "@/utils/logger.js"; import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler"; -import { IQueueHelper } from "@/service/job-queues/job-queue.helper.js"; import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; -import { type QueueMetrics, type QueueJobsPagination, type QueueJobsPage, IJobQueue } from "@/service/job-queues/job-queue.interface.js"; +import { IWorkerHelper } from "@/worker/worker.helper.js"; +import { type WorkerMetrics, type WorkerJobsPagination, type WorkerJobsPage, IWorker } from "@/worker/worker.interface.js"; import { type QueueMode } from "@/domain/app-settings/app-settings.type.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; const SERVICE_NAME = "JobQueue"; @@ -17,11 +17,11 @@ type MonitorJob = Omit & { data: Monitor }; const MONITOR_TEMPLATES = new Set(["monitor-job", "geo-check-job"]); const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template); -export class LessSimpleQueue implements IJobQueue { +export class LessSimpleWorker implements IWorker { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; - private helper: IQueueHelper; + private helper: IWorkerHelper; private monitorsRepository: IMonitorsRepository; private workersRepository: IQueueWorkersRepository; private readonly scheduler: Scheduler; @@ -30,7 +30,7 @@ export class LessSimpleQueue implements IJobQueue { constructor( logger: ILogger, - helper: IQueueHelper, + helper: IWorkerHelper, monitorsRepository: IMonitorsRepository, workersRepository: IQueueWorkersRepository, scheduler: Scheduler, @@ -46,7 +46,7 @@ export class LessSimpleQueue implements IJobQueue { } get serviceName() { - return LessSimpleQueue.SERVICE_NAME; + return LessSimpleWorker.SERVICE_NAME; } private registerListeners = () => { @@ -126,7 +126,7 @@ export class LessSimpleQueue implements IJobQueue { static async create( logger: ILogger, - helper: IQueueHelper, + helper: IWorkerHelper, monitorsRepository: IMonitorsRepository, workersRepository: IQueueWorkersRepository, envSettings: EnvConfig, @@ -139,7 +139,7 @@ export class LessSimpleQueue implements IJobQueue { concurrency: envSettings.queueMode === "primary" ? 0 : 50, processEvery: 100, }); - const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode); + const instance = new LessSimpleWorker(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode); await instance.init(queueMode); return instance; @@ -350,7 +350,7 @@ export class LessSimpleQueue implements IJobQueue { this.scheduler.stop(); }; - getMetrics = async (): Promise => { + getMetrics = async (): Promise => { const stats = await this.scheduler.getStats(); const jobsWithFailures = stats.jobsWithFailures.map((failure) => { @@ -379,7 +379,7 @@ export class LessSimpleQueue implements IJobQueue { }; }; - getJobs = async ({ page = 0, rowsPerPage = 0 }: QueueJobsPagination): Promise => { + getJobs = async ({ page = 0, rowsPerPage = 0 }: WorkerJobsPagination): Promise => { const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined; const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]); const mapped = jobs.map((job) => { diff --git a/server/src/worker/worker.monitor-status-policy.ts b/server/src/worker/worker.monitor-status-policy.ts new file mode 100644 index 000000000..4ef36b2a9 --- /dev/null +++ b/server/src/worker/worker.monitor-status-policy.ts @@ -0,0 +1,46 @@ +import { StatusChangeResult } from "@/types/network.js"; +import { MonitorActionDecision } from "@/worker/worker.helper.js"; + +export interface IMonitorStatusPolicy { + evaluate(statusChange: StatusChangeResult): MonitorActionDecision; +} + +export class MonitorStatusPolicy implements IMonitorStatusPolicy { + evaluate = (statusChangeResult: StatusChangeResult): MonitorActionDecision => { + const { monitor, statusChanged, prevStatus } = statusChangeResult; + + // Initialize result + const decision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }; + + if (!statusChanged) { + return decision; + } + + if (monitor.status === "down") { + // Monitor went down (unreachable) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (monitor.status === "breached") { + // Hardware monitor exceeded thresholds + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "threshold_breach"; + decision.notificationReason = "threshold_breach"; + } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { + // Monitor recovered from down or breached state + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + + return decision; + }; +} diff --git a/server/src/service/job-queues/job-queue.sumper-simple.ts b/server/src/worker/worker.super-simple.ts similarity index 95% rename from server/src/service/job-queues/job-queue.sumper-simple.ts rename to server/src/worker/worker.super-simple.ts index c53ca5860..86b9f152a 100644 --- a/server/src/service/job-queues/job-queue.sumper-simple.ts +++ b/server/src/worker/worker.super-simple.ts @@ -1,25 +1,25 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { ILogger } from "@/utils/logger.js"; import Scheduler from "super-simple-scheduler"; -import { IQueueHelper } from "@/service/job-queues/job-queue.helper.js"; import { Monitor, MonitorType, supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; import { hostname } from "node:os"; import { randomUUID } from "node:crypto"; import { QueueMode } from "@/domain/app-settings/app-settings.type.js"; -import { IJobQueue, type QueueJobSummary } from "@/service/job-queues/job-queue.interface.js"; +import { IWorker, WorkerJobSummary } from "@/worker/worker.interface.js"; +import { IWorkerHelper } from "@/worker/worker.helper.js"; const SERVICE_NAME = "JobQueue"; -export class SuperSimpleQueue implements IJobQueue { +export class SuperSimpleQueue implements IWorker { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; - private helper: IQueueHelper; + private helper: IWorkerHelper; private workerId: string; private monitorsRepository: IMonitorsRepository; private readonly scheduler: Scheduler; - constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { + constructor(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { this.logger = logger; this.helper = helper; this.monitorsRepository = monitorsRepository; @@ -100,7 +100,7 @@ export class SuperSimpleQueue implements IJobQueue { }); }; - static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) { + static async create(logger: ILogger, helper: IWorkerHelper, monitorsRepository: IMonitorsRepository) { const scheduler = new Scheduler({ concurrency: 50, // storeType: "mongo", @@ -341,7 +341,7 @@ export class SuperSimpleQueue implements IJobQueue { return metrics; }; - getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: QueueJobSummary[]; count: number }> => { + getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: WorkerJobSummary[]; count: number }> => { const jobs = await this.scheduler.getJobs(); const mapped = jobs.map((job) => { return { diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 46a6fc630..ea78d5a5d 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -1,5 +1,10 @@ import { jest } from "@jest/globals"; -import { QueueHelper } from "../../src/service/job-queues/job-queue.helper.ts"; +import { WorkerHelper } from "../../src/worker/worker.helper.ts"; +import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts"; +import { CheckPipeline, GeoChecksPipeline } from "../../src/worker/worker.check-pipeline.ts"; +import { NotificationReactor } from "../../src/worker/reactors/reactor.notification.ts"; +import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts"; +import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts"; import { StatusService } from "../../src/service/statusService.ts"; import { IncidentService } from "../../src/domain/incidents/incident.service.ts"; import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; @@ -142,24 +147,37 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { }; const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; + const geoChecksService = { buildGeoCheck: jest.fn() }; - const helper = new QueueHelper( - logger, + const checkPipeline = new CheckPipeline( + monitorsRepo as any, + maintenanceWindowsRepo as any, + checkService as any, networkService as any, + bufferStub as any, + new MonitorStatusPolicy(), statusService as any, - notificationsService as any, + logger + ); + const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepo as any, geoChecksService as any, bufferStub as any, logger); + + const notificationReactor = new NotificationReactor(notificationsService as any); + const incidentReactor = new IncidentReactor(incidentService as any); + const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]); + + const helper = new WorkerHelper( + logger, checkService as any, { getDBSettings: jest.fn() } as any, - bufferStub as any, - incidentService as any, - maintenanceWindowsRepo as any, monitorsRepo as any, { findAllTeamIds: jest.fn() } as any, createStubMonitorStatsRepo() as any, createStubChecksRepo() as any, incidentsRepo as any, - { buildGeoCheck: jest.fn() } as any, - { deleteByMonitorIdsNotIn: jest.fn() } as any + { deleteByMonitorIdsNotIn: jest.fn() } as any, + reactorDispatcher, + checkPipeline, + geoCheckPipeline ); return { diff --git a/server/test/integration/incidentLifecycle.test.ts b/server/test/integration/incidentLifecycle.test.ts index 3f301113a..73b25d893 100644 --- a/server/test/integration/incidentLifecycle.test.ts +++ b/server/test/integration/incidentLifecycle.test.ts @@ -6,7 +6,7 @@ import type { IMonitorsRepository } from "../../src/domain/monitors/monitor.repo import type { IUsersRepository } from "../../src/domain/users/user.repository.interface.ts"; import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts"; import type { Monitor } from "../../src/domain/monitors/monitor.types.ts"; -import type { MonitorActionDecision } from "../../src/service/job-queues/job-queue.helper.ts"; +import type { MonitorActionDecision } from "../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/middleware/handleErrors.test.ts b/server/test/unit/middleware/handleErrors.test.ts new file mode 100644 index 000000000..5994cf7d3 --- /dev/null +++ b/server/test/unit/middleware/handleErrors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; +import { handleErrors } from "../../../src/api/middleware/handleErrors.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response["status"]; + res.json = jest.fn().mockReturnValue(res) as unknown as Response["json"]; + return res; +}; + +describe("handleErrors", () => { + it("maps a multer file-size violation to 413 Payload Too Large", () => { + const res = makeRes(); + handleErrors(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(413); + }); + + it("maps other multer errors to 400", () => { + const res = makeRes(); + handleErrors(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("preserves AppError status codes", () => { + const res = makeRes(); + handleErrors(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(415); + }); + + it("falls back to 500 for unknown errors", () => { + const res = makeRes(); + handleErrors(new Error("boom"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(500); + }); +}); diff --git a/server/test/unit/middleware/statusPageCorsOrigin.test.ts b/server/test/unit/middleware/statusPageCorsOrigin.test.ts new file mode 100644 index 000000000..88a854845 --- /dev/null +++ b/server/test/unit/middleware/statusPageCorsOrigin.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createStatusPageCorsOrigin } from "../../../src/api/middleware/statusPageCorsOrigin.ts"; +import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; + +const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + isPublished: true, + customDomain: "status.example.com", + ...overrides, + }) as StatusPage; + +const createRepo = () => + ({ + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), + }) as unknown as jest.Mocked>; + +const invokeOrigin = (originFn: ReturnType, origin: string): Promise => + new Promise((resolve, reject) => { + originFn?.(origin, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result ?? false); + }); + }); + +describe("createStatusPageCorsOrigin", () => { + let repo: jest.Mocked>; + + beforeEach(() => { + repo = createRepo(); + }); + + it("allows the configured client host without a repository lookup", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://checkmate.example.com")).resolves.toBe(true); + expect(repo.findByCustomDomain).not.toHaveBeenCalled(); + }); + + it("caches published custom domain lookups", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + }); + + it("rejects unpublished custom domains", async () => { + repo.findByCustomDomain.mockResolvedValue(makeStatusPage({ isPublished: false })); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + }); + + it("caches unknown custom domain lookups", async () => { + repo.findByCustomDomain.mockRejectedValue(new Error("Status page not found")); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("unknown.example.com"); + }); +}); diff --git a/server/test/unit/middleware/statusPagePublicApiPath.test.ts b/server/test/unit/middleware/statusPagePublicApiPath.test.ts new file mode 100644 index 000000000..95a799593 --- /dev/null +++ b/server/test/unit/middleware/statusPagePublicApiPath.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "@jest/globals"; +import { isPublicStatusPageApiPath } from "../../../src/api/middleware/statusPagePublicApiPath.ts"; + +describe("isPublicStatusPageApiPath", () => { + it("allows public resolve and slug routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("OPTIONS", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page?type=uptime")).toBe(true); + }); + + it("rejects authenticated and mutating status page routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/team")).toBe(false); + expect(isPublicStatusPageApiPath("POST", "/api/v1/status-page")).toBe(false); + expect(isPublicStatusPageApiPath("PUT", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("DELETE", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("GET", "/api/v1/monitors")).toBe(false); + }); +}); diff --git a/server/test/unit/middleware/upload.test.ts b/server/test/unit/middleware/upload.test.ts new file mode 100644 index 000000000..dc172d3da --- /dev/null +++ b/server/test/unit/middleware/upload.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "@jest/globals"; +import type { Request } from "express"; +import { imageUpload } from "../../../src/api/middleware/upload.ts"; +import { MAX_IMAGE_SIZE_BYTES } from "../../../src/types/upload.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +// Reaches into the multer instance config to exercise the fileFilter/limits in isolation. +// Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads). +const config = imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } }; + +const makeFile = (mimetype: string): Express.Multer.File => ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File; + +const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> => + new Promise((resolve) => { + config.fileFilter({} as Request, makeFile(mimetype), (error: unknown, accepted: unknown) => resolve({ error, accepted })); + }); + +describe("imageUpload middleware", () => { + it("enforces a file size limit so large uploads cannot exhaust memory", () => { + expect(config.limits.fileSize).toBe(MAX_IMAGE_SIZE_BYTES); + expect(config.limits.fileSize).toBeLessThanOrEqual(3 * 1024 * 1024); + }); + + it("limits a request to a single file", () => { + expect(config.limits.files).toBe(1); + }); + + it("accepts allowed image mime types", async () => { + for (const mimetype of ["image/jpeg", "image/png", "image/jpg"]) { + const { error, accepted } = await runFilter(mimetype); + expect(error).toBeNull(); + expect(accepted).toBe(true); + } + }); + + it("rejects non-image uploads with a 415 AppError", async () => { + const { error } = await runFilter("application/octet-stream"); + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).status).toBe(415); + }); +}); diff --git a/server/test/unit/services/checkPipeline.test.ts b/server/test/unit/services/checkPipeline.test.ts new file mode 100644 index 000000000..20ab65950 --- /dev/null +++ b/server/test/unit/services/checkPipeline.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { CheckPipeline, GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts"; +import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + geoCheckEnabled: false, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +const activeWindow = () => { + const now = Date.now(); + return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 }; +}; + +// ── CheckPipeline ──────────────────────────────────────────────────────────── + +const createCheckPipeline = (overrides?: Record) => { + const defaults = { + logger: createMockLogger(), + networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + statusService: { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), + }, + checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, + buffer: { addToBuffer: jest.fn() }, + monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) }, + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, + monitorStatusPolicy: new MonitorStatusPolicy(), + ...overrides, + }; + const pipeline = new CheckPipeline( + defaults.monitorsRepository as any, + defaults.maintenanceWindowsRepository as any, + defaults.checkService as any, + defaults.networkService as any, + defaults.buffer as any, + defaults.monitorStatusPolicy as any, + defaults.statusService as any, + defaults.logger as any + ); + return { pipeline, defaults }; +}; + +describe("CheckPipeline", () => { + it("throws when monitor id is missing", async () => { + const { pipeline } = createCheckPipeline(); + await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + it("skips and sets maintenance status when in maintenance window", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + const result = await pipeline.run(makeMonitor({ status: "up" })); + + expect(result).toBeNull(); + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + }); + + it("does not re-write status when already in maintenance", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + await pipeline.run(makeMonitor({ status: "maintenance" })); + + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("throws when network response is null", async () => { + const { pipeline } = createCheckPipeline({ + networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, + }); + await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response"); + }); + + it("returns null and warns when buildCheck returns null", async () => { + const { pipeline, defaults } = createCheckPipeline({ + checkService: { buildCheck: jest.fn().mockReturnValue(null) }, + }); + + const result = await pipeline.run(makeMonitor()); + + expect(result).toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); + expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); + }); + + it("buffers the check, updates status, and returns an evaluation", async () => { + const status = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const { pipeline, defaults } = createCheckPipeline({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) }, + }); + + const result = await pipeline.run(makeMonitor()); + + expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" }); + expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); + expect(result).toMatchObject({ + monitor: statusChange.monitor, + status, + check: { id: "check-1" }, + statusChange, + decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }), + }); + }); + + it("proceeds with the check when no maintenance window is active", async () => { + const { pipeline, defaults } = createCheckPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) }, + }); + + await pipeline.run(makeMonitor()); + + expect(defaults.networkService.requestStatus).toHaveBeenCalled(); + }); +}); + +// ── GeoChecksPipeline ────────────────────────────────────────────────────────── + +const createGeoPipeline = (overrides?: Record) => { + const defaults = { + logger: createMockLogger(), + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(null) }, + buffer: { addGeoCheckToBuffer: jest.fn() }, + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, + ...overrides, + }; + const pipeline = new GeoChecksPipeline( + defaults.maintenanceWindowsRepository as any, + defaults.geoChecksService as any, + defaults.buffer as any, + defaults.logger as any + ); + return { pipeline, defaults }; +}; + +const geoMonitor = (overrides?: Partial) => + makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"], ...overrides }); + +describe("GeoChecksPipeline", () => { + it("throws when monitor id is missing", async () => { + const { pipeline } = createGeoPipeline(); + await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + it("returns null when geoCheckEnabled is false", async () => { + const { pipeline, defaults } = createGeoPipeline(); + const result = await pipeline.run(makeMonitor({ geoCheckEnabled: false })); + expect(result).toBeNull(); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns null when monitor type does not support geo checks", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ type: "hardware" })); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when geoCheckLocations is empty", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ geoCheckLocations: [] })); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("warns when geoCheckLocations is undefined", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ geoCheckLocations: undefined as any })); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("skips when in maintenance window", async () => { + const { pipeline, defaults } = createGeoPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + await pipeline.run(geoMonitor()); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when buildGeoCheck returns null", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor()); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); + }); + + it("adds geo check to buffer on success", async () => { + const geoCheck = { id: "gc-1", monitorId: "m1" }; + const { pipeline, defaults } = createGeoPipeline({ + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, + }); + await pipeline.run(geoMonitor()); + expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); + }); + + it("propagates errors (the job glue handles isolation)", async () => { + const { pipeline } = createGeoPipeline({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, + }); + await expect(pipeline.run(geoMonitor())).rejects.toThrow("api timeout"); + }); +}); diff --git a/server/test/unit/services/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts index f62f4ccf4..aa4fffce8 100644 --- a/server/test/unit/services/incidentService.test.ts +++ b/server/test/unit/services/incidentService.test.ts @@ -7,7 +7,7 @@ import type { IUsersRepository } from "../../../src/domain/users/user.repository import type { INotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; import type { Incident } from "../../../src/domain/incidents/incident.type.ts"; -import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/lessSimpleQueue.test.ts b/server/test/unit/services/lessSimpleQueue.test.ts index b43383825..8b29df1bb 100644 --- a/server/test/unit/services/lessSimpleQueue.test.ts +++ b/server/test/unit/services/lessSimpleQueue.test.ts @@ -44,7 +44,7 @@ jest.unstable_mockModule("less-simple-scheduler", () => ({ MongoStore: MockMongoStore, })); -const { LessSimpleQueue } = await import("../../../src/service/job-queues/job-queue.less-simple.ts"); +const { LessSimpleWorker: LessSimpleQueue } = await import("../../../src/worker/worker.less-simple.ts"); const createQueueHelper = () => ({ getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 3dff29f26..d64007ebe 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -98,7 +98,7 @@ const createService = ( const jobQueue = overrides.jobQueue ?? createJobQueueMock(); const logger = overrides.logger ?? createMockLogger(); const service = new MonitorService({ - jobQueue: jobQueue as any, + worker: jobQueue as any, logger: logger as any, games: (overrides.games ?? { cs2: { name: "Counter-Strike 2" } }) as any, monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), @@ -1535,7 +1535,7 @@ describe("MonitorService", () => { const logger = createMockLogger(); const service = new MonitorService({ - jobQueue: jobQueue as any, + worker: jobQueue as any, logger: logger as any, games: {} as any, monitorsRepository, diff --git a/server/test/unit/services/monitorStatusPolicy.test.ts b/server/test/unit/services/monitorStatusPolicy.test.ts new file mode 100644 index 000000000..7b340a3e9 --- /dev/null +++ b/server/test/unit/services/monitorStatusPolicy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "@jest/globals"; +import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts"; +import type { StatusChangeResult } from "../../../src/types/network.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeStatusChange = (overrides: Partial & { status?: string }): StatusChangeResult => { + const { status = "up", ...rest } = overrides; + return { + monitor: { id: "m1", status } as any, + statusChanged: false, + prevStatus: "up", + code: 200, + timestamp: 0, + ...rest, + } as StatusChangeResult; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("MonitorStatusPolicy", () => { + const policy = new MonitorStatusPolicy(); + + it("does nothing when statusChanged is false", () => { + const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" })); + expect(decision).toEqual({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }); + }); + + it("creates incident and notifies when monitor goes down", () => { + const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", code: 500 })); + expect(decision).toMatchObject({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }); + }); + + it("creates incident and notifies when monitor is breached", () => { + const decision = policy.evaluate(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" })); + expect(decision).toMatchObject({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "threshold_breach", + notificationReason: "threshold_breach", + }); + }); + + it("resolves incident when monitor recovers from down", () => { + const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" })); + expect(decision).toMatchObject({ + shouldResolveIncident: true, + shouldSendNotification: true, + shouldCreateIncident: false, + notificationReason: "status_change", + }); + }); + + it("resolves incident when monitor recovers from breached", () => { + const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" })); + expect(decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true }); + }); + + it("does not create or resolve for unhandled status transitions", () => { + const decision = policy.evaluate(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" })); + expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }); + }); +}); diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts index 0ae4d8096..6e1c4f4dc 100644 --- a/server/test/unit/services/notificationMessageBuilder.test.ts +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; import type { MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/network.ts"; -import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index eca1f349b..8ba8b34aa 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -4,7 +4,7 @@ import { createMockLogger } from "../../helpers/createMockLogger.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; import type { Notification } from "../../../src/domain/notifications/notification.type.ts"; import type { MonitorStatusResponse } from "../../../src/types/network.ts"; -import type { MonitorActionDecision } from "../../../src/service/job-queues/job-queue.helper.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/reactorDispatcher.test.ts b/server/test/unit/services/reactorDispatcher.test.ts new file mode 100644 index 000000000..0bcc90526 --- /dev/null +++ b/server/test/unit/services/reactorDispatcher.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ReactorDispatcher } from "../../../src/worker/reactors/reactor.dispatcher.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const evaluation = { monitor: { id: "m1" } } as any; + +const makeReactor = (name: string, blocking: boolean, impl?: () => Promise) => ({ + name, + blocking, + react: jest.fn(impl ?? (() => Promise.resolve())), +}); + +describe("ReactorDispatcher", () => { + it("runs every reactor with the evaluation", async () => { + const a = makeReactor("a", true); + const b = makeReactor("b", false); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [a, b] as any); + + await dispatcher.dispatch(evaluation); + + expect(a.react).toHaveBeenCalledWith(evaluation); + expect(b.react).toHaveBeenCalledWith(evaluation); + }); + + it("awaits blocking reactors before resolving", async () => { + let done = false; + const blocking = makeReactor( + "incident", + true, + () => + new Promise((r) => + setTimeout(() => { + done = true; + r(); + }, 10) + ) + ); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [blocking] as any); + + await dispatcher.dispatch(evaluation); + + expect(done).toBe(true); + }); + + it("does not await non-blocking reactors", async () => { + let done = false; + const nonBlocking = makeReactor( + "notification", + false, + () => + new Promise((r) => + setTimeout(() => { + done = true; + r(); + }, 20) + ) + ); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [nonBlocking] as any); + + await dispatcher.dispatch(evaluation); + + expect(done).toBe(false); // resolved before the slow fire-and-forget reactor finished + }); + + it("isolates a throwing blocking reactor and still runs later reactors", async () => { + const logger = createMockLogger(); + const bad = makeReactor("incident", true, () => Promise.reject(new Error("db error"))); + const good = makeReactor("after", true); + const dispatcher = new ReactorDispatcher(logger as any, [bad, good] as any); + + await expect(dispatcher.dispatch(evaluation)).resolves.toBeUndefined(); + + expect(good.react).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); + }); + + it("isolates a rejecting non-blocking reactor (fire-and-forget)", async () => { + const logger = createMockLogger(); + const bad = makeReactor("notification", false, () => Promise.reject(new Error("smtp down"))); + const dispatcher = new ReactorDispatcher(logger as any, [bad] as any); + + await dispatcher.dispatch(evaluation); + await new Promise((r) => setTimeout(r, 10)); // let the detached catch run + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 066bdff95..b2c2cdcee 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -26,20 +26,22 @@ const createRepo = () => ({ create: jest.fn().mockResolvedValue(makeStatusPage()), findByUrl: jest.fn().mockResolvedValue(makeStatusPage()), + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]), updateById: jest.fn().mockResolvedValue(makeStatusPage()), deleteById: jest.fn().mockResolvedValue(makeStatusPage()), removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), }) as unknown as jest.Mocked; -const createSettingsService = (themesEnabled: boolean) => +const createSettingsService = (themesEnabled: boolean, clientHost = "http://localhost:5173") => ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + getSettings: jest.fn().mockReturnValue({ clientHost }), }) as unknown as jest.Mocked; -const createService = (themesEnabled = true) => { +const createService = (themesEnabled = true, clientHost = "http://localhost:5173") => { const repo = createRepo(); - const settingsService = createSettingsService(themesEnabled); + const settingsService = createSettingsService(themesEnabled, clientHost); const service = new StatusPageService(repo, settingsService); return { service, repo, settingsService }; }; @@ -99,6 +101,50 @@ describe("StatusPageService", () => { }); }); + describe("getStatusPageByCustomDomain", () => { + it("delegates to repository", async () => { + const { service, repo } = createService(true); + + const result = await service.getStatusPageByCustomDomain("status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + expect(result).toEqual(makeStatusPage()); + }); + }); + + describe("createStatusPage custom domain validation", () => { + it("rejects custom domains that match the Checkmate instance host", async () => { + const { service } = createService(true, "https://checkmate.example.com"); + + await expect( + service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "checkmate.example.com", + }) + ).rejects.toMatchObject({ + status: 400, + }); + }); + + it("normalizes custom domains before create", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "https://Status.Example.COM", + }); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, expect.objectContaining({ customDomain: "status.example.com" })); + }); + + it("allows multiple status pages without a custom domain", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "First" }); + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "Second" }); + + expect(repo.create).toHaveBeenCalledTimes(2); + }); + }); + describe("getStatusPagesByTeamId", () => { it("delegates to repository", async () => { const { service, repo } = createService(true); diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 8f446b74f..b9e9b8314 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -35,7 +35,7 @@ jest.unstable_mockModule("super-simple-scheduler", () => ({ default: MockScheduler, })); -const { SuperSimpleQueue } = await import("../../../src/service/job-queues/job-queue.sumper-simple.ts"); +const { SuperSimpleQueue } = await import("../../../src/worker/worker.super-simple.ts"); const createQueueHelper = () => ({ getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts index 7a369f4db..1c6f28861 100644 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { QueueHelper } from "../../../src/service/job-queues/job-queue.helper.ts"; +import { WorkerHelper } from "../../../src/worker/worker.helper.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── +// +// WorkerHelper is now thin glue: it wires the scheduler's job functions to the +// CheckPipeline / GeoChecksPipeline (acquire→record→decide) and the +// ReactorDispatcher (react), and owns the cleanup jobs. The pipeline and +// dispatcher internals are tested in checkPipeline.test.ts / reactorDispatcher.test.ts; +// here we test only the glue + cleanup jobs. const createHelper = (overrides?: Record) => { - const maintenanceWindowsRepository = { - findByMonitorId: jest.fn().mockResolvedValue([]), - }; const monitorsRepository = { updateById: jest.fn().mockResolvedValue({}), findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]), @@ -29,55 +32,48 @@ const createHelper = (overrides?: Record) => { const geoChecksRepository = { deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), - }; - const settingsServiceMock = { + const settingsService = { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), }; - const geoChecksServiceMock = { - buildGeoCheck: jest.fn().mockResolvedValue(null), + const checkService = { + deleteOlderThan: jest.fn().mockResolvedValue(0), }; + const checkPipeline = { run: jest.fn().mockResolvedValue(null) }; + const geoCheckPipeline = { run: jest.fn().mockResolvedValue(null) }; + const reactorDispatcher = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const defaults = { logger: createMockLogger(), - networkService: { requestStatus: jest.fn() }, - statusService: statusServiceMock, - notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) }, - settingsService: settingsServiceMock, - buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() }, - incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, - maintenanceWindowsRepository, + checkService, + settingsService, monitorsRepository, teamsRepository, monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksService: geoChecksServiceMock, geoChecksRepository, + reactorDispatcher, + checkPipeline, + geoCheckPipeline, ...overrides, }; - const helper = new QueueHelper( + const helper = new WorkerHelper( defaults.logger as any, - defaults.networkService as any, - defaults.statusService as any, - defaults.notificationsService as any, defaults.checkService as any, defaults.settingsService as any, - defaults.buffer as any, - defaults.incidentService as any, - defaults.maintenanceWindowsRepository as any, defaults.monitorsRepository as any, defaults.teamsRepository as any, defaults.monitorStatsRepository as any, defaults.checksRepository as any, defaults.incidentsRepository as any, - defaults.geoChecksService as any, - defaults.geoChecksRepository as any + defaults.geoChecksRepository as any, + defaults.reactorDispatcher as any, + defaults.checkPipeline as any, + defaults.geoCheckPipeline as any ); - return { helper, defaults, maintenanceWindowsRepository }; + return { helper, defaults }; }; const makeMonitor = (overrides?: Partial): Monitor => @@ -94,7 +90,7 @@ const makeMonitor = (overrides?: Partial): Monitor => // ── Tests ──────────────────────────────────────────────────────────────────── -describe("QueueHelper", () => { +describe("WorkerHelper", () => { describe("serviceName", () => { it("returns JobQueueHelper", () => { const { helper } = createHelper(); @@ -102,278 +98,63 @@ describe("QueueHelper", () => { }); }); - // ── getHeartbeatJob ────────────────────────────────────────────────────── + // ── getHeartbeatJob (glue) ──────────────────────────────────────────────── describe("getHeartbeatJob", () => { - it("throws when monitor id is missing", async () => { - const { helper } = createHelper(); - const job = helper.getHeartbeatJob(); - await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); + it("runs the pipeline and dispatches the evaluation to reactors", async () => { + const evaluation = { monitor: { id: "m1" }, decision: {} }; + const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(evaluation) } }); + + await helper.getHeartbeatJob()(makeMonitor()); + + expect(defaults.checkPipeline.run).toHaveBeenCalled(); + expect(defaults.reactorDispatcher.dispatch).toHaveBeenCalledWith(evaluation); }); - it("skips execution and sets maintenance status when in maintenance window", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); + it("does not dispatch when the pipeline returns null (skipped)", async () => { + const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(null) } }); - await job(makeMonitor({ status: "up" })); + await helper.getHeartbeatJob()(makeMonitor()); - expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); - expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + expect(defaults.reactorDispatcher.dispatch).not.toHaveBeenCalled(); }); - it("skips monitor status update when already in maintenance", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); + it("logs and rethrows when the pipeline throws", async () => { + const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue(new Error("boom")) } }); - await job(makeMonitor({ status: "maintenance" })); - - expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toThrow("boom"); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "boom" })); }); - it("throws when network response is null", async () => { - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); + it("rethrows non-Error failures", async () => { + const { helper } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue("unexpected") } }); - await expect(job(makeMonitor())).rejects.toThrow("No network response"); - }); - - it("returns early and warns when buildCheck returns null", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); - expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); - }); - - it("processes full pipeline: network → check → status → incident", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.buffer.addToBuffer).toHaveBeenCalled(); - expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); - expect(defaults.incidentService.handleIncident).toHaveBeenCalled(); - }); - - it("sends notifications when decision says shouldSendNotification", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith( - statusResult.monitor, - networkResponse, - expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true }) - ); - }); - - it("logs error when notifications fail (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - // Wait for the fire-and-forget catch to execute - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); - }); - - it("logs error when notifications fail with non-Error (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); - }); - - it("logs warning when incident handling fails (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - incidentService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); - }); - - it("logs warning when incident handling fails with non-Error", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - incidentService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); - }); - - it("logs and rethrows on unexpected error with non-Error", async () => { - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await expect(job(makeMonitor())).rejects.toBe("unexpected"); + await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toBe("unexpected"); }); }); - // ── getHeartbeatGeoJob ─────────────────────────────────────────────────── + // ── getHeartbeatGeoJob (glue) ───────────────────────────────────────────── describe("getHeartbeatGeoJob", () => { - it("throws nothing when monitor id is missing (logs error)", async () => { + it("runs the geo pipeline", async () => { const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - await job({} as Monitor); + await helper.getHeartbeatGeoJob()(makeMonitor()); - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" })); + expect(defaults.geoCheckPipeline.run).toHaveBeenCalled(); }); - it("returns early when geoCheckEnabled is false", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: false })); - - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("returns early when monitor type does not support geo checks", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.debug).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }) - ); - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("returns early when geoCheckLocations is empty", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); - }); - - it("returns early when geoCheckLocations is undefined", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); - }); - - it("skips when in maintenance window", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("warns when buildGeoCheck returns null", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); - }); - - it("adds geo check to buffer on success", async () => { - const geoCheck = { id: "gc-1", monitorId: "m1" }; - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); - expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); - }); - - it("logs error on unexpected failure without rethrowing", async () => { - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + it("catches and logs a pipeline error without rethrowing", async () => { + const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue(new Error("api timeout")) } }); + await expect(helper.getHeartbeatGeoJob()(makeMonitor())).resolves.toBeUndefined(); expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" })); }); - it("logs 'Unknown error' for non-Error exceptions", async () => { - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); + it("logs 'Unknown error' for non-Error failures", async () => { + const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue("string error") } }); - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + await helper.getHeartbeatGeoJob()(makeMonitor()); expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); }); @@ -400,7 +181,6 @@ describe("QueueHelper", () => { expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); - // Logs info for each deleted count > 0 expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); @@ -413,7 +193,6 @@ describe("QueueHelper", () => { const job = helper.getCleanupOrphanedJob(); await job(); - // Only start and completion logs, no deletion logs const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message); expect(infoMessages).toContain("Starting cleanup of orphaned data"); expect(infoMessages).toContain("Cleanup of orphaned data completed"); @@ -483,236 +262,4 @@ describe("QueueHelper", () => { expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); }); }); - - // ── isInMaintenanceWindow ──────────────────────────────────────────────── - - describe("isInMaintenanceWindow", () => { - it("returns true when an active window spans now", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns true when repeat interval advances window into current time", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 7200000).toISOString(), - end: new Date(now - 6600000).toISOString(), - repeat: 3600000, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns false when no active windows exist", async () => { - const { helper } = createHelper(); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when window is inactive", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: false, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when active window is in the past without repeat", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 5000).toISOString(), - end: new Date(now - 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when repeat advances past current time without match", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - // Window was 2 hours ago, lasts 10 minutes, repeats every hour - // First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match - // Use a repeat that skips over now - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago - end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window - repeat: 3600000, // 1 hour repeat - }, - ]); - // Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min - // Let's use a different setup that clearly doesn't match - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago - end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window - repeat: 3600000, // 1 hour repeat — next window would be in 29 min - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("preserves previous true result via accumulator", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - { - active: false, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - // Second window is inactive but first already matched — accumulator should preserve true - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - }); - - // ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ───────── - - describe("evaluateMonitorAction", () => { - const runJobAndGetDecision = async (statusResult: Record) => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) }; - const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) }; - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - incidentService, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - await job(makeMonitor()); - return { incidentService, notificationsService }; - }; - - it("does nothing when statusChanged is false", async () => { - const { incidentService, notificationsService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: false, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), - expect.anything() - ); - }); - - it("creates incident and notifies when monitor goes down", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "down" }, - statusChanged: true, - prevStatus: "up", - code: 500, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - shouldCreateIncident: true, - shouldSendNotification: true, - incidentReason: "status_down", - notificationReason: "status_change", - }), - expect.anything() - ); - }); - - it("creates incident and notifies when monitor is breached", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "breached" }, - statusChanged: true, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - shouldCreateIncident: true, - incidentReason: "threshold_breach", - notificationReason: "threshold_breach", - }), - expect.anything() - ); - }); - - it("resolves incident when monitor recovers from down", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: true, - prevStatus: "down", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }), - expect.anything() - ); - }); - - it("resolves incident when monitor recovers from breached", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: true, - prevStatus: "breached", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldResolveIncident: true }), - expect.anything() - ); - }); - - it("does not create or resolve for unhandled status transitions", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "paused" }, - statusChanged: true, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), - expect.anything() - ); - }); - }); }); diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index e2e11369c..7b2fb380b 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -65,7 +65,7 @@ const createService = (overrides?: Record) => { const settingsService = { getSettings: jest.fn().mockReturnValue(makeAppSettings()), }; - const jobQueue = { + const worker = { deleteJob: jest.fn().mockResolvedValue(undefined), }; const jwtMock = { @@ -85,7 +85,7 @@ const createService = (overrides?: Record) => { monitorsRepository, emailService, settingsService, - jobQueue, + worker, jwt: jwtMock, crypto: cryptoMock, ...overrides, @@ -475,23 +475,23 @@ describe("UserService", () => { describe("deleteUser", () => { it("deletes user and cleans up monitor jobs for superadmin", async () => { - const { service, usersRepository, jobQueue, monitorsRepository } = createService(); + const { service, usersRepository, worker, monitorsRepository } = createService(); await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); - expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2); + expect(worker.deleteJob).toHaveBeenCalledTimes(2); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); it("handles null monitors for superadmin gracefully", async () => { - const { service, usersRepository, jobQueue } = createService({ + const { service, usersRepository, worker } = createService({ monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) }, }); await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); - expect(jobQueue.deleteJob).not.toHaveBeenCalled(); + expect(worker.deleteJob).not.toHaveBeenCalled(); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); diff --git a/server/test/unit/utils/statusPageDomain.test.ts b/server/test/unit/utils/statusPageDomain.test.ts new file mode 100644 index 000000000..37cd03633 --- /dev/null +++ b/server/test/unit/utils/statusPageDomain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@jest/globals"; +import { normalizeStatusPageDomain, resolveStatusPageDomainFromRequest } from "../../../src/utils/statusPageDomain.ts"; + +describe("statusPageDomain utils", () => { + describe("normalizeStatusPageDomain", () => { + it("returns null for empty values", () => { + expect(normalizeStatusPageDomain("")).toBeNull(); + expect(normalizeStatusPageDomain(" ")).toBeNull(); + expect(normalizeStatusPageDomain(null)).toBeNull(); + }); + + it("normalizes hostnames", () => { + expect(normalizeStatusPageDomain("Status.Example.COM")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("https://status.example.com/path")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("status.example.com:443")).toBe("status.example.com"); + }); + }); + + describe("resolveStatusPageDomainFromRequest", () => { + it("prefers the query domain over the host header", () => { + expect(resolveStatusPageDomainFromRequest("checkmate.example.com", "status.example.com")).toBe("status.example.com"); + }); + + it("falls back to the host header", () => { + expect(resolveStatusPageDomainFromRequest("status.example.com:8443", undefined)).toBe("status.example.com"); + }); + }); +});