Compare commits

...
Author SHA1 Message Date
Alexander Holliday 182ff390e9 Merge pull request #3642 from bluewave-labs/feat/dns-provider
feat: dns provider
2026-05-08 13:17:52 -07:00
Alex fc711e8e24 per call resolver 2026-05-08 13:15:45 -07:00
Alex 0f662e1a65 update tests 2026-05-08 13:04:18 -07:00
Alex 4b4fae91c2 use enums for validation 2026-05-08 12:56:56 -07:00
Alex 172d36ba1d use injected Resolver 2026-05-08 12:56:47 -07:00
Alex 0c36c8e625 tests 2026-05-08 12:44:58 -07:00
Alex 2a7bde9574 update toEntity methods to extract dnsServer and dnsRecord 2026-05-08 12:44:44 -07:00
Alex cb9cc101c0 add dnsServer and dnsRecordType to monitor model 2026-05-08 12:44:18 -07:00
Alex 06ef850577 update validation to allow for dnsServer and dnsRecord 2026-05-08 12:43:33 -07:00
Alex 0797008ac9 add dns record type 2026-05-08 12:43:02 -07:00
Alex Holliday fda9d9edfe add tests 2026-05-08 08:59:31 -07:00
Alex Holliday c6d86d78b8 Add DNS provider 2026-05-08 08:59:25 -07:00
Alex Holliday 0d72071019 add dns types 2026-05-08 08:59:11 -07:00
Alexander Holliday 84533119c9 Merge pull request #3640 from bluewave-labs/fix/maintenance-window-validation
fix(maintenance): reject past-dated one-time windows at creation
2026-05-07 11:44:13 -07:00
Alex 60cded78a6 validation tests 2026-05-07 11:43:00 -07:00
gorkem-bwl c45bcfaa2f revert(maintenance): keep one-time TTL cleanup, narrow PR to validator-only
Per review feedback: auto-deleting completed one-time maintenance windows is
intentional cleanup, not a bug. The actual bug is past-dated end values being
accepted at creation time, which the validator refines now reject up front.

Reverts:
- Restore expiry field + TTL index on MaintenanceWindow schema
- Restore repository's auto-set of expiry = end for repeat=0
- Drop migration 0007 (no longer needed)
- Restore expiry properties in openapi.json
- Restore expiry in create/edit validators (still optional)

Kept from this PR:
- .strict() on create/edit body schemas
- end > start refine on create/edit
- end > now refine on create when repeat=0 (the real fix)
- DurationUnits const tuple + reuse (rules 5/8/10)
- Client no longer sends redundant expiry (server still derives it)
2026-05-07 13:38:10 -04:00
Alexander Holliday 747f31477d Merge pull request #3638 from bluewave-labs/feat/status-code-down-label
feat(checks): render status code 5000 as "Down" with subtype tooltip
2026-05-07 09:58:26 -07:00
Alex 713c3ea34f add span for ref 2026-05-07 09:56:56 -07:00
Alex 1d90b8d8d9 refactor all call sites 2026-05-07 09:54:58 -07:00
Alex 0827d4f528 add new component to barrel export 2026-05-07 09:54:50 -07:00
Alex 7c14d81a24 refactor utils 2026-05-07 09:54:22 -07:00
Alex 7906f2f615 add stauts code label component 2026-05-07 09:54:03 -07:00
gorkem-bwl b462ba7562 refactor(maintenance): centralize DurationUnit + sync OpenAPI spec
Convention pass per docs/coding-conventions.md (rules 5/8/10/11):
- Promote DurationUnit from a raw union type to a const tuple + derived type.
- Mongoose schema enum imports DurationUnits instead of inlining the values.
- Both validators import DurationUnits instead of inlining z.enum twice.
- Drop stale 'expiry' entries from openapi.json (spec is now strict-mode-correct).
2026-05-07 12:38:54 -04:00
gorkem-bwl bd059da94a refactor(maintenance): tighten validator + migration per /simplify
- Collapse two refines into a single superRefine in createMaintenanceWindowBodyValidation; same on edit.
- Match peer migration convention: use mongoose.connection.db directly, not Model.collection.
- Tighten TTL index detection: require expireAfterSeconds + single-field key.
- Add Starting log at migration top to match peers 0003-0006.
2026-05-07 12:33:03 -04:00
gorkem-bwl 4db95cfa44 fix(maintenance): stop silent deletion of one-time maintenance windows
Maintenance windows could disappear seconds after creation when the API received
a payload with end dates already in the past. The repository auto-set
expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the
expiry field then deleted the row on its next sweep. This was particularly
visible with automation tools sending payloads in non-UTC timezones.

