diff --git a/Client/src/Components/Inputs/Field/index.jsx b/Client/src/Components/Inputs/Field/index.jsx index 1843a411b..9dbf616a9 100644 --- a/Client/src/Components/Inputs/Field/index.jsx +++ b/Client/src/Components/Inputs/Field/index.jsx @@ -74,6 +74,7 @@ const Field = forwardRef( "&:has(.input-error) .MuiOutlinedInput-root fieldset": { borderColor: theme.palette.error.text, }, + display: hidden ? "none" : "", }} > {label && ( @@ -142,7 +143,7 @@ const Field = forwardRef( }, } : { - display: hidden ? "none" : "", + } } InputProps={{ diff --git a/Client/src/Components/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx index 200c5dd01..0019a282a 100644 --- a/Client/src/Components/Sidebar/index.jsx +++ b/Client/src/Components/Sidebar/index.jsx @@ -41,6 +41,9 @@ import ArrowUp from "../../assets/icons/up-arrow.svg?react"; import ArrowRight from "../../assets/icons/right-arrow.svg?react"; import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; import DotsVertical from "../../assets/icons/dots-vertical.svg?react"; +import ChangeLog from "../../assets/icons/changeLog.svg?react"; +import Docs from "../../assets/icons/docs.svg?react"; +import Folder from "../../assets/icons/folder.svg?react"; import "./index.css"; @@ -66,12 +69,30 @@ const menu = [ { name: "Team", path: "account/team", icon: }, ], }, + { + name: "Other", + icon: , + nested: [ + { name: "Settings", path: "settings", icon: }, + { name: "Support", path: "support", icon: }, + { name: "Docs", path: "docs", icon: }, + { name: "Changelog", path: "changelog", icon: }, + ], + }, ]; -const other = [ - { name: "Support", path: "support", icon: }, - { name: "Settings", path: "settings", icon: }, -]; +const URL_MAP = { + support: "https://github.com/bluewave-labs/bluewave-uptime/issues", + docs: "https://bluewavelabs.gitbook.io/uptime-manager", + changelog: "https://github.com/bluewave-labs/bluewave-uptime/releases", +}; + +const PATH_MAP = { + monitors: "Dashboard", + pagespeed: "Dashboard", + account: "Account", + settings: "Other", +}; /** * @component @@ -87,7 +108,7 @@ function Sidebar() { const dispatch = useDispatch(); const authState = useSelector((state) => state.auth); const collapsed = useSelector((state) => state.ui.sidebar.collapsed); - const [open, setOpen] = useState({ Dashboard: false, Account: false }); + const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); const [anchorEl, setAnchorEl] = useState(null); const [popup, setPopup] = useState(); const { user } = useSelector((state) => state.auth); @@ -120,13 +141,13 @@ function Sidebar() { }; useEffect(() => { - if ( - location.pathname.includes("monitors") || - location.pathname.includes("pagespeed") - ) - setOpen((prev) => ({ ...prev, Dashboard: true })); - else if (location.pathname.includes("/account")) - setOpen((prev) => ({ ...prev, Account: true })); + const matchedKey = Object.keys(PATH_MAP).find((key) => + location.pathname.includes(key) + ); + + if (matchedKey) { + setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true })); + } }, []); return ( @@ -202,7 +223,9 @@ function Sidebar() { }, }} onClick={() => { - setOpen({ Dashboard: false, Account: false }); + setOpen(prev => + Object.fromEntries(Object.keys(prev).map(key => [key, false])) + ) dispatch(toggleSidebar()); }} > @@ -346,7 +369,12 @@ function Sidebar() { } key={child.path} onClick={() => { - navigate(`/${child.path}`); + const url = URL_MAP[child.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${child.path}`); + } closePopup(); }} sx={{ @@ -372,8 +400,8 @@ function Sidebar() { setOpen((prev) => ({ - ...prev, - [`${item.name}`]: !prev[`${item.name}`], + ...Object.fromEntries(Object.keys(prev).map(key => [key, false])), + [item.name]: !prev[item.name] })) } sx={{ @@ -409,7 +437,14 @@ function Sidebar() { : "" } key={child.path} - onClick={() => navigate(`/${child.path}`)} + onClick={() => { + const url = URL_MAP[child.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${child.path}`); + } + }} sx={{ gap: theme.spacing(4), borderRadius: theme.shape.borderRadius, @@ -455,70 +490,6 @@ function Sidebar() { ) )} - - {/* other */} - - Other - - } - sx={{ px: theme.spacing(6) }} - > - {other.map((item) => ( - - - item.path === "support" - ? window.open( - "https://github.com/bluewave-labs/bluewave-uptime/issues", - "_blank", - "noreferrer" - ) - : navigate(`/${item.path}`) - } - sx={{ - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - }} - > - {item.icon} - {item.name} - - - ))} - )} {collapsed && } - { dispatch(setMode("light")); closePopup(); @@ -647,7 +618,7 @@ function Sidebar() { }} > Dark - + */} { gap={theme.spacing(5)} justifyContent="flex-end" > - diff --git a/Client/src/Components/TabPanels/Account/TeamPanel.jsx b/Client/src/Components/TabPanels/Account/TeamPanel.jsx index ac55cd5b1..e5fd5d02b 100644 --- a/Client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/Client/src/Components/TabPanels/Account/TeamPanel.jsx @@ -49,6 +49,7 @@ const TeamPanel = () => { const [tableData, setTableData] = useState({}); const [members, setMembers] = useState([]); const [filter, setFilter] = useState("all"); + const [isDisabled, setIsDisabled] = useState(true); const [errors, setErrors] = useState({}); const [isSendingInvite, setIsSendingInvite] = useState(false); @@ -137,6 +138,9 @@ const TeamPanel = () => { setTableData(data); }, [members, filter]); + useEffect(() => { + setIsDisabled(Object.keys(errors).length !== 0 || toInvite.email === ""); + }, [errors, toInvite.email]); // RENAME ORGANIZATION const toggleEdit = () => { @@ -172,6 +176,10 @@ const TeamPanel = () => { }; const handleInviteMember = async () => { + if (!toInvite.email) { + setErrors((prev) => ({ ...prev, email: "Email is required." })); + return; + } setIsSendingInvite(true); if (!toInvite.role.includes("user") || !toInvite.role.includes("admin")) setToInvite((prev) => ({ ...prev, role: ["user"] })); @@ -415,7 +423,7 @@ const TeamPanel = () => { color="primary" onClick={handleInviteMember} loading={isSendingInvite} - disabled={Object.keys(errors).length !== 0} + disabled={isDisabled} > Send invite diff --git a/Client/src/Pages/Monitors/Home/actionsMenu.jsx b/Client/src/Pages/Monitors/Home/actionsMenu.jsx index abb1af243..d2ccc0b88 100644 --- a/Client/src/Pages/Monitors/Home/actionsMenu.jsx +++ b/Client/src/Pages/Monitors/Home/actionsMenu.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { useTheme } from "@emotion/react"; import { useNavigate } from "react-router-dom"; import { createToast } from "../../../Utils/toastUtils"; +import { logger } from "../../../Utils/Logger"; import { Button, IconButton, @@ -14,6 +15,7 @@ import { } from "@mui/material"; import { deleteUptimeMonitor, + pauseUptimeMonitor, getUptimeMonitorsByTeamId, } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; import Settings from "../../../assets/icons/settings-bold.svg?react"; @@ -26,6 +28,8 @@ const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => { const dispatch = useDispatch(); const theme = useTheme(); const authState = useSelector((state) => state.auth); + const authToken = authState.authToken; + const handleRemove = async (event) => { event.preventDefault(); @@ -44,6 +48,24 @@ const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => { } }; + const handlePause = async () => { + try { + const action = await dispatch( + pauseUptimeMonitor({ authToken, monitorId: monitor._id }) + ); + if (pauseUptimeMonitor.fulfilled.match(action)) { + updateCallback(); + const state = action?.payload?.data.isActive === false ? "paused" : "resumed"; + createToast({ body: `Monitor ${state} successfully.` }); + } else { + throw new Error(action?.error?.message ?? "Failed to pause monitor."); + } + } catch (error) { + logger.error("Error pausing monitor:", monitor._id, error); + createToast({ body: "Failed to pause monitor." }); + } + }; + const openMenu = (event, id, url) => { event.preventDefault(); event.stopPropagation(); @@ -155,6 +177,17 @@ const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => { Clone )} + {isAdmin && ( + { + e.stopPropagation(); + handlePause(e); + }} + > + {monitor?.isActive === true ? "Pause" : "Resume"} + + + )} {isAdmin && ( { @@ -164,7 +197,7 @@ const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => { > Remove - )} + )} { const { isLoading } = useSelector((state) => state.uptimeMonitors); const { isLoading: authIsLoading } = useSelector((state) => state.auth); const { timezone } = useSelector((state) => state.ui); + const {mode} = useSelector((state) => state.ui); const [checksIsLoading, setChecksIsLoading] = useState(false); const [form, setForm] = useState({ ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0, @@ -188,6 +189,26 @@ const Settings = ({ isAdmin }) => { /> + + + Appearance + + Switch between light and dark mode. + + + + + + {isAdmin && ( diff --git a/Client/src/assets/icons/changeLog.svg b/Client/src/assets/icons/changeLog.svg new file mode 100644 index 000000000..d12f6c10e --- /dev/null +++ b/Client/src/assets/icons/changeLog.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/docs.svg b/Client/src/assets/icons/docs.svg new file mode 100644 index 000000000..d36458de2 --- /dev/null +++ b/Client/src/assets/icons/docs.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/folder.svg b/Client/src/assets/icons/folder.svg new file mode 100644 index 000000000..15941e5b5 --- /dev/null +++ b/Client/src/assets/icons/folder.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Server/.nycrc b/Server/.nycrc index da9bccbfc..d6406bf7e 100644 --- a/Server/.nycrc +++ b/Server/.nycrc @@ -1,6 +1,6 @@ { "all": true, - "include": ["**/*.js"], + "include": ["controllers/*.js"], "exclude": ["**/*.test.js"], "reporter": ["html", "text", "lcov"], "sourceMap": false, diff --git a/Server/controllers/authController.js b/Server/controllers/authController.js index 36fdc73cb..136a05a7a 100644 --- a/Server/controllers/authController.js +++ b/Server/controllers/authController.js @@ -68,6 +68,7 @@ const registerUser = async (req, res, next) => { } const newUser = await req.db.insertUser({ ...req.body }, req.file); + logger.info(successMessages.AUTH_CREATE_USER, { service: SERVICE_NAME, userId: newUser._id, @@ -78,7 +79,9 @@ const registerUser = async (req, res, next) => { delete userForToken.avatarImage; const appSettings = await req.settingsService.getSettings(); + const token = issueToken(userForToken, appSettings); + req.emailService .buildAndSendEmail( "welcomeEmailTemplate", @@ -99,6 +102,7 @@ const registerUser = async (req, res, next) => { data: { user: newUser, token: token }, }); } catch (error) { + console.log("ERROR", error); next(handleError(error, SERVICE_NAME, "registerController")); } }; @@ -293,6 +297,8 @@ const requestRecovery = async (req, res, next) => { msg: successMessages.AUTH_CREATE_RECOVERY_TOKEN, data: msgId, }); + } else { + throw new Error(errorMessages.FRIENDLY_ERROR); } } catch (error) { next(handleError(error, SERVICE_NAME, "recoveryRequestController")); @@ -435,6 +441,7 @@ const getAllUsers = async (req, res) => { }; module.exports = { + issueToken, registerUser, loginUser, editUser, diff --git a/Server/controllers/controllerUtils.js b/Server/controllers/controllerUtils.js index 1b3994f2f..3238cd2a1 100644 --- a/Server/controllers/controllerUtils.js +++ b/Server/controllers/controllerUtils.js @@ -6,8 +6,8 @@ const handleValidationError = (error, serviceName) => { return error; }; -const handleError = (error, serviceName, method, code = 500) => { - error.code === undefined ? (error.code = code) : null; +const handleError = (error, serviceName, method, status = 500) => { + error.status === undefined ? (error.status = status) : null; error.service === undefined ? (error.service = serviceName) : null; error.method === undefined ? (error.method = method) : null; return error; diff --git a/Server/controllers/inviteController.js b/Server/controllers/inviteController.js index 56d6b08f6..b22f9b2cc 100644 --- a/Server/controllers/inviteController.js +++ b/Server/controllers/inviteController.js @@ -8,17 +8,7 @@ require("dotenv").config(); const jwt = require("jsonwebtoken"); const { handleError, handleValidationError } = require("./controllerUtils"); const SERVICE_NAME = "inviteController"; - -const getTokenFromHeaders = (headers) => { - const authorizationHeader = headers.authorization; - if (!authorizationHeader) throw new Error("No auth headers"); - - const parts = authorizationHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") - throw new Error("Invalid auth headers"); - - return parts[1]; -}; +const { getTokenFromHeaders } = require("../utils/utils"); /** * Issues an invitation to a new user. Only admins can invite new users. An invitation token is created and sent via email. @@ -93,6 +83,6 @@ const inviteVerifyController = async (req, res, next) => { }; module.exports = { - inviteController: issueInvitation, + issueInvitation, inviteVerifyController, }; diff --git a/Server/controllers/maintenanceWindowController.js b/Server/controllers/maintenanceWindowController.js index c6c34b7eb..933da41ea 100644 --- a/Server/controllers/maintenanceWindowController.js +++ b/Server/controllers/maintenanceWindowController.js @@ -84,7 +84,7 @@ const getMaintenanceWindowsByTeamId = async (req, res, next) => { req.query ); - return res.status(201).json({ + return res.status(200).json({ success: true, msg: successMessages.MAINTENANCE_WINDOW_GET_BY_TEAM, data: maintenanceWindows, @@ -109,7 +109,7 @@ const getMaintenanceWindowsByMonitorId = async (req, res, next) => { req.params.monitorId ); - return res.status(201).json({ + return res.status(200).json({ success: true, msg: successMessages.MAINTENANCE_WINDOW_GET_BY_USER, data: maintenanceWindows, @@ -128,7 +128,7 @@ const deleteMaintenanceWindow = async (req, res, next) => { } try { await req.db.deleteMaintenanceWindowById(req.params.id); - return res.status(201).json({ + return res.status(200).json({ success: true, msg: successMessages.MAINTENANCE_WINDOW_DELETE, }); diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index b7e27451b..9ef90fa4b 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -33,7 +33,7 @@ const { handleError, handleValidationError } = require("./controllerUtils"); const getAllMonitors = async (req, res, next) => { try { const monitors = await req.db.getAllMonitors(); - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_GET_ALL, data: monitors, @@ -63,7 +63,7 @@ const getMonitorStatsById = async (req, res, next) => { try { const monitorStats = await req.db.getMonitorStatsById(req); - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_STATS_BY_ID, data: monitorStats, @@ -86,7 +86,7 @@ const getMonitorCertificate = async (req, res, next) => { const monitorUrl = new URL(monitor.url); const certificate = await sslChecker(monitorUrl.hostname); if (certificate && certificate.validTo) { - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_CERTIFICATE, data: { @@ -94,7 +94,7 @@ const getMonitorCertificate = async (req, res, next) => { }, }); } else { - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_CERTIFICATE, data: { certificateDate: "N/A" }, @@ -130,9 +130,10 @@ const getMonitorById = async (req, res, next) => { if (!monitor) { const error = new Error(errorMessages.MONITOR_GET_BY_ID); error.status = 404; + throw error; } - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_GET_BY_ID, data: monitor, @@ -207,7 +208,7 @@ const getMonitorsByTeamId = async (req, res, next) => { try { const teamId = req.params.teamId; const monitors = await req.db.getMonitorsByTeamId(req, res); - return res.json({ + return res.status(200).json({ success: true, msg: successMessages.MONITOR_GET_BY_USER_ID(teamId), data: monitors, @@ -324,14 +325,23 @@ const deleteAllMonitors = async (req, res, next) => { const { jwtSecret } = req.settingsService.getSettings(); const { teamId } = jwt.verify(token, jwtSecret); const { monitors, deletedCount } = await req.db.deleteAllMonitors(teamId); - await monitors.forEach(async (monitor) => { - await req.jobQueue.deleteJob(monitor); - await req.db.deleteChecks(monitor._id); - await req.db.deleteAlertByMonitorId(monitor._id); - await req.db.deletePageSpeedChecksByMonitorId(monitor._id); - await req.db.deleteNotificationsByMonitorId(monitor._id); - }); - + await Promise.all( + monitors.map(async (monitor) => { + try { + await req.jobQueue.deleteJob(monitor); + await req.db.deleteChecks(monitor._id); + await req.db.deletePageSpeedChecksByMonitorId(monitor._id); + await req.db.deleteNotificationsByMonitorId(monitor._id); + } catch (error) { + logger.error( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + { + method: "deleteAllMonitors", + } + ); + } + }) + ); return res .status(200) .json({ success: true, msg: `Deleted ${deletedCount} monitors` }); @@ -459,7 +469,7 @@ const addDemoMonitors = async (req, res, next) => { return res.status(200).json({ success: true, - message: successMessages.MONITOR_DEMO_ADDED, + msg: successMessages.MONITOR_DEMO_ADDED, data: demoMonitors.length, }); } catch (error) { diff --git a/Server/controllers/queueController.js b/Server/controllers/queueController.js index 341c4b76b..3151816be 100644 --- a/Server/controllers/queueController.js +++ b/Server/controllers/queueController.js @@ -1,13 +1,15 @@ const { handleError } = require("./controllerUtils"); - +const { errorMessages, successMessages } = require("../utils/messages"); const SERVICE_NAME = "JobQueueController"; const getMetrics = async (req, res, next) => { try { const metrics = await req.jobQueue.getMetrics(); - res - .status(200) - .json({ success: true, msg: "Metrics retrieved", data: metrics }); + res.status(200).json({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data: metrics, + }); } catch (error) { next(handleError(error, SERVICE_NAME, "getMetrics")); return; @@ -17,7 +19,11 @@ const getMetrics = async (req, res, next) => { const getJobs = async (req, res, next) => { try { const jobs = await req.jobQueue.getJobStats(); - return res.status(200).json({ jobs }); + return res.status(200).json({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data: jobs, + }); } catch (error) { next(handleError(error, SERVICE_NAME, "getJobs")); return; @@ -27,7 +33,10 @@ const getJobs = async (req, res, next) => { const addJob = async (req, res, next) => { try { await req.jobQueue.addJob(Math.random().toString(36).substring(7)); - return res.send("Added job"); + return res.status(200).json({ + success: true, + msg: successMessages.QUEUE_ADD_JOB, + }); } catch (error) { next(handleError(error, SERVICE_NAME, "addJob")); return; @@ -37,7 +46,9 @@ const addJob = async (req, res, next) => { const obliterateQueue = async (req, res, next) => { try { await req.jobQueue.obliterate(); - return res.status(200).send("Obliterated queue"); + return res + .status(200) + .json({ success: true, msg: successMessages.QUEUE_OBLITERATE }); } catch (error) { next(handleError(error, SERVICE_NAME, "obliterateQueue")); return; diff --git a/Server/package-lock.json b/Server/package-lock.json index bdd7db873..09793bb98 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -36,6 +36,7 @@ "devDependencies": { "nodemon": "3.1.0", "nyc": "17.1.0", + "proxyquire": "2.1.3", "sinon": "19.0.2" } }, @@ -2802,6 +2803,20 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3535,6 +3550,22 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3575,6 +3606,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -4968,6 +5009,13 @@ "node": ">=10" } }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true, + "license": "MIT" + }, "node_modules/mongodb": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.6.2.tgz", @@ -5833,6 +5881,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -6545,6 +6600,18 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -6683,6 +6750,24 @@ "dev": true, "license": "ISC" }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7242,6 +7327,19 @@ "node": ">=4" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svgo": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", diff --git a/Server/package.json b/Server/package.json index 0ada3d893..cb6997132 100644 --- a/Server/package.json +++ b/Server/package.json @@ -38,6 +38,7 @@ "devDependencies": { "nodemon": "3.1.0", "nyc": "17.1.0", + "proxyquire": "2.1.3", "sinon": "19.0.2" } } diff --git a/Server/routes/inviteRoute.js b/Server/routes/inviteRoute.js index 17744bf82..93e77e068 100644 --- a/Server/routes/inviteRoute.js +++ b/Server/routes/inviteRoute.js @@ -3,7 +3,7 @@ const { verifyJWT } = require("../middleware/verifyJWT"); const { isAllowed } = require("../middleware/isAllowed"); const { - inviteController, + issueInvitation, inviteVerifyController, } = require("../controllers/inviteController"); @@ -11,8 +11,8 @@ router.post( "/", isAllowed(["admin", "superadmin"]), verifyJWT, - inviteController + issueInvitation ); -router.post("/verify", inviteVerifyController); +router.post("/verify", issueInvitation); module.exports = router; diff --git a/Server/tests/controllers/authController.test.js b/Server/tests/controllers/authController.test.js index 5e5613972..842959415 100644 --- a/Server/tests/controllers/authController.test.js +++ b/Server/tests/controllers/authController.test.js @@ -1,4 +1,5 @@ const { + issueToken, registerUser, loginUser, editUser, @@ -12,11 +13,45 @@ const { const jwt = require("jsonwebtoken"); const { errorMessages, successMessages } = require("../../utils/messages"); const sinon = require("sinon"); +const logger = require("../../utils/logger"); + +describe("Auth Controller - issueToken", () => { + it("should reject with an error if jwt.sign fails", () => { + const error = new Error("jwt.sign error"); + stub = sinon.stub(jwt, "sign").throws(error); + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret" }; + expect(() => issueToken(payload, appSettings)).to.throw(error); + stub.restore(); + }); + + it("should return a token if jwt.sign is successful and appSettings.jtwTTL is not defined", () => { + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret" }; + const token = issueToken(payload, appSettings); + expect(token).to.be.a("string"); + }); + + it("should return a token if jwt.sign is successful and appSettings.jwtTTL is defined", () => { + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret", jwtTTL: "1s" }; + const token = issueToken(payload, appSettings); + expect(token).to.be.a("string"); + }); +}); describe("Auth Controller - registerUser", () => { - // Set up test beforeEach(() => { req = { + body: { + firstName: "firstname", + lastName: "lastname", + email: "test@test.com", + password: "Uptime1!", + role: ["admin"], + teamId: "123", + inviteToken: "invite", + }, db: { checkSuperadmin: sinon.stub(), getInviteTokenAndDelete: sinon.stub(), @@ -29,7 +64,7 @@ describe("Auth Controller - registerUser", () => { }), }, emailService: { - buildAndSendEmail: sinon.stub().returns(Promise.resolve()), + buildAndSendEmail: sinon.stub(), }, file: {}, }; @@ -38,72 +73,97 @@ describe("Auth Controller - registerUser", () => { json: sinon.stub(), }; next = sinon.stub(); + sinon.stub(logger, "error"); + }); + afterEach(() => { + sinon.restore(); }); - it("should register a valid user", async () => { - req.body = { - firstName: "John", - lastName: "Doe", - email: "john.doe@example.com", - password: "Uptime1!", - inviteToken: "someToken", - role: ["user"], - teamId: "123", - }; - req.db.checkSuperadmin.resolves(false); - req.db.insertUser.resolves({ - _id: "123", - _doc: { - firstName: "John", - lastName: "Doe", - email: "john.doe@example.com", - }, - }); + it("should reject with an error if body validation fails", async () => { + req.body = {}; + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if checkSuperadmin fails", async () => { + req.db.checkSuperadmin.rejects(new Error("checkSuperadmin error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("checkSuperadmin error"); + }); + + it("should reject with an error if getInviteTokenAndDelete fails", async () => { + req.db.checkSuperadmin.resolves(true); + req.db.getInviteTokenAndDelete.rejects( + new Error("getInviteTokenAndDelete error") + ); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal( + "getInviteTokenAndDelete error" + ); + }); + + it("should reject with an error if updateAppSettings fails", async () => { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.rejects(new Error("updateAppSettings error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("updateAppSettings error"); + }); + + it("should reject with an error if insertUser fails", async () => { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.rejects(new Error("insertUser error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("insertUser error"); + }); + + it("should reject with an error if settingsService.getSettings fails", async () => { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.resolves({ _id: "123" }); + req.settingsService.getSettings.rejects( + new Error("settingsService.getSettings error") + ); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal( + "settingsService.getSettings error" + ); + }); + + it("should log an error if emailService.buildAndSendEmail fails", async () => { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.returns({ _id: "123" }); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.emailService.buildAndSendEmail.rejects(new Error("emailService error")); + await registerUser(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[1].error).to.equal("emailService error"); + }); + it("should return a success message and data if all operations are successful", async () => { + const user = { _id: "123" }; + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.returns(user); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.emailService.buildAndSendEmail.resolves("message-id"); await registerUser(req, res, next); expect(res.status.calledWith(200)).to.be.true; expect( - res.json.calledWith( - sinon.match({ - success: true, - msg: sinon.match.string, - data: { - user: sinon.match.object, - token: sinon.match.string, - }, - }) - ) + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_CREATE_USER, + data: { user, token: sinon.match.string }, + }) ).to.be.true; expect(next.notCalled).to.be.true; }); - it("should reject a user with an invalid password", async () => { - req.body = { - firstName: "John", - lastName: "Doe", - email: "john.doe@example.com", - password: "bad_password", - inviteToken: "someToken", - role: ["user"], - teamId: "123", - }; - await registerUser(req, res, next); - expect(next.firstCall.args[0]).to.be.an("error"); - expect(next.firstCall.args[0].status).to.equal(422); - }); - it("should reject a user with an invalid role", async () => { - req.body = { - firstName: "John", - lastName: "Doe", - email: "john.doe@example.com", - password: "Uptime1!", - inviteToken: "someToken", - role: ["superman"], - teamId: "123", - }; - await registerUser(req, res, next); - expect(next.firstCall.args[0]).to.be.an("error"); - expect(next.firstCall.args[0].status).to.equal(422); - }); }); describe("Auth Controller - loginUser", () => { @@ -131,6 +191,20 @@ describe("Auth Controller - loginUser", () => { comparePassword: sinon.stub(), }; }); + it("should reject with an error if validation fails", async () => { + req.body = {}; + await loginUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getUserByEmail fails", async () => { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await loginUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + it("should login user successfully", async () => { req.db.getUserByEmail.resolves(user); user.comparePassword.resolves(true); @@ -186,10 +260,63 @@ describe("Auth Controller - editUser", async () => { json: sinon.stub(), }; next = sinon.stub(); + stub = sinon.stub(jwt, "verify").returns({ email: "test@example.com" }); + }); + afterEach(() => { + sinon.restore(); + stub.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if body validation fails", async () => { + req.body = { invalid: 1 }; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if param.userId !== req.user._id", async () => { + req.params = { userId: "456" }; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(401); + }); + + it("should reject with an error if !req.body.password and getUserByEmail fails", async () => { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + + it("should reject with an error if user.comparePassword fails", async () => { + req.db.getUserByEmail.returns({ + comparePassword: sinon.stub().rejects(new Error("Bad Password Match")), + }); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Bad Password Match"); + }); + + it("should reject with an error if user.comparePassword returns false", async () => { + req.db.getUserByEmail.returns({ + comparePassword: sinon.stub().returns(false), + }); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(403); + expect(next.firstCall.args[0].message).to.equal( + errorMessages.AUTH_INCORRECT_PASSWORD + ); }); it("should edit a user if it receives a proper request", async () => { - sinon.stub(jwt, "verify").returns({ email: "test@example.com" }); const user = { comparePassword: sinon.stub().resolves(true), }; @@ -211,6 +338,24 @@ describe("Auth Controller - editUser", async () => { expect(next.notCalled).to.be.true; }); + it("should edit a user if it receives a proper request and both password fields are undefined", async () => { + req.body.password = undefined; + req.body.newPassword = undefined; + req.db.getUserByEmail.resolves(user); + req.db.updateUser.resolves({ email: "test@example.com" }); + + await editUser(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_UPDATE_USER, + data: { email: "test@example.com" }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + it("should reject an edit request if password format is incorrect", async () => { req.body = { password: "bad_password", newPassword: "bad_password" }; const user = { @@ -239,6 +384,13 @@ describe("Auth Controller - checkSuperadminExists", async () => { next = sinon.stub(); }); + it("should reject with an error if checkSuperadmin fails", async () => { + req.db.checkSuperadmin.rejects(new Error("checkSuperadmin error")); + await checkSuperadminExists(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("checkSuperadmin error"); + }); + it("should return true if a superadmin exists", async () => { req.db.checkSuperadmin.resolves(true); await checkSuperadminExists(req, res, next); @@ -289,6 +441,30 @@ describe("Auth Controller - requestRecovery", async () => { }; next = sinon.stub(); }); + + it("should reject with an error if validation fails", async () => { + req.body = {}; + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getUserByEmail fails", async () => { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + + it("should throw an error if the user is not found", async () => { + req.db.getUserByEmail.resolves(null); + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + // expect(next.firstCall.args[0].message).to.equal( + // errorMessages.FRIENDLY_ERROR + // ); + }); + it("should throw an error if the email is not provided", async () => { req.body = {}; await requestRecovery(req, res, next); @@ -344,15 +520,24 @@ describe("Auth Controller - validateRecovery", async () => { next = sinon.stub(); }); - it("should call next with a validation error if the token is invalid", async () => { - req = { - body: {}, - }; + it("should reject with an error if validation fails", async () => { + req.body = {}; await validateRecovery(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); expect(next.firstCall.args[0].status).to.equal(422); }); + it("should reject with an error if validateRecoveryToken fails", async () => { + req.db.validateRecoveryToken.rejects( + new Error("validateRecoveryToken error") + ); + await validateRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal( + "validateRecoveryToken error" + ); + }); + it("should return a success message if the token is valid", async () => { req.db.validateRecoveryToken.resolves(); await validateRecovery(req, res, next); @@ -391,14 +576,23 @@ describe("Auth Controller - resetPassword", async () => { }; handleValidationError = sinon.stub(); handleError = sinon.stub(); - issueToken = sinon.stub(); }); - it("should call next with a validation error if the password is invalid", async () => { + it("should reject with an error if validation fails", async () => { req.body = { password: "bad_password" }; await resetPassword(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); expect(next.firstCall.args[0].status).to.equal(422); }); + + it("should reject with an error if resetPassword fails", async () => { + const error = new Error("resetPassword error"); + newPasswordValidation.validateAsync.resolves(); + req.db.resetPassword.rejects(error); + await resetPassword(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("resetPassword error"); + }); + it("should reset password successfully", async () => { const user = { _doc: {} }; const appSettings = { jwtSecret: "my_secret" }; @@ -407,7 +601,6 @@ describe("Auth Controller - resetPassword", async () => { newPasswordValidation.validateAsync.resolves(); req.db.resetPassword.resolves(user); req.settingsService.getSettings.resolves(appSettings); - issueToken.returns(token); await resetPassword(req, res, next); diff --git a/Server/tests/controllers/checkController.test.js b/Server/tests/controllers/checkController.test.js new file mode 100644 index 000000000..a3060d60a --- /dev/null +++ b/Server/tests/controllers/checkController.test.js @@ -0,0 +1,370 @@ +const { + createCheck, + getChecks, + getTeamChecks, + deleteChecks, + deleteChecksByTeamId, + updateChecksTTL, +} = require("../../controllers/checkController"); +const jwt = require("jsonwebtoken"); +const { errorMessages, successMessages } = require("../../utils/messages"); +const sinon = require("sinon"); + +describe("Check Controller - createCheck", () => { + beforeEach(() => { + req = { + params: {}, + body: {}, + db: { + createCheck: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); // Restore the original methods after each test + }); + + it("should reject with a validation if params are invalid", async () => { + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with a validation error if body is invalid", async () => { + req.params = { + monitorId: "monitorId", + }; + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async () => { + req.params = { + monitorId: "monitorId", + }; + req.body = { + monitorId: "monitorId", + status: true, + responseTime: 100, + statusCode: 200, + message: "message", + }; + req.db.createCheck.rejects(new Error("error")); + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); + + it("should return a success message if check is created", async () => { + req.params = { + monitorId: "monitorId", + }; + req.db.createCheck.resolves({ id: "123" }); + req.body = { + monitorId: "monitorId", + status: true, + responseTime: 100, + statusCode: 200, + message: "message", + }; + await createCheck(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.CHECK_CREATE, + data: { id: "123" }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Check Controller - getChecks", () => { + beforeEach(() => { + req = { + params: {}, + query: {}, + db: { + getChecks: sinon.stub(), + getChecksCount: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with a validation error if params are invalid", async () => { + await getChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should return a success message if checks are found", async () => { + req.params = { + monitorId: "monitorId", + }; + req.db.getChecks.resolves([{ id: "123" }]); + req.db.getChecksCount.resolves(1); + await getChecks(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.CHECK_GET, + data: { checksCount: 1, checks: [{ id: "123" }] }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should call next with error if data retrieval fails", async () => { + req.params = { + monitorId: "monitorId", + }; + req.db.getChecks.rejects(new Error("error")); + await getChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); +}); + +describe("Check Controller - getTeamChecks", () => { + beforeEach(() => { + req = { + params: {}, + query: {}, + db: { + getTeamChecks: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with a validation error if params are invalid", async () => { + await getTeamChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should return 200 and check data on successful validation and data retrieval", async () => { + req.params = { teamId: "1" }; + const checkData = [{ id: 1, name: "Check 1" }]; + req.db.getTeamChecks.resolves(checkData); + + await getTeamChecks(req, res, next); + expect(req.db.getTeamChecks.calledOnceWith(req)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_GET, + data: checkData, + }) + ).to.be.true; + }); + + it("should call next with error if data retrieval fails", async () => { + req.params = { teamId: "1" }; + req.db.getTeamChecks.rejects(new Error("Retrieval Error")); + await getTeamChecks(req, res, next); + expect(req.db.getTeamChecks.calledOnceWith(req)).to.be.true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); +}); + +describe("Check Controller - deleteChecks", () => { + beforeEach(() => { + req = { + params: {}, + db: { + deleteChecks: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + await deleteChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async () => { + req.params = { monitorId: "1" }; + req.db.deleteChecks.rejects(new Error("Deletion Error")); + await deleteChecks(req, res, next); + expect(req.db.deleteChecks.calledOnceWith(req.params.monitorId)).to.be.true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); + + it("should delete checks successfully", async () => { + req.params = { monitorId: "123" }; + req.db.deleteChecks.resolves(1); + await deleteChecks(req, res, next); + expect(req.db.deleteChecks.calledOnceWith(req.params.monitorId)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_DELETE, + data: { deletedCount: 1 }, + }) + ).to.be.true; + }); +}); + +describe("Check Controller - deleteChecksByTeamId", () => { + beforeEach(() => { + req = { + params: {}, + db: { + deleteChecksByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + await deleteChecksByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async () => { + req.params = { teamId: "1" }; + req.db.deleteChecksByTeamId.rejects(new Error("Deletion Error")); + await deleteChecksByTeamId(req, res, next); + expect(req.db.deleteChecksByTeamId.calledOnceWith(req.params.teamId)).to.be + .true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); + + it("should delete checks successfully", async () => { + req.params = { teamId: "123" }; + req.db.deleteChecksByTeamId.resolves(1); + await deleteChecksByTeamId(req, res, next); + expect(req.db.deleteChecksByTeamId.calledOnceWith(req.params.teamId)).to.be + .true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_DELETE, + data: { deletedCount: 1 }, + }) + ).to.be.true; + }); +}); + +describe("Check Controller - updateCheckTTL", () => { + beforeEach(() => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + + req = { + body: {}, + headers: { authorization: "Bearer token" }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "my_secret" }), + }, + db: { + updateChecksTTL: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + stub.restore(); + }); + + it("should reject if body validation fails", async () => { + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should throw a JwtError if verification fails", async () => { + stub.restore(); + req.body = { + ttl: 1, + }; + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + + it("should call next with error if data retrieval fails", async () => { + req.body = { + ttl: 1, + }; + req.db.updateChecksTTL.rejects(new Error("Update Error")); + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); + + it("should update TTL successfully", async () => { + req.body = { + ttl: 1, + }; + req.db.updateChecksTTL.resolves(); + await updateChecksTTL(req, res, next); + expect(req.db.updateChecksTTL.calledOnceWith("123", 1 * 86400)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_UPDATE_TTL, + }) + ).to.be.true; + }); +}); diff --git a/Server/tests/controllers/controllerUtils.test.js b/Server/tests/controllers/controllerUtils.test.js new file mode 100644 index 000000000..71d134bb6 --- /dev/null +++ b/Server/tests/controllers/controllerUtils.test.js @@ -0,0 +1,105 @@ +const { + handleValidationError, + handleError, +} = require("../../controllers/controllerUtils"); + +describe("controllerUtils - handleValidationError", () => { + it("should set status to 422", () => { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.status).to.equal(422); + }); + + it("should set service to the provided serviceName", () => { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.service).to.equal(serviceName); + }); + + it("should set message to error.details[0].message if present", () => { + const error = { + details: [{ message: "Detail message" }], + }; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Detail message"); + }); + + it("should set message to error.message if error.details is not present", () => { + const error = { + message: "Error message", + }; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Error message"); + }); + + it('should set message to "Validation Error" if neither error.details nor error.message is present', () => { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Validation Error"); + }); +}); + +describe("handleError", () => { + it("should set stats to the provided status if error.code is undefined", () => { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const status = 400; + const result = handleError(error, serviceName, method, status); + expect(result.status).to.equal(status); + }); + + it("should not overwrite error.code if it is already defined", () => { + const error = { status: 404 }; + const serviceName = "TestService"; + const method = "testMethod"; + const status = 400; + const result = handleError(error, serviceName, method, status); + expect(result.status).to.equal(404); + }); + + it("should set service to the provided serviceName if error.service is undefined", () => { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.service).to.equal(serviceName); + }); + + it("should not overwrite error.service if it is already defined", () => { + const error = { service: "ExistingService" }; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.service).to.equal("ExistingService"); + }); + + it("should set method to the provided method if error.method is undefined", () => { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.method).to.equal(method); + }); + + it("should not overwrite error.method if it is already defined", () => { + const error = { method: "existingMethod" }; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.method).to.equal("existingMethod"); + }); + + it("should set code to 500 if error.code is undefined and no code is provided", () => { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.status).to.equal(500); + }); +}); diff --git a/Server/tests/controllers/inviteController.test.js b/Server/tests/controllers/inviteController.test.js new file mode 100644 index 000000000..31085c40f --- /dev/null +++ b/Server/tests/controllers/inviteController.test.js @@ -0,0 +1,202 @@ +const { + issueInvitation, + inviteVerifyController, +} = require("../../controllers/inviteController"); +const jwt = require("jsonwebtoken"); +const { errorMessages, successMessages } = require("../../utils/messages"); +const sinon = require("sinon"); +const joi = require("joi"); +describe("inviteController - issueInvitation", () => { + beforeEach(() => { + req = { + headers: { authorization: "Bearer token" }, + body: { + email: "test@test.com", + role: ["admin"], + teamId: "123", + }, + db: { requestInviteToken: sinon.stub() }, + settingsService: { getSettings: sinon.stub() }, + emailService: { buildAndSendEmail: sinon.stub() }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if role validation fails", async () => { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["bad_role"], firstname: "first_name", teamId: "1" }; + }); + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0]).to.be.instanceOf(joi.ValidationError); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if body validation fails", async () => { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["admin"], firstname: "first_name", teamId: "1" }; + }); + req.body = {}; + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if DB operations fail", async () => { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["admin"], firstname: "first_name", teamId: "1" }; + }); + req.db.requestInviteToken.throws(new Error("DB error")); + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + + it("should send an invite successfully", async () => { + const token = "token"; + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + req.emailService.buildAndSendEmail.resolves(); + await issueInvitation(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: "Invite sent", + data: inviteToken, + }) + ).to.be.true; + stub.restore(); + }); + + it("should send an email successfully", async () => { + const token = "token"; + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + req.emailService.buildAndSendEmail.resolves(); + + await issueInvitation(req, res, next); + expect(req.emailService.buildAndSendEmail.calledOnce).to.be.true; + expect( + req.emailService.buildAndSendEmail.calledWith( + "employeeActivationTemplate", + { + name: "John", + link: "http://localhost/register/inviteToken", + }, + "test@test.com", + "Welcome to Uptime Monitor" + ) + ).to.be.true; + stub.restore(); + }); + + it("should continue executing if sending an email fails", async () => { + const token = "token"; + req.emailService.buildAndSendEmail.rejects(new Error("Email error")); + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + await issueInvitation(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: "Invite sent", + data: inviteToken, + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("inviteController - inviteVerifyController", () => { + beforeEach(() => { + req = { + body: { token: "token" }, + db: { + getInviteToken: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async () => { + req.body = {}; + await inviteVerifyController(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async () => { + req.db.getInviteToken.throws(new Error("DB error")); + await inviteVerifyController(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return 200 and invite data when validation and invite retrieval are successful", async () => { + req.db.getInviteToken.resolves({ invite: "data" }); + await inviteVerifyController(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + status: "success", + msg: "Invite verified", + data: { invite: "data" }, + }) + ).to.be.true; + expect(next.called).to.be.false; + }); +}); diff --git a/Server/tests/controllers/maintenanceWindowController.test.js b/Server/tests/controllers/maintenanceWindowController.test.js new file mode 100644 index 000000000..5bc3a906e --- /dev/null +++ b/Server/tests/controllers/maintenanceWindowController.test.js @@ -0,0 +1,410 @@ +const { + createMaintenanceWindows, + getMaintenanceWindowById, + getMaintenanceWindowsByTeamId, + getMaintenanceWindowsByMonitorId, + deleteMaintenanceWindow, + editMaintenanceWindow, +} = require("../../controllers/maintenanceWindowController"); + +const jwt = require("jsonwebtoken"); +const { errorMessages, successMessages } = require("../../utils/messages"); +const sinon = require("sinon"); + +describe("maintenanceWindowController - createMaintenanceWindows", () => { + beforeEach(() => { + req = { + body: { + monitors: ["66ff52e7c5911c61698ac724"], + name: "window", + active: true, + start: "2024-10-11T05:27:13.747Z", + end: "2024-10-11T05:27:14.747Z", + repeat: "123", + }, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + createMaintenanceWindow: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async () => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.body = {}; + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if jwt.verify fails", async () => { + stub = sinon.stub(jwt, "verify").throws(new jwt.JsonWebTokenError()); + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + stub.restore(); + }); + + it("should reject with an error DB operations fail", async () => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.createMaintenanceWindow.throws(new Error("DB error")); + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + it("should return success message if all operations are successful", async () => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + await createMaintenanceWindows(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_CREATE, + }) + ).to.be.true; + stub.restore(); + }); + it("should return success message if all operations are successful with active set to undefined", async () => { + req.body.active = undefined; + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + await createMaintenanceWindows(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_CREATE, + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowById", () => { + beforeEach(() => { + req = { + body: {}, + params: { + id: "123", + }, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject if param validation fails", async () => { + req.params = {}; + await getMaintenanceWindowById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject if DB operations fail", async () => { + req.db.getMaintenanceWindowById.throws(new Error("DB error")); + await getMaintenanceWindowById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should return success message with data if all operations are successful", async () => { + req.db.getMaintenanceWindowById.returns({ id: "123" }); + await getMaintenanceWindowById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_ID, + data: { id: "123" }, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowsByTeamId", () => { + beforeEach(() => { + req = { + body: {}, + params: {}, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowsByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + it("should reject if query validation fails", async () => { + req.query = { + invalid: 1, + }; + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject if jwt.verify fails", async () => { + stub = sinon.stub(jwt, "verify").throws(new jwt.JsonWebTokenError()); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + stub.restore(); + }); + + it("should reject with an error if DB operations fail", async () => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.getMaintenanceWindowsByTeamId.throws(new Error("DB error")); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + + it("should return success message with data if all operations are successful", async () => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.getMaintenanceWindowsByTeamId.returns([{ id: "123" }]); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_TEAM, + data: [{ id: jwt.verify().teamId }], + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowsByMonitorId", () => { + beforeEach(() => { + req = { + body: {}, + params: { + monitorId: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowsByMonitorId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject if param validation fails", async () => { + req.params = {}; + await getMaintenanceWindowsByMonitorId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async () => { + req.db.getMaintenanceWindowsByMonitorId.throws(new Error("DB error")); + await getMaintenanceWindowsByMonitorId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message with data if all operations are successful", async () => { + const data = [{ monitorId: "123" }]; + req.db.getMaintenanceWindowsByMonitorId.returns(data); + await getMaintenanceWindowsByMonitorId(req, res, next); + expect( + req.db.getMaintenanceWindowsByMonitorId.calledOnceWith( + req.params.monitorId + ) + ); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_MONITOR, + data: data, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - deleteMaintenanceWindow", () => { + beforeEach(() => { + req = { + body: {}, + params: { + id: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + deleteMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should reject if param validation fails", async () => { + req.params = {}; + await deleteMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async () => { + req.db.deleteMaintenanceWindowById.throws(new Error("DB error")); + await deleteMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message if all operations are successful", async () => { + await deleteMaintenanceWindow(req, res, next); + expect(req.db.deleteMaintenanceWindowById.calledOnceWith(req.params.id)); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_DELETE, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - editMaintenanceWindow", () => { + beforeEach(() => { + req = { + body: { + active: true, + name: "test", + }, + params: { + id: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + editMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject if param validation fails", async () => { + req.params = {}; + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject if body validation fails", async () => { + req.body = { invalid: 1 }; + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async () => { + req.db.editMaintenanceWindowById.throws(new Error("DB error")); + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message with data if all operations are successful", async () => { + const data = { id: "123" }; + req.db.editMaintenanceWindowById.returns(data); + + await editMaintenanceWindow(req, res, next); + expect( + req.db.editMaintenanceWindowById.calledOnceWith(req.params.id, req.body) + ); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_EDIT, + data: data, + }) + ).to.be.true; + }); +}); diff --git a/Server/tests/controllers/monitorController.test.js b/Server/tests/controllers/monitorController.test.js new file mode 100644 index 000000000..e2855e81b --- /dev/null +++ b/Server/tests/controllers/monitorController.test.js @@ -0,0 +1,1010 @@ +const { + getAllMonitors, + getMonitorStatsById, + getMonitorCertificate, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + deleteMonitor, + deleteAllMonitors, + editMonitor, + pauseMonitor, + addDemoMonitors, +} = require("../../controllers/monitorController"); +const jwt = require("jsonwebtoken"); +const proxyquire = require("proxyquire"); +const sinon = require("sinon"); +const { errorMessages, successMessages } = require("../../utils/messages"); +const sslCheckerStub = sinon.stub(); +const monitorController = proxyquire("../../controllers/monitorController", { + "ssl-checker": sslCheckerStub, +}); +const logger = require("../../utils/logger"); +const SERVICE_NAME = "monitorController"; + +describe("Monitor Controller - getAllMonitors", () => { + beforeEach(() => { + req = { + params: {}, + query: {}, + body: {}, + db: { + getAllMonitors: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getAllMonitors.throws(new Error("DB error")); + await getAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async () => { + const data = [{ monitor: "data" }]; + req.db.getAllMonitors.returns(data); + await getAllMonitors(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_ALL, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorStatsById", () => { + beforeEach(() => { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorStatsById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if query validation fails", async () => { + req.query = { invalid: 1 }; + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getMonitorStatsById.throws(new Error("DB error")); + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should return success message and data if all operations succeed", async () => { + const data = [{ monitorStats: "data" }]; + req.db.getMonitorStatsById.returns(data); + await getMonitorStatsById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_STATS_BY_ID, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorCertificate", () => { + beforeEach(() => { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await getMonitorCertificate(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if creating a URL fails", async () => { + req.db.getMonitorById.returns(null); + await getMonitorCertificate(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0]).to.be.instanceOf(TypeError); + }); + it("should reject with an error if SSL checker fails", async () => { + req.db.getMonitorById.returns({ url: "https://example.com" }); + sslCheckerStub.throws(new Error("SSL checker error")); + await monitorController.getMonitorCertificate(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("SSL checker error"); + }); + it("should return success message and data if all operations succeed", async () => { + req.db.getMonitorById.returns({ url: "https://example.com" }); + const certificate = { validTo: 1 }; + sslCheckerStub.returns(certificate); + await monitorController.getMonitorCertificate(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CERTIFICATE, + data: { + certificateDate: new Date(certificate.validTo).toLocaleDateString(), + }, + }) + ).to.be.true; + }); + it("should return success message and N/A if certificate is not found", async () => { + req.db.getMonitorById.returns({ url: "https://example.com" }); + sslCheckerStub.returns(null); + await monitorController.getMonitorCertificate(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CERTIFICATE, + data: { + certificateDate: "N/A", + }, + }) + ).to.be.true; + }); + + it("should return success message and N/A if certificate doesn't have validTo", async () => { + req.db.getMonitorById.returns({ url: "https://example.com" }); + sslCheckerStub.returns({ valid: 1 }); + await monitorController.getMonitorCertificate(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CERTIFICATE, + data: { + certificateDate: "N/A", + }, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorById", () => { + beforeEach(() => { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if query param validation fails", async () => { + req.query = { invalid: 1 }; + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getMonitorById.throws(new Error("DB error")); + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should return 404 if a monitor is not found", async () => { + req.db.getMonitorById.returns(null); + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(404); + }); + it("should return success message and data if all operations succeed", async () => { + const data = { monitor: "data" }; + req.db.getMonitorById.returns(data); + await getMonitorById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_ID, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorsAndSummaryByTeamId", () => { + beforeEach(() => { + req = { + params: { + teamId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorsAndSummaryByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if query validation fails", async () => { + req.query = { invalid: 1 }; + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getMonitorsAndSummaryByTeamId.throws(new Error("DB error")); + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should return success message and data if all operations succeed", async () => { + const data = { monitors: "data", summary: "data" }; + req.db.getMonitorsAndSummaryByTeamId.returns(data); + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_USER_ID(req.params.teamId), + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorsByTeamId", () => { + beforeEach(() => { + req = { + params: { + teamId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorsByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if query validation fails", async () => { + req.query = { invalid: 1 }; + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getMonitorsByTeamId.throws(new Error("DB error")); + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should return success message and data if all operations succeed", async () => { + const data = { monitors: "data" }; + req.db.getMonitorsByTeamId.returns(data); + await getMonitorsByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_USER_ID(req.params.teamId), + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - createMonitor", () => { + beforeEach(() => { + req = { + params: {}, + query: {}, + body: { + userId: "123", + teamId: "123", + name: "test_monitor", + description: "test_monitor_desc", + type: "http", + url: "https://example.com", + notifications: [{ email: "example@example.com" }], + }, + db: { + createMonitor: sinon.stub(), + createNotification: sinon.stub(), + }, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if body validation fails", async () => { + req.body = {}; + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB createMonitor operation fail", async () => { + req.db.createMonitor.throws(new Error("DB error")); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if DB createNotification operation fail", async () => { + req.db.createNotification.throws(new Error("DB error")); + req.db.createMonitor.returns({ _id: "123" }); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if monitor.save operation fail", async () => { + req.db.createMonitor.returns({ + _id: "123", + save: sinon.stub().throws(new Error("Monitor save error")), + }); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Monitor save error"); + }); + it("should throw an error if addJob operation fails", async () => { + req.db.createMonitor.returns({ _id: "123", save: sinon.stub() }); + req.jobQueue.addJob.throws(new Error("Job error")); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Job error"); + }); + it("should return success message and data if all operations succeed with empty notifications", async () => { + req.body.notifications = []; + const monitor = { _id: "123", save: sinon.stub() }; + req.db.createMonitor.returns(monitor); + await createMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CREATE, + data: monitor, + }) + ).to.be.true; + }); + it("should return success message and data if all operations succeed", async () => { + const monitor = { _id: "123", save: sinon.stub() }; + req.db.createMonitor.returns(monitor); + await createMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CREATE, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - deleteMonitor", () => { + beforeEach(() => { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + deleteMonitor: sinon.stub(), + deleteChecks: sinon.stub(), + deletePageSpeedChecksByMonitorId: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logger, "error"); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await deleteMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if DB deleteMonitor operation fail", async () => { + req.db.deleteMonitor.throws(new Error("DB error")); + await deleteMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should log an error if deleteJob throws an error", async () => { + const error = new Error("Job error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.jobQueue.deleteJob.rejects(error); + await monitorController.deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + expect(logger.error.firstCall.args[1]).to.deep.equal({ + method: "deleteMonitor", + service: SERVICE_NAME, + error: error.message, + }); + }); + it("should log an error if deleteChecks throws an error", async () => { + const error = new Error("Checks error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deleteChecks.rejects(error); + await monitorController.deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + expect(logger.error.firstCall.args[1]).to.deep.equal({ + method: "deleteMonitor", + service: SERVICE_NAME, + error: error.message, + }); + }); + it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async () => { + const error = new Error("PageSpeed error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deletePageSpeedChecksByMonitorId.rejects(error); + await monitorController.deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + expect(logger.error.firstCall.args[1]).to.deep.equal({ + method: "deleteMonitor", + service: SERVICE_NAME, + error: error.message, + }); + }); + it("should log an error if deleteNotificationsByMonitorId throws an error", async () => { + const error = new Error("Notifications error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deleteNotificationsByMonitorId.rejects(error); + await monitorController.deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + expect(logger.error.firstCall.args[1]).to.deep.equal({ + method: "deleteMonitor", + service: SERVICE_NAME, + error: error.message, + }); + }); + it("should return success message if all operations succeed", async () => { + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + await monitorController.deleteMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_DELETE, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - deleteAllMonitors", () => { + beforeEach(() => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req = { + headers: { + authorization: "Bearer token", + }, + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + deleteAllMonitors: sinon.stub(), + deleteChecks: sinon.stub(), + deletePageSpeedChecksByMonitorId: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logger, "error"); + }); + afterEach(() => { + sinon.restore(); + stub.restore(); + }); + it("should reject with an error if getTokenFromHeaders throws an error", async () => { + req.headers = {}; + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("No auth headers"); + expect(next.firstCall.args[0].status).to.equal(500); + }); + it("should reject with an error if token validation fails", async () => { + stub.restore(); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + it("should reject with an error if DB deleteAllMonitors operation fail", async () => { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.throws(new Error("DB error")); + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should log an error if deleteJob throws an error", async () => { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Job error"); + req.jobQueue.deleteJob.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.deep.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + it("should log an error if deleteChecks throws an error", async () => { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Check error"); + req.db.deleteChecks.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.deep.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async () => { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Pagespeed Check error"); + req.db.deletePageSpeedChecksByMonitorId.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.deep.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + it("should log an error if deleteNotificationsByMonitorId throws an error", async () => { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Notifications Check error"); + req.db.deleteNotificationsByMonitorId.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.deep.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); +}); + +describe("Monitor Controller - editMonitor", () => { + beforeEach(() => { + req = { + headers: {}, + params: { + monitorId: "123", + }, + query: {}, + body: { + notifications: [{ email: "example@example.com" }], + }, + db: { + getMonitorById: sinon.stub(), + editMonitor: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + createNotification: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + addJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if body validation fails", async () => { + req.body = { invalid: 1 }; + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if getMonitorById operation fails", async () => { + req.db.getMonitorById.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if editMonitor operation fails", async () => { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if deleteNotificationsByMonitorId operation fails", async () => { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.db.deleteNotificationsByMonitorId.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if createNotification operation fails", async () => { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.db.createNotification.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if deleteJob operation fails", async () => { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.jobQueue.deleteJob.throws(new Error("Job error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Job error"); + }); + it("should reject with an error if addJob operation fails", async () => { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + it("should return success message with data if all operations succeed and empty notifications", async () => { + req.body.notifications = []; + const monitor = { _id: "123" }; + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns(monitor); + await editMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_EDIT, + data: monitor, + }) + ).to.be.true; + }); + it("should return success message with data if all operations succeed", async () => { + const monitor = { _id: "123" }; + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns(monitor); + await editMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_EDIT, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - pauseMonitor", () => { + beforeEach(() => { + req = { + headers: {}, + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + addJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async () => { + req.params = {}; + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if getMonitorById operation fails", async () => { + req.db.getMonitorById.throws(new Error("DB error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if deleteJob operation fails", async () => { + const monitor = { _id: req.params.monitorId, isActive: true }; + req.db.getMonitorById.returns(monitor); + req.jobQueue.deleteJob.throws(new Error("Delete Job error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Delete Job error"); + }); + + it("should reject with an error if addJob operation fails", async () => { + const monitor = { _id: req.params.monitorId, isActive: false }; + req.db.getMonitorById.returns(monitor); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + it("should reject with an error if monitor.save operation fails", async () => { + const monitor = { + _id: req.params.monitorId, + active: false, + save: sinon.stub().throws(new Error("Save error")), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Save error"); + }); + it("should return success pause message with data if all operations succeed with inactive monitor", async () => { + const monitor = { + _id: req.params.monitorId, + isActive: false, + save: sinon.stub().resolves(), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_PAUSE, + data: monitor, + }) + ).to.be.true; + }); + it("should return success resume message with data if all operations succeed with active monitor", async () => { + const monitor = { + _id: req.params.monitorId, + isActive: true, + save: sinon.stub().resolves(), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_RESUME, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - addDemoMonitors", () => { + beforeEach(() => { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { _id: "123", teamId: "123" }; + }); + req = { + headers: { + authorization: "Bearer token", + }, + params: {}, + query: {}, + body: {}, + db: { + addDemoMonitors: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + stub.restore(); + }); + it("should reject with an error if getTokenFromHeaders fails", async () => { + req.headers = {}; + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("No auth headers"); + expect(next.firstCall.args[0].status).to.equal(500); + }); + it("should reject with an error if getting settings fails", async () => { + req.settingsService.getSettings.throws(new Error("Settings error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Settings error"); + }); + it("should reject with an error if JWT validation fails", async () => { + stub.restore(); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + it("should reject with an error if addDemoMonitors operation fails", async () => { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.throws(new Error("DB error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + it("should reject with an error if addJob operation fails", async () => { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.returns([{ _id: "123" }]); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + it("should return success message with data if all operations succeed", async () => { + const monitors = [{ _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.returns(monitors); + await addDemoMonitors(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_DEMO_ADDED, + data: monitors.length, + }) + ).to.be.true; + }); +}); diff --git a/Server/tests/controllers/queueController.test.js b/Server/tests/controllers/queueController.test.js new file mode 100644 index 000000000..476905b54 --- /dev/null +++ b/Server/tests/controllers/queueController.test.js @@ -0,0 +1,168 @@ +const { afterEach } = require("node:test"); +const { + getMetrics, + getJobs, + addJob, + obliterateQueue, +} = require("../../controllers/queueController"); +const SERVICE_NAME = "JobQueueController"; + +const { errorMessages, successMessages } = require("../../utils/messages"); +const sinon = require("sinon"); + +describe("Queue Controller - getMetrics", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + getMetrics: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should throw an error if getMetrics throws an error", async () => { + req.jobQueue.getMetrics.throws(new Error("getMetrics error")); + await getMetrics(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getMetrics error"); + }); + + it("should return a success message and data if getMetrics is successful", async () => { + const data = { data: "metrics" }; + req.jobQueue.getMetrics.returns(data); + await getMetrics(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data, + }); + }); +}); + +describe("Queue Controller - getJobs", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + getJobStats: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if getJobs throws an error", async () => { + req.jobQueue.getJobStats.throws(new Error("getJobs error")); + await getJobs(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getJobs error"); + }); + it("should return a success message and data if getJobs is successful", async () => { + const data = { data: "jobs" }; + req.jobQueue.getJobStats.returns(data); + await getJobs(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data, + }); + }); +}); + +describe("Queue Controller - addJob", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if addJob throws an error", async () => { + req.jobQueue.addJob.throws(new Error("addJob error")); + await addJob(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("addJob error"); + }); + it("should return a success message if addJob is successful", async () => { + req.jobQueue.addJob.resolves(); + await addJob(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_ADD_JOB, + }); + }); +}); + +describe("Queue Controller - obliterateQueue", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + obliterate: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if obliterateQueue throws an error", async () => { + req.jobQueue.obliterate.throws(new Error("obliterateQueue error")); + await obliterateQueue(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("obliterateQueue error"); + }); + it("should return a success message if obliterateQueue is successful", async () => { + req.jobQueue.obliterate.resolves(); + await obliterateQueue(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_OBLITERATE, + }); + }); +}); diff --git a/Server/tests/controllers/settingsController.test.js b/Server/tests/controllers/settingsController.test.js new file mode 100644 index 000000000..b06565f38 --- /dev/null +++ b/Server/tests/controllers/settingsController.test.js @@ -0,0 +1,105 @@ +const { afterEach } = require("node:test"); +const { + getAppSettings, + updateAppSettings, +} = require("../../controllers/settingsController"); + +const { errorMessages, successMessages } = require("../../utils/messages"); +const sinon = require("sinon"); + +describe("Settings Controller - getAppSettings", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should throw an error if getSettings throws an error", async () => { + req.settingsService.getSettings.throws(new Error("getSettings error")); + await getAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getSettings error"); + }); + + it("should return a success message and data if getSettings is successful", async () => { + const data = { data: "settings" }; + req.settingsService.getSettings.returns(data); + await getAppSettings(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.GET_APP_SETTINGS, + data, + }); + }); +}); + +describe("Settings Controller - updateAppSettings", () => { + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + db: { + updateAppSettings: sinon.stub(), + }, + settingsService: { + reloadSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if body validation fails", async () => { + req.body = { invalid: 1 }; + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + it("should reject with an error if updateAppSettings throws an error", async () => { + req.db.updateAppSettings.throws(new Error("updateAppSettings error")); + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("updateAppSettings error"); + }); + it("should reject with an error if reloadSettings throws an error", async () => { + req.settingsService.reloadSettings.throws( + new Error("reloadSettings error") + ); + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("reloadSettings error"); + }); + it("should return a success message and data if updateAppSettings is successful", async () => { + const data = { data: "settings" }; + req.settingsService.reloadSettings.returns(data); + await updateAppSettings(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.UPDATE_APP_SETTINGS, + data, + }); + }); +}); diff --git a/Server/utils/messages.js b/Server/utils/messages.js index 16b2f7883..d685e7326 100644 --- a/Server/utils/messages.js +++ b/Server/utils/messages.js @@ -86,6 +86,12 @@ const successMessages = { MONITOR_CERTIFICATE: "Got monitor certificate successfully", MONITOR_DEMO_ADDED: "Successfully added demo monitors", + // Queue Controller + QUEUE_GET_METRICS: "Got metrics successfully", + QUEUE_GET_METRICS: "Got job stats successfully", + QUEUE_ADD_JOB: "Job added successfully", + QUEUE_OBLITERATE: "Queue obliterated", + //Job Queue JOB_QUEUE_DELETE_JOB: "Job removed successfully", JOB_QUEUE_OBLITERATE: "Queue OBLITERATED!!!", diff --git a/Server/validation/joi.js b/Server/validation/joi.js index 3133da80a..9e36c2135 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -10,8 +10,8 @@ const { start } = require("repl"); const roleValidatior = (role) => (value, helpers) => { const hasRole = role.some((role) => value.includes(role)); if (!hasRole) { - throw new Joi.ValidationError( - `You do not have the required authorization. Required roles: ${roles.join(", ")}` + throw new joi.ValidationError( + `You do not have the required authorization. Required roles: ${role.join(", ")}` ); } return value;