diff --git a/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js b/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js index 67d2c14f2..6cd91ac28 100644 --- a/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js +++ b/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js @@ -31,6 +31,30 @@ export const createPageSpeed = createAsyncThunk( } ); +export const checkEndpointResolution = createAsyncThunk( + "monitors/checkEndpoint", + async (data, thunkApi) => { + try { + const { authToken, monitorURL } = data; + + const res = await networkService.checkEndpointResolution({ + authToken: authToken, + monitorURL: monitorURL, + }) + return res.data; + } catch (error) { + if (error.response && error.response.data) { + return thunkApi.rejectWithValue(error.response.data); + } + const payload = { + status: false, + msg: error.message ? error.message : "Unknown error", + }; + return thunkApi.rejectWithValue(payload); + } + } +) + export const getPagespeedMonitorById = createAsyncThunk( "monitors/getMonitorById", async (data, thunkApi) => { @@ -222,7 +246,24 @@ const pageSpeedMonitorSlice = createSlice({ ? action.payload.msg : "Failed to create page speed monitor"; }) - + // ***************************************************** + // Resolve Endpoint + // ***************************************************** + .addCase(checkEndpointResolution.pending, (state) => { + state.isLoading = true; + }) + .addCase(checkEndpointResolution.fulfilled, (state, action) => { + state.isLoading = false; + state.success = action.payload.success; + state.msg = action.payload.msg; + }) + .addCase(checkEndpointResolution.rejected, (state, action) => { + state.isLoading = false; + state.success = false; + state.msg = action.payload + ? action.payload.msg + : "Failed to check endpoint resolution"; + }) // ***************************************************** // Update Monitor // ***************************************************** diff --git a/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js b/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js index e761361f8..601dc016a 100644 --- a/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js +++ b/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js @@ -31,6 +31,30 @@ export const createUptimeMonitor = createAsyncThunk( } ); +export const checkEndpointResolution = createAsyncThunk( + "monitors/checkEndpoint", + async (data, thunkApi) => { + try { + const { authToken, monitorURL } = data; + + const res = await networkService.checkEndpointResolution({ + authToken: authToken, + monitorURL: monitorURL, + }) + return res.data; + } catch (error) { + if (error.response && error.response.data) { + return thunkApi.rejectWithValue(error.response.data); + } + const payload = { + status: false, + msg: error.message ? error.message : "Unknown error", + }; + return thunkApi.rejectWithValue(payload); + } + } +) + export const getUptimeMonitorById = createAsyncThunk( "monitors/getMonitorById", async (data, thunkApi) => { @@ -271,6 +295,24 @@ const uptimeMonitorsSlice = createSlice({ : "Failed to create uptime monitor"; }) // ***************************************************** + // Resolve Endpoint + // ***************************************************** + .addCase(checkEndpointResolution.pending, (state) => { + state.isLoading = true; + }) + .addCase(checkEndpointResolution.fulfilled, (state, action) => { + state.isLoading = false; + state.success = action.payload.success; + state.msg = action.payload.msg; + }) + .addCase(checkEndpointResolution.rejected, (state, action) => { + state.isLoading = false; + state.success = false; + state.msg = action.payload + ? action.payload.msg + : "Failed to check endpoint resolution"; + }) + // ***************************************************** // Get Monitor By Id // ***************************************************** .addCase(getUptimeMonitorById.pending, (state) => { diff --git a/Client/src/Pages/Monitors/CreateMonitor/index.jsx b/Client/src/Pages/Monitors/CreateMonitor/index.jsx index b5a6ff5dd..bfa62c7fa 100644 --- a/Client/src/Pages/Monitors/CreateMonitor/index.jsx +++ b/Client/src/Pages/Monitors/CreateMonitor/index.jsx @@ -1,8 +1,10 @@ import { useState, useEffect } from "react"; import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material"; +import LoadingButton from '@mui/lab/LoadingButton'; import { useSelector, useDispatch } from "react-redux"; import { monitorValidation } from "../../../Validation/validation"; import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; +import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice" import { useNavigate, useParams } from "react-router-dom"; import { useTheme } from "@emotion/react"; import { createToast } from "../../../Utils/toastUtils"; @@ -17,12 +19,12 @@ import { getUptimeMonitorById } from "../../../Features/UptimeMonitors/uptimeMon import "./index.css"; const CreateMonitor = () => { - const MS_PER_MINUTE = 60000; - const { user, authToken } = useSelector((state) => state.auth); - const { monitors } = useSelector((state) => state.uptimeMonitors); - const dispatch = useDispatch(); - const navigate = useNavigate(); - const theme = useTheme(); + const MS_PER_MINUTE = 60000; + const { user, authToken } = useSelector((state) => state.auth); + const { monitors, isLoading } = useSelector((state) => state.uptimeMonitors); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const theme = useTheme(); const idMap = { "monitor-url": "url", @@ -146,6 +148,17 @@ const CreateMonitor = () => { setErrors(newErrors); createToast({ body: "Error validation data." }); } else { + if (monitor.type === "http") { + const checkEndpointAction = await dispatch( + checkEndpointResolution({ authToken, monitorURL: form.url }) + ) + if (checkEndpointAction.meta.requestStatus === "rejected") { + createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." }); + setErrors({ url: "The entered URL is not reachable." }); + return; + } + } + form = { ...form, description: form.name, @@ -373,18 +386,19 @@ const CreateMonitor = () => { direction="row" justifyContent="flex-end" > - + + Create monitor + ); }; -export default CreateMonitor; +export default CreateMonitor; \ No newline at end of file diff --git a/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx b/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx index 40e42cf70..162b231a2 100644 --- a/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx +++ b/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material"; +import LoadingButton from '@mui/lab/LoadingButton'; import { useSelector, useDispatch } from "react-redux"; import { monitorValidation } from "../../../Validation/validation"; import { useNavigate } from "react-router-dom"; import { useTheme } from "@emotion/react"; -import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice"; +import { createPageSpeed, checkEndpointResolution } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice"; import { createToast } from "../../../Utils/toastUtils"; import { logger } from "../../../Utils/Logger"; import { ConfigBox } from "../../Monitors/styled"; @@ -16,11 +17,12 @@ import Breadcrumbs from "../../../Components/Breadcrumbs"; import "./index.css"; const CreatePageSpeed = () => { - const MS_PER_MINUTE = 60000; - const { user, authToken } = useSelector((state) => state.auth); - const dispatch = useDispatch(); - const navigate = useNavigate(); - const theme = useTheme(); + const MS_PER_MINUTE = 60000; + const { user, authToken } = useSelector((state) => state.auth); + const { isLoading } = useSelector((state) => state.pageSpeedMonitors); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const theme = useTheme(); const idMap = { "monitor-url": "url", @@ -103,248 +105,252 @@ const CreatePageSpeed = () => { abortEarly: false, }); - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: "Error validation data." }); - } else { - form = { - ...form, - description: form.name, - teamId: user.teamId, - userId: user._id, - notifications: monitor.notifications, - }; - const action = await dispatch(createPageSpeed({ authToken, monitor: form })); - if (action.meta.requestStatus === "fulfilled") { - createToast({ body: "Monitor created successfully!" }); - navigate("/pagespeed"); - } else { - createToast({ body: "Failed to create monitor." }); - } - } - }; + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + createToast({ body: "Error validation data." }); + } else { + const checkEndpointAction = await dispatch( + checkEndpointResolution({ authToken, monitorURL: form.url }) + ) + if (checkEndpointAction.meta.requestStatus === "rejected") { + createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." }); + setErrors({ url: "The entered URL is not reachable." }); + return; + } - //select values - const frequencies = [ - { _id: 3, name: "3 minutes" }, - { _id: 5, name: "5 minutes" }, - { _id: 10, name: "10 minutes" }, - { _id: 20, name: "20 minutes" }, - { _id: 60, name: "1 hour" }, - { _id: 1440, name: "1 day" }, - { _id: 10080, name: "1 week" }, - ]; - return ( - - - - - - Create your{" "} - - - pagespeed monitor - - - - - General settings - - Here you can select the URL of the host, together with the type of monitor. - - - - - - - - - - Checks to perform - - You can always add or remove checks after adding your site. - - - - - handleChange(event)} - /> - - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - - - Incident notifications - - When there is an incident, notify users. - - - - When there is a new incident, - logger.warn("disabled")} - isDisabled={true} - /> - notification.type === "email" - )} - value={user?.email} - onChange={(event) => handleChange(event)} - /> - logger.warn("disabled")} - isDisabled={true} - /> - {monitor.notifications.some( - (notification) => notification.type === "emails" - ) ? ( - - logger.warn("disabled")} - /> - - You can separate multiple emails with a comma - - - ) : ( - "" - )} - - - - - Advanced settings - - - handleChange(event, "interval")} + items={frequencies} + /> + + + + + Create monitor + + + + + ); }; export default CreatePageSpeed; diff --git a/Client/src/Utils/NetworkService.js b/Client/src/Utils/NetworkService.js index e000b2c08..5571d40ff 100644 --- a/Client/src/Utils/NetworkService.js +++ b/Client/src/Utils/NetworkService.js @@ -3,7 +3,6 @@ const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL; const FALLBACK_BASE_URL = "http://localhost:5000/api/v1"; import { clearAuthState } from "../Features/Auth/authSlice"; import { clearUptimeMonitorState } from "../Features/UptimeMonitors/uptimeMonitorsSlice"; -import { logger } from "./Logger"; class NetworkService { constructor(store, dispatch, navigate) { this.store = store; @@ -88,22 +87,48 @@ class NetworkService { }, }); } + + /** + * + * ************************************ + * Check the endpoint resolution + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.authToken - The authorization token to be used in the request header. + * @param {Object} config.monitorURL - The monitor url to be sent in the request body. + * @returns {Promise} The response from the axios POST request. + */ + async checkEndpointResolution(config) { + const { authToken, monitorURL } = config; + const params = new URLSearchParams(); + + if (monitorURL) params.append("monitorURL", monitorURL); - /** - * - * ************************************ - * Gets monitors and summary of stats by TeamID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.authToken - The authorization token to be used in the request header. - * @param {string} config.teamId - Team ID - * @param {Array} config.types - Array of monitor types - * @returns {Promise} The response from the axios POST request. - */ - async getMonitorsAndSummaryByTeamId(config) { - const params = new URLSearchParams(); + return this.axiosInstance.get(`/monitors/resolution/url?${params.toString()}`, { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + } + }) + } + + /** + * + * ************************************ + * Gets monitors and summary of stats by TeamID + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.authToken - The authorization token to be used in the request header. + * @param {string} config.teamId - Team ID + * @param {Array} config.types - Array of monitor types + * @returns {Promise} The response from the axios POST request. + */ + async getMonitorsAndSummaryByTeamId(config) { + const params = new URLSearchParams(); if (config.types) { config.types.forEach((type) => { diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index 91c1e3f74..24761089f 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -3,6 +3,7 @@ import { getMonitorByIdQueryValidation, getMonitorsByTeamIdValidation, createMonitorBodyValidation, + getMonitorURLByQueryValidation, editMonitorBodyValidation, getMonitorsAndSummaryByTeamIdParamValidation, getMonitorsAndSummaryByTeamIdQueryValidation, @@ -13,13 +14,14 @@ import { getCertificateParamValidation, } from "../validation/joi.js"; import sslChecker from "ssl-checker"; - -const SERVICE_NAME = "monitorController"; import { errorMessages, successMessages } from "../utils/messages.js"; import jwt from "jsonwebtoken"; import { getTokenFromHeaders } from "../utils/utils.js"; import logger from "../utils/logger.js"; import { handleError, handleValidationError } from "./controllerUtils.js"; +import dns from "dns"; + +const SERVICE_NAME = "monitorController"; /** * Returns all monitors @@ -257,6 +259,44 @@ const createMonitor = async (req, res, next) => { } }; +/** + * Checks if the endpoint can be resolved + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.query - The query parameters of the request. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message, and the resolution result. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ +const checkEndpointResolution = async (req, res, next) => { + try { + await getMonitorURLByQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + let { monitorURL } = req.query; + monitorURL = new URL(monitorURL); + await new Promise((resolve, reject) => { + dns.resolve(monitorURL.hostname, (error) => { + if (error) { + reject(error); + } + resolve(); + }); + }); + return res.status(200).json({ + success: true, + msg: `URL resolved successfully`, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "checkEndpointResolution")); + } +} + /** * Deletes a monitor by its ID and also deletes associated checks, alerts, and notifications. * @async @@ -477,6 +517,7 @@ export { getMonitorsAndSummaryByTeamId, getMonitorsByTeamId, createMonitor, + checkEndpointResolution, deleteMonitor, deleteAllMonitors, editMonitor, diff --git a/Server/openapi.json b/Server/openapi.json index fd7eae94f..da5eed94f 100644 --- a/Server/openapi.json +++ b/Server/openapi.json @@ -22,12 +22,16 @@ "variables": { "PORT": { "description": "API Port", - "enum": ["5000"], + "enum": [ + "5000" + ], "default": "5000" }, "API_PATH": { "description": "API Base Path", - "enum": ["api/v1"], + "enum": [ + "api/v1" + ], "default": "api/v1" } } @@ -38,7 +42,9 @@ "variables": { "API_PATH": { "description": "API Base Path", - "enum": ["api/v1"], + "enum": [ + "api/v1" + ], "default": "api/v1" } } @@ -49,12 +55,16 @@ "variables": { "PORT": { "description": "API Port", - "enum": ["5000"], + "enum": [ + "5000" + ], "default": "5000" }, "API_PATH": { "description": "API Base Path", - "enum": ["api/v1"], + "enum": [ + "api/v1" + ], "default": "api/v1" } } @@ -89,7 +99,9 @@ "paths": { "/auth/register": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Register a new user", "requestBody": { "content": { @@ -125,8 +137,23 @@ }, "role": { "type": "array", - "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], - "default": ["superadmin"] + "enum": [ + [ + "user" + ], + [ + "admin" + ], + [ + "superadmin" + ], + [ + "Demo" + ] + ], + "default": [ + "superadmin" + ] }, "teamId": { "type": "string", @@ -173,14 +200,19 @@ }, "/auth/login": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Login with credentials", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["email", "password"], + "required": [ + "email", + "password" + ], "properties": { "email": { "type": "string", @@ -231,7 +263,9 @@ }, "/auth/user/{userId}": { "put": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Change user information", "parameters": [ { @@ -292,7 +326,9 @@ ] }, "delete": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Delete user", "parameters": [ { @@ -345,7 +381,9 @@ }, "/auth/users/superadmin": { "get": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Checks to see if an admin account exists", "responses": { "200": { @@ -388,7 +426,9 @@ }, "/auth/users": { "get": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Get all users", "responses": { "200": { @@ -431,14 +471,18 @@ }, "/auth/recovery/request": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Request a recovery token", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["email"], + "required": [ + "email" + ], "properties": { "email": { "type": "string", @@ -485,14 +529,18 @@ }, "/auth/recovery/validate": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Validate recovery token", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken"], + "required": [ + "recoveryToken" + ], "properties": { "recoveryToken": { "type": "string" @@ -538,14 +586,19 @@ }, "/auth/recovery/reset": { "post": { - "tags": ["auth"], + "tags": [ + "auth" + ], "description": "Password reset", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken", "password"], + "required": [ + "recoveryToken", + "password" + ], "properties": { "recoveryToken": { "type": "string" @@ -594,14 +647,19 @@ }, "/invite": { "post": { - "tags": ["invite"], + "tags": [ + "invite" + ], "description": "Request an invitation", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["email", "role"], + "required": [ + "email", + "role" + ], "properties": { "email": { "type": "string" @@ -655,14 +713,18 @@ }, "/invite/verify": { "post": { - "tags": ["invite"], + "tags": [ + "invite" + ], "description": "Request an invitation", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": ["token"], + "required": [ + "token" + ], "properties": { "token": { "type": "string" @@ -713,7 +775,9 @@ }, "/monitors": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get all monitors", "responses": { "200": { @@ -754,7 +818,9 @@ ] }, "post": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Create a new monitor", "requestBody": { "content": { @@ -804,7 +870,9 @@ ] }, "delete": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Delete all monitors", "responses": { "200": { @@ -845,9 +913,78 @@ ] } }, + "/monitors/resolution/url": { + "get": { + "tags": [ + "monitors" + ], + "description": "Check DNS resolution for a given URL", + "parameters": [ + { + "name": "monitorURL", + "in": "query", + "required": true, + "schema": { + "type": "string", + "example": "https://example.com" + }, + "description": "The URL to check DNS resolution for" + } + ], + "responses": { + "200": { + "description": "URL resolved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "400": { + "description": "DNS resolution failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/monitors/{monitorId}": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get monitor by id", "parameters": [ { @@ -898,7 +1035,9 @@ ] }, "put": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Update monitor by id", "parameters": [ { @@ -958,7 +1097,9 @@ ] }, "delete": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Delete monitor by id", "parameters": [ { @@ -1011,7 +1152,9 @@ }, "/monitors/stats/{monitorId}": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get monitor stats", "parameters": [ { @@ -1064,7 +1207,9 @@ }, "/monitors/certificate/{monitorId}": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get monitor certificate", "parameters": [ { @@ -1117,7 +1262,9 @@ }, "/monitors/team/summary/{teamId}": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get monitors and summary by teamId", "parameters": [ { @@ -1134,7 +1281,11 @@ "required": false, "schema": { "type": "array", - "enum": ["http", "ping", "pagespeed"] + "enum": [ + "http", + "ping", + "pagespeed" + ] } } ], @@ -1179,7 +1330,9 @@ }, "/monitors/team/{teamId}": { "get": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Get monitors by teamId", "parameters": [ { @@ -1206,7 +1359,10 @@ "required": false, "schema": { "type": "string", - "enum": ["asc", "desc"] + "enum": [ + "asc", + "desc" + ] } }, { @@ -1225,7 +1381,11 @@ "required": false, "schema": { "type": "string", - "enum": ["http", "ping", "pagespeed"] + "enum": [ + "http", + "ping", + "pagespeed" + ] } }, { @@ -1269,7 +1429,11 @@ "required": false, "schema": { "type": "string", - "enum": ["http", "ping", "pagespeed"] + "enum": [ + "http", + "ping", + "pagespeed" + ] } } ], @@ -1314,7 +1478,9 @@ }, "/monitors/pause/{monitorId}": { "post": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Pause monitor", "parameters": [ { @@ -1367,7 +1533,9 @@ }, "/monitors/demo": { "post": { - "tags": ["monitors"], + "tags": [ + "monitors" + ], "description": "Create a demo monitor", "requestBody": { "content": { @@ -1419,7 +1587,9 @@ }, "/checks/{monitorId}": { "get": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Get all checks for a monitor", "parameters": [ { @@ -1470,7 +1640,9 @@ ] }, "post": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Create a new check", "parameters": [ { @@ -1530,7 +1702,9 @@ ] }, "delete": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Delete all checks for a monitor", "parameters": [ { @@ -1583,7 +1757,9 @@ }, "/checks/team/{teamId}": { "get": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Get all checks for a team", "parameters": [ { @@ -1634,7 +1810,9 @@ ] }, "delete": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Delete all checks for a team", "parameters": [ { @@ -1687,7 +1865,9 @@ }, "/checks/team/ttl": { "put": { - "tags": ["checks"], + "tags": [ + "checks" + ], "description": "Update check TTL", "requestBody": { "content": { @@ -1739,7 +1919,9 @@ }, "/maintenance-window/monitor/{monitorId}": { "get": { - "tags": ["maintenance-window"], + "tags": [ + "maintenance-window" + ], "description": "Get maintenance window for monitor", "parameters": [ { @@ -1790,7 +1972,9 @@ ] }, "post": { - "tags": ["maintenance-window"], + "tags": [ + "maintenance-window" + ], "description": "Create maintenance window for monitor", "parameters": [ { @@ -1852,7 +2036,9 @@ }, "/maintenance-window/user/{userId}": { "get": { - "tags": ["maintenance-window"], + "tags": [ + "maintenance-window" + ], "description": "Get maintenance window for user", "parameters": [ { @@ -1905,7 +2091,9 @@ }, "/queue/jobs": { "get": { - "tags": ["queue"], + "tags": [ + "queue" + ], "description": "Get all jobs in queue", "responses": { "200": { @@ -1946,7 +2134,9 @@ ] }, "post": { - "tags": ["queue"], + "tags": [ + "queue" + ], "description": "Create a new job. Useful for testing scaling workers", "responses": { "200": { @@ -1989,7 +2179,9 @@ }, "/queue/metrics": { "get": { - "tags": ["queue"], + "tags": [ + "queue" + ], "description": "Get queue metrics", "responses": { "200": { @@ -2032,7 +2224,9 @@ }, "/queue/obliterate": { "post": { - "tags": ["queue"], + "tags": [ + "queue" + ], "description": "Obliterate job queue", "responses": { "200": { @@ -2074,7 +2268,6 @@ } } }, - "components": { "securitySchemes": { "bearerAuth": { @@ -2113,7 +2306,14 @@ }, "UserUpdateRequest": { "type": "object", - "required": ["firstName", "lastName", "email", "password", "role", "teamId"], + "required": [ + "firstName", + "lastName", + "email", + "password", + "role", + "teamId" + ], "properties": { "firstName": { "type": "string" @@ -2135,8 +2335,23 @@ }, "role": { "type": "array", - "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], - "default": ["superadmin"] + "enum": [ + [ + "user" + ], + [ + "admin" + ], + [ + "superadmin" + ], + [ + "Demo" + ] + ], + "default": [ + "superadmin" + ] }, "deleteProfileImage": { "type": "boolean" @@ -2145,7 +2360,14 @@ }, "CreateMonitorBody": { "type": "object", - "required": ["userId", "teamId", "name", "description", "type", "url"], + "required": [ + "userId", + "teamId", + "name", + "description", + "type", + "url" + ], "properties": { "_id": { "type": "string" @@ -2164,7 +2386,11 @@ }, "type": { "type": "string", - "enum": ["http", "ping", "pagespeed"] + "enum": [ + "http", + "ping", + "pagespeed" + ] }, "url": { "type": "string" @@ -2205,7 +2431,13 @@ }, "CreateCheckBody": { "type": "object", - "required": ["monitorId", "status", "responseTime", "statusCode", "message"], + "required": [ + "monitorId", + "status", + "responseTime", + "statusCode", + "message" + ], "properties": { "monitorId": { "type": "string" @@ -2226,7 +2458,9 @@ }, "UpdateCheckTTLBody": { "type": "object", - "required": ["ttl"], + "required": [ + "ttl" + ], "properties": { "ttl": { "type": "integer" @@ -2235,7 +2469,13 @@ }, "CreateMaintenanceWindowBody": { "type": "object", - "required": ["userId", "active", "oneTime", "start", "end"], + "required": [ + "userId", + "active", + "oneTime", + "start", + "end" + ], "properties": { "userId": { "type": "string" @@ -2262,4 +2502,4 @@ } } } -} +} \ No newline at end of file diff --git a/Server/routes/monitorRoute.js b/Server/routes/monitorRoute.js index d6ba03ece..f306a5146 100644 --- a/Server/routes/monitorRoute.js +++ b/Server/routes/monitorRoute.js @@ -1,17 +1,18 @@ import { Router } from "express"; import { - getAllMonitors, - getMonitorStatsById, - getMonitorCertificate, - getMonitorById, - getMonitorsAndSummaryByTeamId, - getMonitorsByTeamId, - createMonitor, - deleteMonitor, - deleteAllMonitors, - editMonitor, - pauseMonitor, - addDemoMonitors, + getAllMonitors, + getMonitorStatsById, + getMonitorCertificate, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + checkEndpointResolution, + deleteMonitor, + deleteAllMonitors, + editMonitor, + pauseMonitor, + addDemoMonitors, } from "../controllers/monitorController.js"; import { isAllowed } from "../middleware/isAllowed.js"; import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; @@ -21,15 +22,25 @@ const router = Router(); router.get("/", getAllMonitors); router.get("/stats/:monitorId", getMonitorStatsById); router.get("/certificate/:monitorId", (req, res, next) => { - getMonitorCertificate(req, res, next, fetchMonitorCertificate); + getMonitorCertificate(req, res, next, fetchMonitorCertificate); }); router.get("/:monitorId", getMonitorById); router.get("/team/summary/:teamId", getMonitorsAndSummaryByTeamId); router.get("/team/:teamId", getMonitorsByTeamId); -router.post("/", isAllowed(["admin", "superadmin"]), createMonitor); +router.get( + "/resolution/url", + isAllowed(["admin", "superadmin"]), + checkEndpointResolution +) -router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), deleteMonitor); +router.delete( + "/:monitorId", + isAllowed(["admin", "superadmin"]), + deleteMonitor +); + +router.post("/", isAllowed(["admin", "superadmin"]), createMonitor); router.put("/:monitorId", isAllowed(["admin", "superadmin"]), editMonitor); diff --git a/Server/tests/controllers/monitorController.test.js b/Server/tests/controllers/monitorController.test.js index b14626204..0a9dbafe6 100644 --- a/Server/tests/controllers/monitorController.test.js +++ b/Server/tests/controllers/monitorController.test.js @@ -6,6 +6,7 @@ import { getMonitorsAndSummaryByTeamId, getMonitorsByTeamId, createMonitor, + checkEndpointResolution, deleteMonitor, deleteAllMonitors, editMonitor, @@ -16,9 +17,7 @@ import jwt from "jsonwebtoken"; import sinon from "sinon"; import { successMessages } from "../../utils/messages.js"; import logger from "../../utils/logger.js"; -import * as monitorController from "../../controllers/monitorController.js"; -import { fetchMonitorCertificate } from "../../controllers/controllerUtils.js"; - +import dns from "dns"; const SERVICE_NAME = "monitorController"; describe("Monitor Controller - getAllMonitors", () => { @@ -460,6 +459,50 @@ describe("Monitor Controller - createMonitor", () => { }); }); +describe("Monitor Controllor - checkEndpointResolution", () => { + let req, res, next, dnsResolveStub; + beforeEach(() => { + req = { query: { monitorURL: 'https://example.com' } }; + res = { status: sinon.stub().returnsThis(), json: sinon.stub() }; + next = sinon.stub(); + dnsResolveStub = sinon.stub(dns, 'resolve'); + }); + afterEach(() => { + dnsResolveStub.restore(); + }); + it('should resolve the URL successfully', async () => { + dnsResolveStub.callsFake((hostname, callback) => callback(null)); + await checkEndpointResolution(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect(res.json.calledWith({ + success: true, + msg: 'URL resolved successfully', + })).to.be.true; + expect(next.called).to.be.false; + }); + it("should return an error if DNS resolution fails", async () => { + const dnsError = new Error("DNS resolution failed"); + dnsError.code = 'ENOTFOUND'; + dnsResolveStub.callsFake((hostname, callback) => callback(dnsError)); + await checkEndpointResolution(req, res, next); + expect(next.calledOnce).to.be.true; + const errorPassedToNext = next.getCall(0).args[0]; + expect(errorPassedToNext).to.be.an.instanceOf(Error); + expect(errorPassedToNext.message).to.include('DNS resolution failed'); + expect(errorPassedToNext.code).to.equal('ENOTFOUND'); + expect(errorPassedToNext.status).to.equal(500); + }); + it('should reject with an error if query validation fails', async () => { + req.query.monitorURL = 'invalid-url'; + await checkEndpointResolution(req, res, next); + expect(next.calledOnce).to.be.true; + const error = next.getCall(0).args[0]; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + expect(error.message).to.equal('"monitorURL" must be a valid uri'); + }); +}); + describe("Monitor Controller - deleteMonitor", () => { let req, res, next; beforeEach(() => { diff --git a/Server/validation/joi.js b/Server/validation/joi.js index ad5c480ae..d1ba107bc 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -236,6 +236,10 @@ const pauseMonitorParamValidation = joi.object({ monitorId: joi.string().required(), }); +const getMonitorURLByQueryValidation = joi.object({ + monitorURL: joi.string().uri().required(), +}); + //**************************************** // Alerts //**************************************** @@ -446,6 +450,7 @@ export { getCertificateParamValidation, editMonitorBodyValidation, pauseMonitorParamValidation, + getMonitorURLByQueryValidation, editUserParamValidation, editUserBodyValidation, createAlertParamValidation,