diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index c884b2e12..3d9b31e2d 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -1,9 +1,14 @@ ## Describe your changes -## Issue Number if applicable +Provide a brief description of the changes you’ve made and their purpose. -## Checklist before requesting a review +## Issue number -- [ ] I have performed a self-review of my code -- [ ] I have inlcuded the issue # in the PR if applicable -- [ ] I have labelled the PR correctly +Mention the issue number(s) this PR addresses (e.g., #123). + +## Please ensure all items are checked off before requesting a review: + +- [ ] I have performed a self-review of my code. +- [ ] I have included the issue # in the PR. +- [ ] I have labelled the PR correctly. +- [ ] My PR is granular and targeted to one specific feature only. 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/Docker/dev/build_images.sh b/Docker/dev/build_images.sh index 2de827dfe..f6171089c 100755 --- a/Docker/dev/build_images.sh +++ b/Docker/dev/build_images.sh @@ -1,24 +1,26 @@ - #!/bin/bash # Change directory to root Server directory for correct Docker Context +cd "$(dirname "$0")" cd ../.. -#Client -client="./Docker/dev/client.Dockerfile" +# Define an array of services and their Dockerfiles +declare -A services=( + ["uptime_client"]="./Docker/dev/client.Dockerfile" + ["uptime_database_mongo"]="./Docker/dev/mongoDB.Dockerfile" + ["uptime_redis"]="./Docker/dev/redis.Dockerfile" + ["uptime_server"]="./Docker/dev/server.Dockerfile" +) -# MongoDB -mongoDB="./Docker/dev/mongoDB.Dockerfile" +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + ## Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done -# Redis -redis="./Docker/dev/redis.Dockerfile" - -# Server -server="./Docker/dev/server.Dockerfile" - -docker build -f $client -t uptime_client . -docker build -f $mongoDB -t uptime_database_mongo . -docker build -f $redis -t uptime_redis . -docker build -f $server -t uptime_server . - -echo "All images built" \ No newline at end of file +echo "All images built successfully" diff --git a/README.md b/README.md index 06c248c4b..aacaf4ea8 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,13 @@ We've just launched our [Discussions](https://github.com/bluewave-labs/bluewave- **Roadmap (short term):** -- [ ] Memory, disk and CPU monitoring -- [ ] 3rd party integrations -- [ ] DNS monitoring -- [ ] SSL monitoring +We are actively developing **infrastructure monitoring** features for Uptime Manager, which will include comprehensive monitoring of memory, disk usage, and CPU performance. Our goal is to build a lightweight agent that runs on Linux servers, continuously collecting and transmitting health metrics to Uptime Manager, where the data will be visualized for real-time insights. **Roadmap (long term):** -- [ ] Status pages +- DNS monitoring +- SSL monitoring +- Status pages ## Tech stack 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/db/mongo/modules/checkModule.js b/Server/db/mongo/modules/checkModule.js index 09868dc43..c01dceb07 100644 --- a/Server/db/mongo/modules/checkModule.js +++ b/Server/db/mongo/modules/checkModule.js @@ -157,7 +157,7 @@ const getTeamChecks = async (req) => { // Default sort order is newest -> oldest sortOrder = sortOrder === "asc" ? 1 : -1; - checksQuery = { monitorId: { $in: userMonitors } }; + const checksQuery = { monitorId: { $in: userMonitors } }; if (filter !== undefined) { checksQuery.status = false; diff --git a/Server/index.js b/Server/index.js index ae9b7241c..71dc42467 100644 --- a/Server/index.js +++ b/Server/index.js @@ -20,7 +20,12 @@ import { fileURLToPath } from "url"; import { connectDbAndRunServer } from "./configs/db.js"; import queueRouter from "./routes/queueRoute.js"; import JobQueue from "./service/jobQueue.js"; + +//Network service and dependencies import NetworkService from "./service/networkService.js"; +import axios from "axios"; +import ping from "ping"; +import http from "http"; // Email service and dependencies import EmailService from "./service/emailService.js"; @@ -149,7 +154,7 @@ const startApp = async () => { nodemailer, logger ); - const networkService = new NetworkService(db, emailService); + const networkService = new NetworkService(db, emailService, axios, ping, logger, http); const jobQueue = await JobQueue.createJobQueue(db, networkService, settingsService); const cleanup = async () => { 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/service/jobQueue.js b/Server/service/jobQueue.js index 1e8c7f8e4..f8493911e 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -98,7 +98,7 @@ class JobQueue { }, false); if (!maintenanceWindowActive) { - const res = await this.networkService.getStatus(job); + await this.networkService.getStatus(job); } else { logger.info(`Monitor ${monitorId} is in maintenance window`, { service: SERVICE_NAME, diff --git a/Server/service/networkService.js b/Server/service/networkService.js index d2964bd25..ad372dbce 100644 --- a/Server/service/networkService.js +++ b/Server/service/networkService.js @@ -1,7 +1,3 @@ -import axios from "axios"; -import ping from "ping"; -import logger from "../utils/logger.js"; -import http from "http"; import { errorMessages, successMessages } from "../utils/messages.js"; /** @@ -12,7 +8,17 @@ import { errorMessages, successMessages } from "../utils/messages.js"; */ class NetworkService { - constructor(db, emailService) { + /** + * Creates an instance of NetworkService. + * + * @param {Object} db - The database service. + * @param {Object} emailService - The email service. + * @param {Object} axios - The axios HTTP client. + * @param {Object} ping - The ping service. + * @param {Object} logger - The logging service. + * @param {Object} http - The HTTP service. + */ + constructor(db, emailService, axios, ping, logger, http) { this.db = db; this.emailService = emailService; this.TYPE_PING = "ping"; @@ -20,9 +26,19 @@ class NetworkService { this.TYPE_PAGESPEED = "pagespeed"; this.SERVICE_NAME = "NetworkService"; this.NETWORK_ERROR = 5000; + this.axios = axios; + this.ping = ping; + this.logger = logger; + this.http = http; } - async handleNotification(monitor, isAlive) { + /** + * Handles the notification process for a monitor. + * + * @param {Object} monitor - The monitor object containing monitor details. + * @param {boolean} isAlive - The status of the monitor (true if up, false if down). + * @returns {Promise} + */ async handleNotification(monitor, isAlive) { try { let template = isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate"; let status = isAlive === true ? "up" : "down"; @@ -39,7 +55,7 @@ class NetworkService { } } } catch (error) { - logger.error(error.message, { + this.logger.error(error.message, { method: "handleNotification", service: this.SERVICE_NAME, monitorId: monitor._id, @@ -47,11 +63,19 @@ class NetworkService { } } + /** + * Handles the status update for a monitor job. + * + * @param {Object} job - The job object containing job details. + * @param {boolean} isAlive - The status of the monitor (true if up, false if down). + * @returns {Promise} + */ async handleStatusUpdate(job, isAlive) { let monitor; + const { _id } = job.data; + // Look up the monitor, if it doesn't exist, it's probably been removed, return try { - const { _id } = job.data; monitor = await this.db.getMonitorById(_id); } catch (error) { return; @@ -60,7 +84,7 @@ class NetworkService { // Otherwise, try to update monitor status try { if (monitor === null || monitor === undefined) { - logger.error(`Null Monitor: ${_id}`, { + this.logger.error(`Null Monitor: ${_id}`, { method: "handleStatusUpdate", service: this.SERVICE_NAME, jobId: job.id, @@ -77,7 +101,7 @@ class NetworkService { } } } catch (error) { - logger.error(error.message, { + this.logger.error(error.message, { method: "handleStatusUpdate", service: this.SERVICE_NAME, jobId: job.id, @@ -100,7 +124,7 @@ class NetworkService { } catch (error) { const endTime = Date.now(); error.responseTime = endTime - startTime; - error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.service === undefined ? (error.service = this.SERVICE_NAME) : null; error.method === undefined ? (error.method = "measureResponseTime") : null; throw error; } @@ -112,13 +136,13 @@ class NetworkService { * @returns {Promise<{boolean}} The result of logging and storing the check */ async handlePing(job) { + let isAlive; + const operation = async () => { - const response = await ping.promise.probe(job.data.url); + const response = await this.ping.promise.probe(job.data.url); return response; }; - let isAlive; - try { const { responseTime, response } = await this.measureResponseTime(operation); isAlive = response.alive; @@ -130,7 +154,7 @@ class NetworkService { ? successMessages.PING_SUCCESS : errorMessages.PING_CANNOT_RESOLVE, }; - return await this.logAndStoreCheck(checkData, this.db.createCheck); + await this.logAndStoreCheck(checkData, this.db.createCheck); } catch (error) { isAlive = false; const checkData = { @@ -139,7 +163,7 @@ class NetworkService { message: errorMessages.PING_CANNOT_RESOLVE, responseTime: error.responseTime, }; - return await this.logAndStoreCheck(checkData, this.db.createCheck); + await this.logAndStoreCheck(checkData, this.db.createCheck); } finally { this.handleStatusUpdate(job, isAlive); } @@ -153,7 +177,7 @@ class NetworkService { async handleHttp(job) { // Define operation for timing const operation = async () => { - const response = await axios.get(job.data.url); + const response = await this.axios.get(job.data.url); return response; }; @@ -171,12 +195,12 @@ class NetworkService { status: isAlive, responseTime, statusCode: response.status, - message: http.STATUS_CODES[response.status], + message: this.http.STATUS_CODES[response.status], }; - return await this.logAndStoreCheck(checkData, this.db.createCheck); + await this.logAndStoreCheck(checkData, this.db.createCheck); } catch (error) { const statusCode = error.response?.status || this.NETWORK_ERROR; - let message = http.STATUS_CODES[statusCode] || "Network Error"; + let message = this.http.STATUS_CODES[statusCode] || "Network Error"; isAlive = false; const checkData = { monitorId: job.data._id, @@ -185,8 +209,7 @@ class NetworkService { responseTime: error.responseTime, message, }; - - return await this.logAndStoreCheck(checkData, this.db.createCheck); + await this.logAndStoreCheck(checkData, this.db.createCheck); } finally { this.handleStatusUpdate(job, isAlive); } @@ -210,7 +233,8 @@ class NetworkService { let isAlive; try { const url = job.data.url; - const response = await axios.get( + + const response = await this.axios.get( `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance` ); const pageSpeedResults = response.data; @@ -223,7 +247,6 @@ class NetworkService { "largest-contentful-paint": lcp, "total-blocking-time": tbt, } = audits; - // Weights // First Contentful Paint 10% // Speed Index 10% @@ -236,7 +259,7 @@ class NetworkService { monitorId: job.data._id, status: isAlive, statusCode: response.status, - message: http.STATUS_CODES[response.status], + message: this.http.STATUS_CODES[response.status], accessibility: (categories.accessibility?.score || 0) * 100, bestPractices: (categories["best-practices"]?.score || 0) * 100, seo: (categories.seo?.score || 0) * 100, @@ -249,12 +272,11 @@ class NetworkService { tbt, }, }; - this.logAndStoreCheck(checkData, this.db.createPageSpeedCheck); } catch (error) { isAlive = false; const statusCode = error.response?.status || this.NETWORK_ERROR; - const message = http.STATUS_CODES[statusCode] || "Network Error"; + const message = this.http.STATUS_CODES[statusCode] || "Network Error"; const checkData = { monitorId: job.data._id, status: isAlive, @@ -287,7 +309,7 @@ class NetworkService { case this.TYPE_PAGESPEED: return await this.handlePagespeed(job); default: - logger.error(`Unsupported type: ${job.data.type}`, { + this.logger.error(`Unsupported type: ${job.data.type}`, { service: this.SERVICE_NAME, method: "getStatus", jobId: job.id, @@ -311,8 +333,9 @@ class NetworkService { if (insertedCheck !== null && insertedCheck !== undefined) { return insertedCheck.status; } + throw new Error(); } catch (error) { - logger.error(`Error wrtiting check for ${data.monitorId}`, { + this.logger.error(`Error writing check for ${data.monitorId}`, { service: this.SERVICE_NAME, method: "logAndStoreCheck", monitorId: data.monitorId, 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/tests/services/networkService.test.js b/Server/tests/services/networkService.test.js new file mode 100644 index 000000000..619fa0fc9 --- /dev/null +++ b/Server/tests/services/networkService.test.js @@ -0,0 +1,750 @@ +import sinon from "sinon"; +import NetworkService from "../../service/networkService.js"; +import { errorMessages, successMessages } from "../../utils/messages.js"; +import exp from "constants"; +describe("NetworkService - Constructor", function () { + let dbMock, emailServiceMock, axiosMock, pingMock, loggerMock, httpMock; + + beforeEach(function () { + dbMock = sinon.stub(); + emailServiceMock = sinon.stub(); + axiosMock = sinon.stub(); + pingMock = sinon.stub(); + loggerMock = sinon.stub(); + httpMock = sinon.stub(); + }); + + it("should correctly initialize properties", function () { + const networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + + expect(networkService.db).to.equal(dbMock); + expect(networkService.emailService).to.equal(emailServiceMock); + expect(networkService.TYPE_PING).to.equal("ping"); + expect(networkService.TYPE_HTTP).to.equal("http"); + expect(networkService.TYPE_PAGESPEED).to.equal("pagespeed"); + expect(networkService.SERVICE_NAME).to.equal("NetworkService"); + expect(networkService.NETWORK_ERROR).to.equal(5000); + expect(networkService.axios).to.equal(axiosMock); + expect(networkService.ping).to.equal(pingMock); + expect(networkService.logger).to.equal(loggerMock); + expect(networkService.http).to.equal(httpMock); + }); +}); + +describe("NetworkService - handleNotification", () => { + let dbMock, emailServiceMock, loggerMock, networkService; + + beforeEach(function () { + dbMock = { + getNotificationsByMonitorId: sinon.stub(), + }; + emailServiceMock = { + buildAndSendEmail: sinon.stub().resolves(), + }; + loggerMock = { + error: sinon.stub(), + }; + + networkService = new NetworkService( + dbMock, + emailServiceMock, + null, + null, + loggerMock, + null + ); + }); + + it("should send email notifications when monitor is down", async function () { + const monitor = { _id: "monitor1", name: "Test Monitor", url: "http://test.com" }; + const notifications = [{ type: "email", address: "test@example.com" }]; + dbMock.getNotificationsByMonitorId.resolves(notifications); + + await networkService.handleNotification(monitor, false); + + expect(emailServiceMock.buildAndSendEmail.calledOnce).to.be.true; + expect( + emailServiceMock.buildAndSendEmail.calledWith( + "serverIsDownTemplate", + { monitorName: monitor.name, monitorUrl: monitor.url }, + "test@example.com", + "Monitor Test Monitor is down" + ) + ).to.be.true; + }); + + it("should send email notifications when monitor is up", async function () { + const monitor = { _id: "monitor1", name: "Test Monitor", url: "http://test.com" }; + const notifications = [{ type: "email", address: "test@example.com" }]; + dbMock.getNotificationsByMonitorId.resolves(notifications); + + await networkService.handleNotification(monitor, true); + + expect(emailServiceMock.buildAndSendEmail.calledOnce).to.be.true; + expect( + emailServiceMock.buildAndSendEmail.calledWith( + "serverIsUpTemplate", + { monitorName: monitor.name, monitorUrl: monitor.url }, + "test@example.com", + "Monitor Test Monitor is up" + ) + ).to.be.true; + }); + + it("should log an error if an exception is thrown", async function () { + const monitor = { _id: "monitor1", name: "Test Monitor", url: "http://test.com" }; + dbMock.getNotificationsByMonitorId.rejects(new Error("Database error")); + + await networkService.handleNotification(monitor, true); + + expect(loggerMock.error.calledOnce).to.be.true; + expect( + loggerMock.error.calledWith("Database error", { + method: "handleNotification", + service: networkService.SERVICE_NAME, + monitorId: monitor._id, + }) + ).to.be.true; + }); +}); + +describe("NetworkService - handleStatusUpdate", function () { + let dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock, + networkService, + monitorMock; + + beforeEach(function () { + dbMock = { getMonitorById: sinon.stub() }; + emailServiceMock = sinon.stub(); + axiosMock = sinon.stub(); + pingMock = sinon.stub(); + loggerMock = { error: sinon.stub() }; + httpMock = sinon.stub(); + networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + monitorMock = { + status: sinon.stub(), + save: sinon.stub(), + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return if getMonitorById throws an error", async function () { + dbMock.getMonitorById.throws(new Error("Database error")); + + const res = await networkService.handleStatusUpdate( + { data: { _id: "monitor1" } }, + true + ); + expect(res).to.be.undefined; + }); + + it("should log an error if monitor is null", async function () { + dbMock.getMonitorById.resolves(null); + + await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); + + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith("Null Monitor: monitor1")).to.be.true; + }); + + it("should update monitor status if different", async function () { + monitorMock.status = false; + dbMock.getMonitorById.resolves(monitorMock); + + await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); + + expect(monitorMock.save.calledOnce).to.be.true; + expect(monitorMock.status).to.be.true; + }); + + it("should not update monitor status if same", async function () { + monitorMock.status = true; + dbMock.getMonitorById.resolves(monitorMock); + + await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); + + expect(monitorMock.save.called).to.be.false; + }); + + it("should log an error if exception is thrown during status update", async function () { + monitorMock.status = false; + dbMock.getMonitorById.resolves(monitorMock); + monitorMock.save.rejects(new Error("Save error")); + + await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); + + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith("Save error")).to.be.true; + }); +}); + +describe("NetworkService - measureResponseTime", () => { + let mockOperation, + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock, + networkService; + + beforeEach(() => { + mockOperation = sinon.stub(); + dbMock = { getMonitorById: sinon.stub() }; + emailServiceMock = sinon.stub(); + axiosMock = sinon.stub(); + pingMock = sinon.stub(); + loggerMock = { error: sinon.stub() }; + httpMock = sinon.stub(); + networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return response time and response on successful operation", async () => { + const fakeResponse = { data: "test" }; + mockOperation.resolves(fakeResponse); + + const result = await networkService.measureResponseTime(mockOperation); + expect(result).to.have.property("responseTime").that.is.a("number"); + expect(result).to.have.property("response").that.deep.equals(fakeResponse); + }); + + it("should throw an error with response time and additional properties on operation failure", async () => { + const fakeError = new Error("Operation failed"); + mockOperation.rejects(fakeError); + + try { + await networkService.measureResponseTime(mockOperation); + } catch (error) { + expect(error).to.have.property("responseTime").that.is.a("number"); + expect(error).to.have.property("service", networkService.SERVICE_NAME); + expect(error).to.have.property("method", "measureResponseTime"); + expect(error.message).to.equal("Operation failed"); + } + }); + it("should throw an error with response time set properties on operation failure", async () => { + const fakeError = new Error("Operation failed"); + fakeError.service = "test"; + fakeError.method = "testMethod"; + mockOperation.rejects(fakeError); + + try { + await networkService.measureResponseTime(mockOperation); + } catch (error) { + expect(error).to.have.property("responseTime").that.is.a("number"); + expect(error).to.have.property("service", "test"); + expect(error).to.have.property("method", "testMethod"); + expect(error.message).to.equal("Operation failed"); + } + }); +}); + +describe("networkService - handlePing", () => { + let job, + dbMock, + pingMock, + loggerMock, + httpMock, + networkService, + logAndStoreCheckStub, + handleStatusUpdateStub; + beforeEach(function () { + job = { + data: { + url: "http://example.com", + _id: "12345", + }, + }; + + dbMock = { getMonitorById: sinon.stub() }; + pingMock = { promise: { probe: sinon.stub() } }; + loggerMock = { error: sinon.stub() }; + httpMock = sinon.stub(); + networkService = new NetworkService( + dbMock, + null, + null, + pingMock, + loggerMock, + httpMock + ); + logAndStoreCheckStub = sinon.stub(networkService, "logAndStoreCheck"); + handleStatusUpdateStub = sinon.stub(networkService, "handleStatusUpdate"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should handle a successful ping response", async function () { + const response = { alive: true }; + const responseTime = 0; + pingMock.promise.probe.resolves(response); + logAndStoreCheckStub.resolves(); + await networkService.handlePing(job); + expect( + logAndStoreCheckStub.calledOnceWith( + { + monitorId: job.data._id, + status: true, + responseTime, + message: successMessages.PING_SUCCESS, + }, + networkService.db.createCheck + ) + ).to.be.true; + expect(handleStatusUpdateStub.calledOnceWith(job, true)).to.be.true; + }); + it("should handle a successful ping response and isAlive === false", async function () { + const response = { alive: false }; + const responseTime = 0; + pingMock.promise.probe.resolves(response); + logAndStoreCheckStub.resolves(); + + await networkService.handlePing(job); + console.log(logAndStoreCheckStub.getCall(0).args[0]); + expect( + logAndStoreCheckStub.calledOnceWith( + { + monitorId: job.data._id, + status: false, + responseTime, + message: errorMessages.PING_CANNOT_RESOLVE, + }, + networkService.db.createCheck + ) + ).to.be.true; + expect(handleStatusUpdateStub.calledOnceWith(job, false)).to.be.true; + }); + + it("should handle a failed ping response", async function () { + const error = new Error("Ping failed"); + error.responseTime = 0; + + pingMock.promise.probe.rejects(error); + + await networkService.handlePing(job); + + expect(handleStatusUpdateStub.calledOnceWith(job, false)).to.be.true; + }); +}); + +describe("NetworkService - handleHttp", () => { + let networkService, + axiosMock, + dbMock, + httpMock, + jobMock, + loggerMock, + logAndStoreCheckStub, + handleStatusUpdateStub; + + beforeEach(() => { + axiosMock = { + get: sinon.stub(), + }; + dbMock = { + createCheck: sinon.stub().resolves(), + }; + httpMock = { + STATUS_CODES: { + 200: "OK", + 500: "Internal Server Error", + }, + }; + loggerMock = { + error: sinon.stub(), + }; + networkService = new NetworkService( + dbMock, + null, + axiosMock, + null, + loggerMock, + httpMock + ); + jobMock = { + data: { + url: "http://example.com", + _id: "monitorId123", + }, + }; + sinon.stub(networkService, "measureResponseTime").callsFake(async (operation) => { + const response = await operation(); + return { responseTime: 100, response }; + }); + logAndStoreCheckStub = sinon.stub(networkService, "logAndStoreCheck").resolves(); + handleStatusUpdateStub = sinon.stub(networkService, "handleStatusUpdate").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should handle a successful HTTP response", async () => { + const responseMock = { status: 200 }; + axiosMock.get.resolves(responseMock); + + await networkService.handleHttp(jobMock); + + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: true, + statusCode: 200, + message: "OK", + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, true)).to.be.true; + }); + + it("should handle an HTTP error response", async () => { + const errorMock = { response: { status: 500 }, responseTime: 200 }; + axiosMock.get.rejects(errorMock); + + await networkService.handleHttp(jobMock); + + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: false, + statusCode: 500, + message: "Internal Server Error", + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, false)).to.be.true; + }); + it("should handle an HTTP error response with undefined status", async () => { + const errorMock = { response: { status: undefined }, responseTime: 200 }; + axiosMock.get.rejects(errorMock); + + await networkService.handleHttp(jobMock); + + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: false, + statusCode: 5000, + message: "Network Error", + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, false)).to.be.true; + }); +}); + +describe("networkService - handlePagespeed", () => { + let dbMock, + axiosMock, + jobMock, + emailServiceMock, + pingMock, + loggerMock, + httpMock, + networkService, + logAndStoreCheckStub, + handleStatusUpdateStub; + beforeEach(() => { + jobMock = { + data: { + _id: "12345", + url: "http://example.com", + }, + }; + dbMock = { getMonitorById: sinon.stub() }; + axiosMock = { get: sinon.stub() }; + + emailServiceMock = sinon.stub(); + pingMock = { promise: { probe: sinon.stub() } }; + loggerMock = { error: sinon.stub() }; + httpMock = { + STATUS_CODES: { + 200: "OK", + 500: "Internal Server Error", + }, + }; + networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + logAndStoreCheckStub = sinon.stub(networkService, "logAndStoreCheck").resolves(); + handleStatusUpdateStub = sinon.stub(networkService, "handleStatusUpdate").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should handle a successful PageSpeed response", async () => { + const responseMock = { + status: 200, + data: { + lighthouseResult: { + categories: { + accessibility: { score: 0.9 }, + "best-practices": { score: 0.8 }, + seo: { score: 0.7 }, + performance: { score: 0.6 }, + }, + audits: { + "cumulative-layout-shift": { score: 0.1 }, + "speed-index": { score: 0.2 }, + "first-contentful-paint": { score: 0.3 }, + "largest-contentful-paint": { score: 0.4 }, + "total-blocking-time": { score: 0.5 }, + }, + }, + }, + }; + axiosMock.get.resolves(responseMock); + + await networkService.handlePagespeed(jobMock); + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: true, + statusCode: 200, + message: "OK", + accessibility: 90, + bestPractices: 80, + seo: 70, + performance: 60, + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, true)).to.be.true; + }); + + it("should handle a successful PageSpeed response with missing data", async () => { + const responseMock = { + status: 200, + data: { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": { score: 0.1 }, + "speed-index": { score: 0.2 }, + "first-contentful-paint": { score: 0.3 }, + "largest-contentful-paint": { score: 0.4 }, + "total-blocking-time": { score: 0.5 }, + }, + }, + }, + }; + axiosMock.get.resolves(responseMock); + + await networkService.handlePagespeed(jobMock); + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: true, + statusCode: 200, + message: "OK", + accessibility: 0, + bestPractices: 0, + seo: 0, + performance: 0, + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, true)).to.be.true; + }); + + it("should handle an error PageSpeed response", async () => { + const errorMock = { response: { status: 500 } }; + axiosMock.get.rejects(errorMock); + + await networkService.handlePagespeed(jobMock); + + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: false, + statusCode: 500, + message: "Internal Server Error", + accessibility: 0, + bestPractices: 0, + seo: 0, + performance: 0, + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, false)).to.be.true; + }); + it("should handle an error PageSpeed response with unknown status", async () => { + const errorMock = { response: { status: undefined } }; + axiosMock.get.rejects(errorMock); + + await networkService.handlePagespeed(jobMock); + + expect(networkService.logAndStoreCheck.calledOnce).to.be.true; + const checkData = networkService.logAndStoreCheck.getCall(0).args[0]; + expect(checkData).to.include({ + monitorId: jobMock.data._id, + status: false, + statusCode: 5000, + message: "Network Error", + accessibility: 0, + bestPractices: 0, + seo: 0, + performance: 0, + }); + expect(networkService.handleStatusUpdate.calledOnceWith(jobMock, false)).to.be.true; + }); +}); + +describe("NetworkService - getStatus", () => { + let dbMock, emailServiceMock, axiosMock, pingMock, loggerMock, httpMock, networkService; + + beforeEach(() => { + dbMock = { getMonitorById: sinon.stub() }; + emailServiceMock = sinon.stub(); + axiosMock = sinon.stub(); + pingMock = sinon.stub(); + loggerMock = { error: sinon.stub() }; + httpMock = sinon.stub(); + networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return true if the job type is ping and handlePing is successful", async () => { + const job = { data: { type: networkService.TYPE_PING } }; + sinon.stub(networkService, "handlePing").resolves(true); + const result = await networkService.getStatus(job); + expect(result).to.be.true; + }); + it("should return false if the job type is ping and handlePing is not successful", async () => { + const job = { data: { type: networkService.TYPE_PING } }; + sinon.stub(networkService, "handlePing").resolves(false); + const result = await networkService.getStatus(job); + expect(result).to.be.false; + }); + it("should return true if the job type is http and handleHttp is successful", async () => { + const job = { data: { type: networkService.TYPE_HTTP } }; + sinon.stub(networkService, "handleHttp").resolves(true); + const result = await networkService.getStatus(job); + expect(result).to.be.true; + }); + it("should return false if the job type is http and handleHttp is not successful", async () => { + const job = { data: { type: networkService.TYPE_HTTP } }; + sinon.stub(networkService, "handleHttp").resolves(false); + const result = await networkService.getStatus(job); + expect(result).to.be.false; + }); + it("should return true if the job type is pagespeed and handlePagespeed is successful", async () => { + const job = { data: { type: networkService.TYPE_PAGESPEED } }; + sinon.stub(networkService, "handlePagespeed").resolves(true); + const result = await networkService.getStatus(job); + expect(result).to.be.true; + }); + it("should return false if the job type is pagespeed and handlePagespeed is not successful", async () => { + const job = { data: { type: networkService.TYPE_PAGESPEED } }; + sinon.stub(networkService, "handlePagespeed").resolves(false); + const result = await networkService.getStatus(job); + expect(result).to.be.false; + }); + it("should log an error and return false if the job type is unknown", async () => { + const job = { data: { type: "unknown" } }; + const result = await networkService.getStatus(job); + expect(result).to.be.false; + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith(`Unsupported type: unknown`)).to.be.true; + }); +}); + +describe("NetworkService - logAndStoreCheck", async () => { + let dbMock, emailServiceMock, axiosMock, pingMock, loggerMock, httpMock, networkService; + + beforeEach(() => { + dbMock = { getMonitorById: sinon.stub() }; + emailServiceMock = sinon.stub(); + axiosMock = sinon.stub(); + pingMock = sinon.stub(); + loggerMock = { error: sinon.stub() }; + httpMock = sinon.stub(); + networkService = new NetworkService( + dbMock, + emailServiceMock, + axiosMock, + pingMock, + loggerMock, + httpMock + ); + }); + const data = { monitorId: "12345" }; + const writeToDB = sinon.stub(); + afterEach(() => { + sinon.restore(); + }); + + it("should log an error if `writeToDb` throws an error", async () => { + writeToDB.rejects(new Error("Database error")); + await networkService.logAndStoreCheck(data, writeToDB); + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be + .true; + }); + + it("should return the status of the inserted check if successful", async () => { + writeToDB.resolves({ status: true }); + const result = await networkService.logAndStoreCheck(data, writeToDB); + expect(result).to.be.true; + }); + + it("should thrown an error if the check is not inserted (null)", async () => { + writeToDB.resolves(null); + await networkService.logAndStoreCheck(data, writeToDB); + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be + .true; + }); + it("should thrown an error if the check is not inserted (undefined)", async () => { + writeToDB.resolves(undefined); + await networkService.logAndStoreCheck(data, writeToDB); + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be + .true; + }); +}); diff --git a/Server/tests/utils/dataUtils.test.js b/Server/tests/utils/dataUtils.test.js index 4af193d74..293291b5e 100644 --- a/Server/tests/utils/dataUtils.test.js +++ b/Server/tests/utils/dataUtils.test.js @@ -62,7 +62,6 @@ describe("calculatePercentile", () => { const percentile = 100; const result = calculatePercentile(checks, percentile); const expected = 50; - console.log(result); expect(result).to.equal(expected); }); }); 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,