## Changes

### Validator hardening
- Add .strict() to create + edit body schemas so unknown keys return 400
  instead of being silently dropped.
- Refine end > start on create.
- Refine end > now for one-time create payloads (repeat = 0).
- Refine end > start on edit when both are provided.
- Remove the accepted expiry field from both schemas.

### Stop the auto-delete behaviour
- Repository.create no longer copies end into expiry.
- Schema's expiry field + TTL index removed.
- Migration 0007 drops the live TTL index and unsets stale expiry values on
  existing rows so legacy data is no longer at risk.

### Client
- Form submission no longer includes expiry.
2026-05-07 12:29:26 -04:00
gorkem-bwl 602769b3e7 feat(checks): render status code 5000 as "Down" with subtype tooltip
Status code 5000 is Checkmate's internal sentinel for "no HTTP response
received" (DNS failure, connection refused, timeout, TLS error, etc.).
Showing the raw number to admins is confusing — the value isn't a real
HTTP code and means nothing without context.

This change replaces "5000" with "Down" across all check/incident
surfaces, and adds a hover tooltip that explains the underlying cause
based on the stored error message. Real HTTP codes (2xx/3xx/4xx/5xx)
are unchanged.

## Changes
- Add client/src/Utils/statusCode.ts with formatStatusCode,
  getStatusCodeTooltip, getStatusCodeValueType, extractErrorCode, and a
  message-to-subtype parser (timeout / dns / refused / tls / reset).
- Wire into 6 render sites: Checks table, Uptime details Checks table,
  GeoChecks table + map popup, Incidents table, Incident details dialog.
- Add common.statusCode.* i18n keys in en.json with subtype-specific
  descriptions. Tooltip appends the raw error code (ECONNREFUSED,
  ETIMEDOUT, CERT_HAS_EXPIRED, etc.) when present in the message.

## Tooltip examples
- "queryA ECONNREFUSED www.example.com" →
  "The server refused the connection (port closed or service not listening). (ECONNREFUSED)"
- "connect ETIMEDOUT 1.2.3.4:443" →
  "The server didn't respond in time (request timed out). (ETIMEDOUT)"

## Notes
- No DB or server changes; statusCode field stays 5000 in storage.
- Other locales fall back to English keys until translated.
2026-05-07 01:13:09 -04:00
Gorkem Cetin a69f8a5ab5 Merge pull request #3637 from bluewave-labs/docs/coding-conventions
docs: rename frontend-conventions to coding-conventions, add backend rules
2026-05-06 14:06:53 -04:00
25 changed files with 875 additions and 124 deletions

No files matched your search

@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import {
formatStatusCode,
getStatusCodeTooltip,
getStatusCodeValueType,
} from "@/Utils/statusCode";
import { ValueLabel } from "./StatusLabel";
import { Tooltip } from "./Tooltip";
export type StatusCodeLabelProps = {
statusCode: number | null | undefined;
message?: string | null;
fallback?: string;
};
export const StatusCodeLabel = ({
statusCode,
message,
fallback = "N/A",
}: StatusCodeLabelProps) => {
const { t } = useTranslation();
if (!statusCode) {
return <>{fallback}</>;
}
const text = formatStatusCode(statusCode, t);
const tooltip = getStatusCodeTooltip(statusCode, message, t);
const body = (
<ValueLabel
value={getStatusCodeValueType(statusCode)}
text={text}
/>
);
if (!tooltip) return body;
return (
<Tooltip title={tooltip}>
<span>{body}</span>
</Tooltip>
);
};
@@ -25,3 +25,4 @@ export * from "./TextLink";
export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
export * from "./StatusCodeLabel";
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { t } = useTranslation();
const mapPopupStyles = (
<GlobalStyles
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
{formatStatusCode(selectedCheck.statusCode, t)}
</Typography>
<Typography variant="body2">
<Typography
@@ -1,8 +1,12 @@
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements/Table";
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Check } from "@/Types/Check";
@@ -29,7 +33,7 @@ export const ChecksTable = ({
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const getHeaders = (t: Function, uiTimezone: string) => {
const getHeaders = () => {
const headers: Header<Check>[] = [
{
id: "monitorName",
@@ -63,13 +67,10 @@ export const ChecksTable = ({
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
@@ -85,7 +86,7 @@ export const ChecksTable = ({
return headers;
};
const headers = getHeaders(t, uiTimezone);
const headers = getHeaders();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
return (
<Stack
gap={theme.spacing(LAYOUT.MD)}
@@ -123,7 +124,12 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
</Grid>
<Grid size={4}>
<Cell>{incident.statusCode ?? "N/A"}</Cell>
<Cell>
<StatusCodeLabel
statusCode={incident.statusCode}
message={incident.message}
/>
</Cell>
</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
@@ -1,8 +1,6 @@
import { Table } from "@/Components/design-elements";
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import type { Header } from "@/Components/design-elements/Table";
import { ValueLabel } from "@/Components/design-elements";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { ActionsMenu } from "@/Components/actions-menu";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main,
}}
color={
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main
}
textTransform={"capitalize"}
>
{row.resolutionType}
</Typography>
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
let value: ValueType = "neutral";
if (code < 300) value = "positive";
else if (code >= 400) value = "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
repeat,
};
if (repeat === 0) {
payload.expiry = endDateTime.toISOString();
}
return payload;
};
@@ -1,4 +1,9 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { Check } from "@/Types/Check";
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
const getHeaders = (t: Function, uiTimezone: string) => {
export const ChecksTable = ({
checks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
checks: Check[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers: Header<Check>[] = [
{
id: "status",
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
return (
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
},
];
return headers;
};
export const ChecksTable = ({
checks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
checks: Check[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -1,4 +1,9 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers: Header<FlatGeoCheck>[] = [
{
id: "status",
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
return <StatusCodeLabel statusCode={row.statusCode} />;
},
},
{
@@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
+76
View File
@@ -0,0 +1,76 @@
import type { ValueType } from "@/Components/design-elements/StatusLabel";
export const NETWORK_ERROR_CODE = 5000;
export const NETWORK_ERROR_SUBTYPES = [
"timeout",
"dns",
"refused",
"tls",
"reset",
"unknown",
] as const;
export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number];
type TranslateFn = (key: string) => string;
export const isNetworkError = (code?: number | null): boolean =>
code === NETWORK_ERROR_CODE;
const ERROR_CODE_PATTERN =
/\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/;
export const extractErrorCode = (message?: string | null): string | null => {
if (!message) return null;
const match = message.match(ERROR_CODE_PATTERN);
return match ? match[1] : null;
};
export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => {
if (!message) return "unknown";
const m = message.toUpperCase();
if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT"))
return "timeout";
if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS"))
return "dns";
if (m.includes("ECONNREFUSED")) return "refused";
if (
m.includes("CERT_") ||
m.includes("UNABLE_TO_VERIFY") ||
m.includes("DEPTH_ZERO_SELF_SIGNED") ||
m.includes("SELF_SIGNED") ||
m.includes("TLS") ||
m.includes("SSL")
)
return "tls";
if (m.includes("ECONNRESET")) return "reset";
return "unknown";
};
export const formatStatusCode = (
code: number | null | undefined,
t: TranslateFn
): string => {
if (isNetworkError(code)) return t("common.statusCode.down");
return String(code);
};
export const getStatusCodeTooltip = (
code: number | null | undefined,
message: string | null | undefined,
t: TranslateFn
): string | null => {
if (!isNetworkError(code)) return null;
const description = t(
`common.statusCode.networkError.${getNetworkErrorSubtype(message)}`
);
const errorCode = extractErrorCode(message);
return errorCode ? `${description} (${errorCode})` : description;
};
export const getStatusCodeValueType = (code: number): ValueType => {
if (isNetworkError(code)) return "negative";
if (code < 300) return "positive";
if (code < 400) return "neutral";
return "negative";
};
+11
View File
@@ -14,6 +14,17 @@
}
},
"appName": "Checkmate",
"statusCode": {
"down": "Down",
"networkError": {
"timeout": "The server didn't respond in time (request timed out).",
"dns": "Couldn't resolve the hostname (DNS lookup failed).",
"refused": "The server refused the connection (port closed or service not listening).",
"tls": "TLS or SSL handshake failed (certificate or encryption issue).",
"reset": "The connection was reset before a response was received.",
"unknown": "No HTTP response was received from the target."
}
},
"breadcrumbs": {
"details": "Details",
"home": "Home"
+1
View File
@@ -13,6 +13,7 @@ const config: Config = {
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
"^(\\.{1,2}/.*)\\.js$": "$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
setupFilesAfterEnv: [],
+4
View File
@@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
// Third-party
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
@@ -226,6 +228,7 @@ export const initializeServices = async ({
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
pingProvider,
@@ -237,6 +240,7 @@ export const initializeServices = async ({
gameProvider,
grpcProvider,
webSocketProvider,
dnsProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
+2 -2
View File
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
},
durationUnit: {
type: String,
enum: ["seconds", "minutes", "hours", "days"],
enum: DurationUnits,
},
repeat: {
type: Number,
+8 -1
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -351,6 +351,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Number,
default: 300000,
},
dnsServer: {
type: String,
},
dnsRecordType: {
type: String,
enum: DnsRecordTypes,
},
recentChecks: {
type: [checkSnapshotSchema],
default: [],
@@ -446,6 +446,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -505,6 +507,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -0,0 +1,96 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
const SERVICE_NAME = "DNSProvider";
export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
readonly type = "dns";
constructor(private createResolver: () => Resolver) {}
supports(type: MonitorType) {
return type === "dns";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
try {
const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor;
if (!dnsServer) {
throw new Error("DNS server is required for DNS monitoring");
}
const resolver = this.createResolver();
resolver.setServers([dnsServer]);
const {
response: results,
responseTime,
error,
} = await timeRequest(() => {
switch (dnsRecordType.toUpperCase()) {
case "A":
return resolver.resolve4(hostname);
case "AAAA":
return resolver.resolve6(hostname);
case "CNAME":
return resolver.resolveCname(hostname);
case "MX":
return resolver.resolveMx(hostname);
case "TXT":
return resolver.resolveTxt(hostname);
case "NS":
return resolver.resolveNs(hostname);
default:
return resolver.resolve(hostname, dnsRecordType);
}
});
if (error) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: error instanceof Error ? error.message : String(error),
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: false,
results,
},
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: true,
code: 200,
message: "Success",
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: true,
results,
},
};
} catch (error: unknown) {
const originalMessage = error instanceof Error ? error.message : String(error);
throw new AppError({
message: originalMessage || "Error performing DNS check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url },
});
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const;
export type DurationUnit = (typeof DurationUnits)[number];
export interface MaintenanceWindow {
id: string;
+7 -1
View File
@@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js";
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
export type { GeoContinent } from "@/types/geoCheck.js";
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
@@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
export type DnsRecordType = (typeof DnsRecordTypes)[number];
export const MAX_RECENT_CHECKS = 50;
export interface Monitor {
@@ -54,6 +58,8 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
+10
View File
@@ -8,6 +8,7 @@ import type {
CheckNetworkInterfaceInfo,
GotTimings,
ILighthouseAudit,
DnsRecordType,
Monitor,
MonitorMatchMethod,
MonitorType,
@@ -116,6 +117,14 @@ export interface WebSocketStatusPayload {
connected: boolean;
}
export interface DNSStatusPayload {
hostname: string;
dnsServer: string;
recordType: DnsRecordType;
resolved: boolean;
results: unknown;
}
export interface MonitorPayloadMap {
ping: PingStatusPayload;
http: HttpStatusPayload;
@@ -126,6 +135,7 @@ export interface MonitorPayloadMap {
game: GameStatusPayload;
grpc: GrpcStatusPayload;
websocket: WebSocketStatusPayload;
dns: DNSStatusPayload;
unknown: unknown;
}
@@ -1,5 +1,6 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DurationUnits } from "@/types/maintenanceWindow.js";
const dateToString = z.coerce.date().transform((d) => d.toISOString());
@@ -7,17 +8,37 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString());
// Maintenance Window Validations
//****************************************
export const createMaintenanceWindowBodyValidation = z.object({
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
name: z.string().min(1, "Name is required"),
active: z.boolean().optional(),
duration: z.number().min(1, "Duration is required"),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
start: dateToString,
end: dateToString,
repeat: z.number().min(0, "Repeat must be a non-negative number"),
expiry: dateToString.optional(),
});
export const createMaintenanceWindowBodyValidation = z
.object({
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
name: z.string().min(1, "Name is required"),
active: z.boolean().optional(),
duration: z.number().min(1, "Duration is required"),
durationUnit: z.enum(DurationUnits),
start: dateToString,
end: dateToString,
repeat: z.number().min(0, "Repeat must be a non-negative number"),
expiry: dateToString.optional(),
})
.strict()
.superRefine((data, ctx) => {
const start = new Date(data.start).getTime();
const end = new Date(data.end).getTime();
if (end <= start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["end"],
});
}
if (data.repeat === 0 && end <= Date.now()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be in the future for one-time maintenance windows",
path: ["end"],
});
}
});
export const getMaintenanceWindowByIdParamValidation = z.object({
id: z.string().min(1, "ID is required"),
@@ -43,14 +64,29 @@ export const editMaintenanceWindowByIdParamValidation = z.object({
id: z.string().min(1, "ID is required"),
});
export const editMaintenanceByIdWindowBodyValidation = z.object({
active: z.boolean().optional(),
name: z.string().optional(),
repeat: z.number().optional(),
start: dateToString.optional(),
end: dateToString.optional(),
expiry: dateToString.optional(),
monitors: z.array(z.string()).optional(),
duration: z.number().optional(),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
});
export const editMaintenanceByIdWindowBodyValidation = z
.object({
active: z.boolean().optional(),
name: z.string().optional(),
repeat: z.number().optional(),
start: dateToString.optional(),
end: dateToString.optional(),
expiry: dateToString.optional(),
monitors: z.array(z.string()).optional(),
duration: z.number().optional(),
durationUnit: z.enum(DurationUnits).optional(),
})
.strict()
.superRefine((data, ctx) => {
if (data.start && data.end) {
const start = new Date(data.start).getTime();
const end = new Date(data.end).getTime();
if (end <= start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["end"],
});
}
}
});
+11 -13
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({
export const getMonitorsByTeamIdParamValidation = z.object({});
export const getMonitorsByTeamIdQueryValidation = z.object({
type: z
.union([
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
])
.optional(),
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
filter: z.union([z.string(), z.literal("")]).optional(),
});
@@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
filter: z.union([z.string(), z.literal("")]).optional(),
field: z.string().optional(),
order: z.enum(["asc", "desc"]).optional(),
type: z
.union([
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
])
.optional(),
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
explain: booleanCoercion.optional(),
});
@@ -78,6 +68,8 @@ export const createMonitorBodyValidation = z.object({
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
});
export const editMonitorBodyValidation = z.object({
@@ -107,6 +99,8 @@ export const editMonitorBodyValidation = z.object({
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
});
export const pauseMonitorParamValidation = z.object({
@@ -168,6 +162,8 @@ const importedMonitorSchema = z.object({
geoCheckEnabled: z.boolean().default(false),
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});
@@ -219,6 +215,8 @@ export const monitorResponseSchema = z
geoCheckEnabled: z.boolean(),
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
userId: z.string(),
createdAt: z.string(),
@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
const mockResolverInstance = {
setServers: jest.fn(),
resolve4: jest.fn<() => Promise<string[]>>(),
resolve6: jest.fn<() => Promise<string[]>>(),
resolveCname: jest.fn<() => Promise<string[]>>(),
resolveMx: jest.fn<() => Promise<unknown[]>>(),
resolveTxt: jest.fn<() => Promise<string[][]>>(),
resolveNs: jest.fn<() => Promise<string[]>>(),
resolve: jest.fn<() => Promise<unknown>>(),
};
const createResolver = () => mockResolverInstance as any;
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "dns",
url: "example.com",
dnsServer: "8.8.8.8",
dnsRecordType: "A",
...overrides,
}) as Monitor;
beforeEach(() => {
mockResolverInstance.setServers.mockClear();
mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]);
mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]);
mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]);
mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]);
mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]);
mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]);
mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]);
});
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("DNSProvider", {
create: () => new DNSProvider(createResolver),
supportedType: "dns",
unsupportedType: "ping",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DNSProvider", () => {
it("returns success with results and payload on resolve", async () => {
mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]);
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "dns",
status: true,
code: 200,
message: "Success",
payload: {
hostname: "example.com",
dnsServer: "8.8.8.8",
recordType: "A",
resolved: true,
results: ["1.2.3.4"],
},
})
);
expect(typeof result.responseTime).toBe("number");
});
it("calls setServers with the configured dnsServer", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" }));
expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]);
});
it("defaults to A record when dnsRecordType is not provided", async () => {
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor({ dnsRecordType: undefined }));
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
expect(result.payload?.recordType).toBe("A");
});
it("dispatches AAAA records to resolve6", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "AAAA" }));
expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com");
});
it("dispatches CNAME records to resolveCname", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "CNAME" }));
expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com");
});
it("dispatches MX records to resolveMx", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "MX" }));
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
});
it("dispatches TXT records to resolveTxt", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "TXT" }));
expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com");
});
it("dispatches NS records to resolveNs", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "NS" }));
expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com");
});
it("falls back to generic resolve for unrecognized record types", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "SOA" }));
expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA");
});
it("treats record type case-insensitively", async () => {
const provider = new DNSProvider(createResolver);
await provider.handle(makeMonitor({ dnsRecordType: "mx" }));
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
});
it("returns failure response with NETWORK_ERROR when resolver rejects", async () => {
mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND"));
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "dns",
status: false,
code: NETWORK_ERROR,
message: "ENOTFOUND",
payload: expect.objectContaining({
hostname: "example.com",
dnsServer: "8.8.8.8",
recordType: "A",
resolved: false,
results: null,
}),
})
);
expect(typeof result.responseTime).toBe("number");
});
it("stringifies non-Error rejections in failure response", async () => {
mockResolverInstance.resolve4.mockRejectedValue("DNS bombed");
const provider = new DNSProvider(createResolver);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("DNS bombed");
});
it("throws AppError when dnsServer is missing", async () => {
const provider = new DNSProvider(createResolver);
await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring");
});
it("falls back to default message when underlying error has empty message", async () => {
mockResolverInstance.setServers.mockImplementationOnce(() => {
throw new Error("");
});
const provider = new DNSProvider(createResolver);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
});
it("stringifies non-Error throws from outside timeRequest", async () => {
mockResolverInstance.setServers.mockImplementationOnce(() => {
throw 42;
});
const provider = new DNSProvider(createResolver);
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
});
});
@@ -0,0 +1,171 @@
import { describe, expect, it, jest, afterEach } from "@jest/globals";
import {
createMaintenanceWindowBodyValidation,
editMaintenanceByIdWindowBodyValidation,
} from "../../../src/validation/maintenanceWindowValidation.ts";
const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime();
const baseCreateBody = (overrides: Record<string, unknown> = {}) => ({
monitors: ["mon-1"],
name: "Scheduled Maintenance",
duration: 60,
durationUnit: "minutes",
start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
repeat: 0,
...overrides,
});
afterEach(() => {
jest.useRealTimers();
});
describe("createMaintenanceWindowBodyValidation", () => {
it("accepts a valid one-time window with end in the future", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody());
expect(result.success).toBe(true);
});
it("accepts a recurring window even when end is in the past", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(
baseCreateBody({
repeat: 1,
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
})
);
expect(result.success).toBe(true);
});
it("rejects when end is before start", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(
baseCreateBody({
start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
})
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
}
});
it("rejects when end equals start", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString();
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant }));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
}
});
it("rejects a one-time window with end in the past", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(
baseCreateBody({
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
})
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "End must be in the future for one-time maintenance windows",
path: ["end"],
}),
])
);
}
});
it("rejects a one-time window with end exactly equal to now", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(
baseCreateBody({
start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
end: new Date(FROZEN_NOW).toISOString(),
})
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "End must be in the future for one-time maintenance windows",
path: ["end"],
}),
])
);
}
});
it("rejects unknown fields under .strict()", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" }));
expect(result.success).toBe(false);
});
it("rejects an invalid durationUnit value", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" }));
expect(result.success).toBe(false);
});
it("rejects a missing required field", () => {
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
const { name: _name, ...withoutName } = baseCreateBody();
const result = createMaintenanceWindowBodyValidation.safeParse(withoutName);
expect(result.success).toBe(false);
});
});
describe("editMaintenanceByIdWindowBodyValidation", () => {
it("accepts an empty body (all fields optional)", () => {
const result = editMaintenanceByIdWindowBodyValidation.safeParse({});
expect(result.success).toBe(true);
});
it("accepts a partial update with only active toggled", () => {
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false });
expect(result.success).toBe(true);
});
it("rejects unknown fields under .strict()", () => {
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
active: false,
id: "mw-1",
});
expect(result.success).toBe(false);
});
it("rejects when start and end are both present and end <= start", () => {
const start = new Date("2026-05-08T10:00:00Z").toISOString();
const end = new Date("2026-05-08T09:00:00Z").toISOString();
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
}
});
it("does not apply the start/end ordering rule when only end is provided", () => {
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
end: new Date("2020-01-01T00:00:00Z").toISOString(),
});
expect(result.success).toBe(true);
});
it("does not apply the start/end ordering rule when only start is provided", () => {
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
start: new Date("2099-01-01T00:00:00Z").toISOString(),
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from "@jest/globals";
import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts";
const baseDnsBody = {
name: "DNS check",
type: "dns" as const,
url: "example.com",
};
describe("monitorValidation — DNS fields", () => {
describe("createMonitorBodyValidation", () => {
it("retains dnsServer and dnsRecordType on a DNS monitor", () => {
const parsed = createMonitorBodyValidation.parse({
...baseDnsBody,
dnsServer: "8.8.8.8",
dnsRecordType: "A",
});
expect(parsed.dnsServer).toBe("8.8.8.8");
expect(parsed.dnsRecordType).toBe("A");
});
it("accepts every supported DNS record type", () => {
for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) {
const parsed = createMonitorBodyValidation.parse({
...baseDnsBody,
dnsServer: "1.1.1.1",
dnsRecordType: recordType,
});
expect(parsed.dnsRecordType).toBe(recordType);
}
});
it("rejects unknown DNS record types", () => {
expect(() =>
createMonitorBodyValidation.parse({
...baseDnsBody,
dnsServer: "8.8.8.8",
dnsRecordType: "BOGUS",
})
).toThrow();
});
it("treats DNS fields as optional (other monitor types still validate)", () => {
const parsed = createMonitorBodyValidation.parse({
name: "HTTP check",
type: "http",
url: "https://example.com",
});
expect(parsed.dnsServer).toBeUndefined();
expect(parsed.dnsRecordType).toBeUndefined();
});
});
describe("editMonitorBodyValidation", () => {
it("retains dnsServer and dnsRecordType on edits", () => {
const parsed = editMonitorBodyValidation.parse({
dnsServer: "1.1.1.1",
dnsRecordType: "MX",
});
expect(parsed.dnsServer).toBe("1.1.1.1");
expect(parsed.dnsRecordType).toBe("MX");
});
});
describe("importMonitorsBodyValidation", () => {
it("retains dnsServer and dnsRecordType on imported DNS monitors", () => {
const parsed = importMonitorsBodyValidation.parse({
monitors: [
{
name: "Imported DNS",
type: "dns",
url: "example.com",
dnsServer: "8.8.4.4",
dnsRecordType: "TXT",
},
],
});
expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4");
expect(parsed.monitors[0].dnsRecordType).toBe("TXT");
});
});
});