diff --git a/Client/package-lock.json b/Client/package-lock.json index 83ffa6cfa..cbe559889 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -6275,9 +6275,9 @@ } }, "node_modules/vite": { - "version": "5.4.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", - "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", diff --git a/Server/.nycrc b/Server/.nycrc index 159e02a0d..96f20d795 100644 --- a/Server/.nycrc +++ b/Server/.nycrc @@ -1,11 +1,6 @@ { "all": true, - "include": [ - "controllers/*.js", - "utils/*.js", - "service/*.js", - "db/mongo/modules/monitorModule.js" - ], + "include": ["controllers/*.js", "utils/*.js", "service/*.js", "db/mongo/modules/*.js"], "exclude": ["**/*.test.js"], "reporter": ["html", "text", "lcov"], "sourceMap": false, diff --git a/Server/controllers/statusPageController.js b/Server/controllers/statusPageController.js new file mode 100644 index 000000000..facac7c4a --- /dev/null +++ b/Server/controllers/statusPageController.js @@ -0,0 +1,51 @@ +import { handleError, handleValidationError } from "./controllerUtils.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, +} from "../validation/joi.js"; +import { successMessages } from "../utils/messages.js"; + +const SERVICE_NAME = "statusPageController"; + +const createStatusPage = async (req, res, next) => { + try { + await createStatusPageBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const statusPage = await req.db.createStatusPage(req.body); + return res.status(200).json({ + success: true, + msg: successMessages.STATUS_PAGE_CREATE, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createStatusPage")); + } +}; + +const getStatusPageByUrl = async (req, res, next) => { + try { + await getStatusPageParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const { url } = req.params; + const statusPage = await req.db.getStatusPageByUrl(url); + return res.status(200).json({ + success: true, + msg: successMessages.STATUS_PAGE_BY_URL, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getStatusPage")); + } +}; + +export { createStatusPage, getStatusPageByUrl }; diff --git a/Server/db/models/HardwareCheck.js b/Server/db/models/HardwareCheck.js index 75e4ac584..527f226b6 100644 --- a/Server/db/models/HardwareCheck.js +++ b/Server/db/models/HardwareCheck.js @@ -30,6 +30,11 @@ const hostSchema = mongoose.Schema({ kernel_version: { type: String, default: "" }, }); +const errorSchema = mongoose.Schema({ + metric: { type: [String], default: [] }, + err: { type: String, default: "" }, +}); + const HardwareCheckSchema = mongoose.Schema( { ...BaseCheckSchema.obj, @@ -49,6 +54,10 @@ const HardwareCheckSchema = mongoose.Schema( type: hostSchema, default: () => ({}), }, + errors: { + type: [errorSchema], + default: () => [], + }, }, { timestamps: true } ); diff --git a/Server/db/models/StatusPage.js b/Server/db/models/StatusPage.js new file mode 100644 index 000000000..1169d4661 --- /dev/null +++ b/Server/db/models/StatusPage.js @@ -0,0 +1,42 @@ +import mongoose from "mongoose"; + +const StatusPageSchema = mongoose.Schema( + { + companyName: { + type: String, + required: true, + default: "", + }, + url: { + type: String, + unique: true, + required: true, + default: "", + }, + timezone: { + type: String, + required: true, + default: "America/Toronto", + }, + color: { + type: String, + required: true, + default: "#4169E1", + }, + theme: { + type: String, + required: true, + default: "light", + }, + monitors: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + required: true, + }, + ], + }, + { timestamps: true } +); + +export default mongoose.model("StatusPage", StatusPageSchema); diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js index 581a9e588..bab17c5ef 100644 --- a/Server/db/mongo/MongoDB.js +++ b/Server/db/mongo/MongoDB.js @@ -169,6 +169,11 @@ import { //**************************************** import { getAppSettings, updateAppSettings } from "./modules/settingsModule.js"; +//**************************************** +// Status Page +//**************************************** +import { createStatusPage, getStatusPageByUrl } from "./modules/statusPageModule.js"; + export default { connect, disconnect, @@ -223,4 +228,6 @@ export default { deleteNotificationsByMonitorId, getAppSettings, updateAppSettings, + createStatusPage, + getStatusPageByUrl, }; diff --git a/Server/db/mongo/modules/inviteModule.js b/Server/db/mongo/modules/inviteModule.js index 63636be86..0cbf0ab04 100644 --- a/Server/db/mongo/modules/inviteModule.js +++ b/Server/db/mongo/modules/inviteModule.js @@ -78,7 +78,7 @@ const getInviteTokenAndDelete = async (token) => { return invite; } catch (error) { error.service = SERVICE_NAME; - error.method = "getInviteToken"; + error.method = "getInviteTokenAndDelete"; throw error; } }; diff --git a/Server/db/mongo/modules/maintenanceWindowModule.js b/Server/db/mongo/modules/maintenanceWindowModule.js index e2488dc3b..072f83c3d 100644 --- a/Server/db/mongo/modules/maintenanceWindowModule.js +++ b/Server/db/mongo/modules/maintenanceWindowModule.js @@ -203,7 +203,7 @@ const deleteMaintenanceWindowByUserId = async (userId) => { const editMaintenanceWindowById = async (maintenanceWindowId, maintenanceWindowData) => { try { - const editedMaintenanceWindow = MaintenanceWindow.findByIdAndUpdate( + const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate( maintenanceWindowId, maintenanceWindowData, { new: true } diff --git a/Server/db/mongo/modules/statusPageModule.js b/Server/db/mongo/modules/statusPageModule.js new file mode 100644 index 000000000..194287448 --- /dev/null +++ b/Server/db/mongo/modules/statusPageModule.js @@ -0,0 +1,40 @@ +import StatusPage from "../../models/StatusPage.js"; +import { errorMessages } from "../../../utils/messages.js"; + +const SERVICE_NAME = "statusPageModule"; + +const createStatusPage = async (statusPageData) => { + try { + const statusPage = new StatusPage({ ...statusPageData }); + await statusPage.save(); + return statusPage; + } catch (error) { + if (error?.code === 11000) { + // Handle duplicate URL errors + error.status = 400; + error.message = errorMessages.STATUS_PAGE_URL_NOT_UNIQUE; + } + error.service = SERVICE_NAME; + error.method = "createStatusPage"; + throw error; + } +}; + +const getStatusPageByUrl = async (url) => { + try { + const statusPage = await StatusPage.findOne({ url }); + if (statusPage === null || statusPage === undefined) { + const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND); + error.status = 404; + + throw error; + } + return statusPage; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPageByUrl"; + throw error; + } +}; + +export { createStatusPage, getStatusPageByUrl }; diff --git a/Server/index.js b/Server/index.js index e12d83a18..11b5b2151 100644 --- a/Server/index.js +++ b/Server/index.js @@ -14,6 +14,7 @@ import monitorRouter from "./routes/monitorRoute.js"; import checkRouter from "./routes/checkRoute.js"; import maintenanceWindowRouter from "./routes/maintenanceWindowRoute.js"; import settingsRouter from "./routes/settingsRoute.js"; +import statusPageRouter from "./routes/statusPageRoute.js"; import { fileURLToPath } from "url"; import queueRouter from "./routes/queueRoute.js"; @@ -88,6 +89,7 @@ const startApp = async () => { app.use("/api/v1/checks", verifyJWT, checkRouter); app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRouter); app.use("/api/v1/queue", verifyJWT, queueRouter); + app.use("/api/v1/status-page", statusPageRouter); //health check app.use("/api/v1/healthy", (req, res) => { diff --git a/Server/openapi.json b/Server/openapi.json index 5d2d512e6..7ba2fc222 100644 --- a/Server/openapi.json +++ b/Server/openapi.json @@ -84,6 +84,10 @@ { "name": "queue", "description": "Queue" + }, + { + "name": "status-page", + "description": "Status Page" } ], "paths": { @@ -2239,6 +2243,118 @@ } ] } + }, + "/status-page/{url}": { + "get": { + "tags": ["status-page"], + "description": "Get a status page by URL", + "parameters": [ + { + "name": "url", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + } + }, + "post": { + "tags": ["status-page"], + "description": "Create a status page", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateStatusPageBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Duplicate URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -2425,6 +2541,21 @@ "format": "date-time" } } + }, + "CreateStatusPageBody": { + "type": "object", + "required": ["companyName", "url", "timezone", "color", "theme", "monitors"], + "properties": { + "companyName": { "type": "string" }, + "url": { "type": "string" }, + "timezone": { "type": "string" }, + "color": { "type": "string" }, + "theme": { "type": "string" }, + "monitors": { + "type": "array", + "items": { "type": "string" } + } + } } } } diff --git a/Server/package-lock.json b/Server/package-lock.json index 53278a0e6..d03515453 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -12,7 +12,7 @@ "@sendgrid/mail": "^8.1.3", "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.25.4", + "bullmq": "5.25.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -1164,9 +1164,9 @@ "license": "MIT" }, "node_modules/bullmq": { - "version": "5.25.4", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.4.tgz", - "integrity": "sha512-f9M5qfFOg9hdoMWmux9x9rZm9ZUPTMFfdDMO2zRsi7IOzgvZ0UxB6oTk77PlC9YSDYoufAgBw82xU1nwvnsKSA==", + "version": "5.25.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.6.tgz", + "integrity": "sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==", "license": "MIT", "dependencies": { "cron-parser": "^4.6.0", diff --git a/Server/package.json b/Server/package.json index 8e822c024..16e0e9d1c 100644 --- a/Server/package.json +++ b/Server/package.json @@ -15,7 +15,7 @@ "@sendgrid/mail": "^8.1.3", "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.25.4", + "bullmq": "5.25.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/Server/routes/statusPageRoute.js b/Server/routes/statusPageRoute.js new file mode 100644 index 000000000..6300723f4 --- /dev/null +++ b/Server/routes/statusPageRoute.js @@ -0,0 +1,11 @@ +import express from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import { + createStatusPage, + getStatusPageByUrl, +} from "../controllers/statusPageController.js"; +const router = express.Router(); + +router.get("/:url", getStatusPageByUrl); +router.post("/:url", verifyJWT, createStatusPage); +export default router; diff --git a/Server/service/statusService.js b/Server/service/statusService.js index 64705e122..22254d050 100644 --- a/Server/service/statusService.js +++ b/Server/service/statusService.js @@ -83,6 +83,7 @@ class StatusService { responseTime, message, }; + if (type === "pagespeed") { const categories = payload.lighthouseResult?.categories; const audits = payload.lighthouseResult?.audits; @@ -101,10 +102,13 @@ class StatusService { } if (type === "hardware") { - check.cpu = payload?.cpu ?? {}; - check.memory = payload?.memory ?? {}; - check.disk = payload?.disk ?? {}; - check.host = payload?.host ?? {}; + const { cpu, memory, disk, host } = payload?.data ?? {}; + const { errors } = payload; + check.cpu = cpu ?? {}; + check.memory = memory ?? {}; + check.disk = disk ?? {}; + check.host = host ?? {}; + check.errors = errors ?? []; } return check; }; @@ -131,6 +135,7 @@ class StatusService { hardware: this.db.createHardwareCheck, }; const operation = operationMap[networkResponse.type]; + const check = this.buildCheck(networkResponse); await operation(check); } catch (error) { diff --git a/Server/tests/controllers/statusPageController.test.js b/Server/tests/controllers/statusPageController.test.js new file mode 100644 index 000000000..0031e73df --- /dev/null +++ b/Server/tests/controllers/statusPageController.test.js @@ -0,0 +1,128 @@ +import sinon from "sinon"; +import { + createStatusPage, + getStatusPageByUrl, +} from "../../controllers/statusPageController.js"; + +describe("statusPageController", () => { + let req, res, next; + beforeEach(() => { + req = { + params: {}, + body: {}, + db: { + createStatusPage: sinon.stub(), + getStatusPageByUrl: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("createStatusPage", () => { + beforeEach(() => { + req.body = { + companyName: "Test Company", + url: "123456", + timezone: "America/Toronto", + color: "#000000", + theme: "light", + monitors: ["67309ca673788e808884c8ac", "67309ca673788e808884c8ac"], + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should handle a validation error", async () => { + req.body = { + // Invalid data that will trigger validation error + companyName: "", + url: "", + timezone: "", + color: "invalid", + theme: "invalid", + monitors: ["invalid-id"], + }; + try { + await createStatusPage(req, res, next); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Validation error"); + } + }); + + it("should handle a db error", async () => { + const err = new Error("DB error"); + req.db.createStatusPage.throws(err); + + try { + await createStatusPage(req, res, next); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should insert a properly formatted status page", async () => { + const result = await createStatusPage(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0].success).to.be.true; + }); + }); + + describe("getStatusPageByUrl", () => { + beforeEach(() => { + req.params = { + url: "123456", + }; + }); + afterEach(() => { + sinon.restore(); + }); + + it("should handle a validation error", async () => { + req.params = { + url: "", + }; + + try { + await getStatusPageByUrl(req, res, next); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Validation error"); + } + }); + + it("should handle a DB error", async () => { + const err = new Error("DB error"); + req.db.getStatusPageByUrl.throws(err); + + try { + await getStatusPageByUrl(req, res, next); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should return a status page", async () => { + const statusPage = { + _id: "123456", + companyName: "Test Company", + url: "123456", + }; + req.db.getStatusPageByUrl.resolves(statusPage); + const result = await getStatusPageByUrl(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0].success).to.be.true; + expect(res.json.firstCall.args[0].data).to.deep.equal(statusPage); + }); + }); +}); diff --git a/Server/tests/db/inviteModule.test.js b/Server/tests/db/inviteModule.test.js new file mode 100644 index 000000000..997352704 --- /dev/null +++ b/Server/tests/db/inviteModule.test.js @@ -0,0 +1,108 @@ +import sinon from "sinon"; +import InviteToken from "../../db/models/InviteToken.js"; +import { + requestInviteToken, + getInviteToken, + getInviteTokenAndDelete, +} from "../../db/mongo/modules/inviteModule.js"; +import { errorMessages } from "../../utils/messages.js"; + +describe("Invite Module", () => { + const mockUserData = { + email: "test@test.com", + teamId: "123", + role: ["admin"], + token: "123", + }; + const mockInviteToken = { _id: 123, time: 123 }; + let inviteTokenDeleteManyStub, + inviteTokenSaveStub, + inviteTokenFindOneStub, + inviteTokenFindOneAndDeleteStub; + beforeEach(() => { + inviteTokenDeleteManyStub = sinon.stub(InviteToken, "deleteMany"); + inviteTokenSaveStub = sinon.stub(InviteToken.prototype, "save"); + inviteTokenFindOneStub = sinon.stub(InviteToken, "findOne"); + inviteTokenFindOneAndDeleteStub = sinon.stub(InviteToken, "findOneAndDelete"); + }); + + afterEach(() => { + sinon.restore(); + }); + describe("requestInviteToken", () => { + it("should return a new invite token", async () => { + inviteTokenDeleteManyStub.resolves(); + inviteTokenSaveStub.resolves(); + const inviteToken = await requestInviteToken(mockUserData); + expect(inviteToken.email).to.equal(mockUserData.email); + expect(inviteToken.role).to.deep.equal(mockUserData.role); + expect(inviteToken.token).to.exist; + }); + + it("should handle an error", async () => { + const err = new Error("test error"); + inviteTokenDeleteManyStub.rejects(err); + try { + await requestInviteToken(mockUserData); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getInviteToken", () => { + it("should return an invite token", async () => { + inviteTokenFindOneStub.resolves(mockInviteToken); + const inviteToken = await getInviteToken(mockUserData.token); + expect(inviteToken).to.deep.equal(mockInviteToken); + }); + + it("should handle a token not found", async () => { + inviteTokenFindOneStub.resolves(null); + try { + await getInviteToken(mockUserData.token); + } catch (error) { + expect(error.message).to.equal(errorMessages.AUTH_INVITE_NOT_FOUND); + } + }); + + it("should handle DB errors", async () => { + const err = new Error("test error"); + inviteTokenFindOneStub.rejects(err); + try { + await getInviteToken(mockUserData.token); + } catch (error) { + expect(error).to.deep.equal(err); + expect(error.method).to.equal("getInviteToken"); + } + }); + }); + + describe("getInviteTokenAndDelete", () => { + it("should return a deleted invite", async () => { + inviteTokenFindOneAndDeleteStub.resolves(mockInviteToken); + const deletedInvite = await getInviteTokenAndDelete(mockUserData.token); + expect(deletedInvite).to.deep.equal(mockInviteToken); + }); + + it("should handle a token not found", async () => { + inviteTokenFindOneAndDeleteStub.resolves(null); + try { + await getInviteTokenAndDelete(mockUserData.token); + } catch (error) { + expect(error.message).to.equal(errorMessages.AUTH_INVITE_NOT_FOUND); + } + }); + + it("should handle DB errors", async () => { + const err = new Error("test error"); + inviteTokenFindOneAndDeleteStub.rejects(err); + try { + await getInviteTokenAndDelete(mockUserData.token); + } catch (error) { + expect(error).to.deep.equal(err); + expect(error.method).to.equal("getInviteTokenAndDelete"); + } + }); + }); +}); diff --git a/Server/tests/db/maintenanceWindowModule.test.js b/Server/tests/db/maintenanceWindowModule.test.js new file mode 100644 index 000000000..094d9a3e8 --- /dev/null +++ b/Server/tests/db/maintenanceWindowModule.test.js @@ -0,0 +1,267 @@ +import sinon from "sinon"; +import MaintenanceWindow from "../../db/models/MaintenanceWindow.js"; +import { + createMaintenanceWindow, + getMaintenanceWindowById, + getMaintenanceWindowsByTeamId, + getMaintenanceWindowsByMonitorId, + deleteMaintenanceWindowById, + deleteMaintenanceWindowByMonitorId, + deleteMaintenanceWindowByUserId, + editMaintenanceWindowById, +} from "../../db/mongo/modules/maintenanceWindowModule.js"; + +describe("MaintenanceWindow Module", () => { + const mockMaintenanceWindow = { + monitorId: "123", + active: true, + oneTime: true, + start: 1, + end: 20000, + }; + + let mockMaintenanceWindows = [mockMaintenanceWindow]; + let maintenanceWindowSaveStub, + maintenanceWindowFindByIdStub, + maintenanceWindowCountDocumentsStub, + maintenanceWindowFindStub, + maintenanceWindowFindByIdAndDeleteStub, + maintenanceWindowDeleteManyStub, + maintenanceWindowFindByIdAndUpdateStub; + + beforeEach(() => { + maintenanceWindowSaveStub = sinon.stub(MaintenanceWindow.prototype, "save"); + maintenanceWindowFindByIdStub = sinon.stub(MaintenanceWindow, "findById"); + maintenanceWindowCountDocumentsStub = sinon.stub(MaintenanceWindow, "countDocuments"); + maintenanceWindowFindStub = sinon.stub(MaintenanceWindow, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMaintenanceWindows), + }), + }), + }); + maintenanceWindowFindByIdAndDeleteStub = sinon.stub( + MaintenanceWindow, + "findByIdAndDelete" + ); + maintenanceWindowDeleteManyStub = sinon.stub(MaintenanceWindow, "deleteMany"); + maintenanceWindowFindByIdAndUpdateStub = sinon.stub( + MaintenanceWindow, + "findByIdAndUpdate" + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("createMaintenanceWindow", () => { + it("should save a new maintenance window", async () => { + maintenanceWindowSaveStub.resolves(mockMaintenanceWindow); + const result = await createMaintenanceWindow(mockMaintenanceWindow); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowSaveStub.rejects(err); + try { + await createMaintenanceWindow(mockMaintenanceWindow); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("getMaintenanceWindowById", () => { + it("should return a maintenance window", async () => { + maintenanceWindowFindByIdStub.resolves(mockMaintenanceWindow); + const result = await getMaintenanceWindowById(mockMaintenanceWindow.id); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowFindByIdStub.rejects(err); + try { + await getMaintenanceWindowById(mockMaintenanceWindow.id); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getMaintenanceWindowsByTeamId", () => { + let query; + beforeEach(() => { + query = { + active: true, + page: 1, + rowsPerPage: 10, + field: "name", + order: "asc", + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return a list of maintenance windows and count", async () => { + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + it("should return a list of maintenance windows and count with empty query", async () => { + query = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + it("should return a list of maintenance windows and count with no pagination provided", async () => { + query.page = undefined; + query.rowsPerPage = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + it("should return a list of maintenance windows and count with field and desc order", async () => { + query.order = "desc"; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + it("should return a list of maintenance windows and count no field", async () => { + query.field = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowCountDocumentsStub.rejects(err); + try { + await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("getMaintenanceWindowsByMonitorId", () => { + it("should return a list of maintenance windows", async () => { + maintenanceWindowFindStub.resolves(mockMaintenanceWindows); + const result = await getMaintenanceWindowsByMonitorId( + mockMaintenanceWindow.monitorId + ); + expect(result).to.deep.equal(mockMaintenanceWindows); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowFindStub.rejects(err); + try { + await getMaintenanceWindowsByMonitorId(mockMaintenanceWindow.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("deleteMaintenanceWindowById", () => { + it("should delete a maintenance window", async () => { + maintenanceWindowFindByIdAndDeleteStub.resolves(mockMaintenanceWindow); + const result = await deleteMaintenanceWindowById(mockMaintenanceWindow.id); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowFindByIdAndDeleteStub.rejects(err); + try { + await deleteMaintenanceWindowById(mockMaintenanceWindow.id); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteMaintenanceWindowByMonitorId", () => { + it("should return the number of documents deleted", async () => { + maintenanceWindowDeleteManyStub.resolves({ deletedCount: 1 }); + const result = await deleteMaintenanceWindowByMonitorId( + mockMaintenanceWindow.monitorId + ); + expect(result).to.deep.equal({ deletedCount: 1 }); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowDeleteManyStub.rejects(err); + try { + await deleteMaintenanceWindowByMonitorId(mockMaintenanceWindow.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteMaintenanceWindowByUserId", () => { + it("should return the number of documents deleted", async () => { + maintenanceWindowDeleteManyStub.resolves({ deletedCount: 1 }); + const result = await deleteMaintenanceWindowByUserId(mockMaintenanceWindow.userId); + expect(result).to.deep.equal({ deletedCount: 1 }); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowDeleteManyStub.rejects(err); + try { + await deleteMaintenanceWindowByUserId(mockMaintenanceWindow.userId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("editMaintenanceWindowById", () => { + it("should return the updated maintenance window", async () => { + maintenanceWindowFindByIdAndUpdateStub.resolves(mockMaintenanceWindow); + const result = await editMaintenanceWindowById( + mockMaintenanceWindow.id, + mockMaintenanceWindow + ); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + maintenanceWindowFindByIdAndUpdateStub.rejects(err); + try { + await editMaintenanceWindowById(mockMaintenanceWindow.id, mockMaintenanceWindow); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/Server/tests/db/monitorModule.test.js b/Server/tests/db/monitorModule.test.js index 558dcb4d2..189c8a703 100644 --- a/Server/tests/db/monitorModule.test.js +++ b/Server/tests/db/monitorModule.test.js @@ -1,6 +1,8 @@ import sinon from "sinon"; import Monitor from "../../db/models/Monitor.js"; import Check from "../../db/models/Check.js"; +import PageSpeedCheck from "../../db/models/PageSpeedCheck.js"; +import HardwareCheck from "../../db/models/HardwareCheck.js"; import Notification from "../../db/models/Notification.js"; import { errorMessages } from "../../utils/messages.js"; @@ -37,7 +39,9 @@ describe("monitorModule", () => { monitorDeleteManyStub, monitorCountStub, monitorInsertManyStub, - checkFindStub; + checkFindStub, + pageSpeedCheckFindStub, + hardwareCheckFindStub; beforeEach(() => { monitorFindStub = sinon.stub(Monitor, "find"); monitorFindByIdStub = sinon.stub(Monitor, "findById"); @@ -50,6 +54,12 @@ describe("monitorModule", () => { checkFindStub = sinon.stub(Check, "find").returns({ sort: sinon.stub(), }); + pageSpeedCheckFindStub = sinon.stub(PageSpeedCheck, "find").returns({ + sort: sinon.stub(), + }); + hardwareCheckFindStub = sinon.stub(HardwareCheck, "find").returns({ + sort: sinon.stub(), + }); }); afterEach(() => { sinon.restore(); @@ -137,6 +147,96 @@ describe("monitorModule", () => { expect(monitor["30"]).to.equal(75); expect(monitor["90"]).to.equal(75); }); + it("should return monitors with stats for pagespeed type", async () => { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "pagespeed", + toObject: () => ({ + _id: "monitor1", + type: "pagespeed", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + pageSpeedCheckFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); + it("should return monitors with stats for hardware type", async () => { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "hardware", + toObject: () => ({ + _id: "monitor1", + type: "hardware", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + hardwareCheckFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); it("should handle errors appropriately", async () => { // Setup stub to throw error diff --git a/Server/tests/db/notificationModule.test.js b/Server/tests/db/notificationModule.test.js new file mode 100644 index 000000000..ebe213731 --- /dev/null +++ b/Server/tests/db/notificationModule.test.js @@ -0,0 +1,77 @@ +import sinon from "sinon"; +import Notification from "../../db/models/Notification.js"; +import { + createNotification, + getNotificationsByMonitorId, + deleteNotificationsByMonitorId, +} from "../../db/mongo/modules/notificationModule.js"; + +describe("notificationModule", () => { + const mockNotification = { + monitorId: "123", + }; + const mockNotifications = [mockNotification]; + let notificationSaveStub, notificationFindStub, notificationDeleteManyStub; + + beforeEach(() => { + notificationSaveStub = sinon.stub(Notification.prototype, "save").resolves(); + notificationFindStub = sinon.stub(Notification, "find").resolves(); + notificationDeleteManyStub = sinon.stub(Notification, "deleteMany").resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("createNotification", () => { + it("should create a new notification", async () => { + const notificationData = { _id: "123", name: "test" }; + notificationSaveStub.resolves(notificationData); + const res = await createNotification(notificationData); + expect(res).to.deep.equal(notificationData); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + notificationSaveStub.rejects(err); + try { + await createNotification(mockNotification); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getNotificationsByMonitorId", () => { + it("should return notifications by monitor ID", async () => { + notificationFindStub.resolves(mockNotifications); + const res = await getNotificationsByMonitorId(mockNotification.monitorId); + expect(res).to.deep.equal(mockNotifications); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + notificationFindStub.rejects(err); + try { + await getNotificationsByMonitorId(mockNotification.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteNotificationsByMonitorId", () => { + it("should delete notifications by monitor ID", async () => { + notificationDeleteManyStub.resolves({ deletedCount: mockNotifications.length }); + const res = await deleteNotificationsByMonitorId(mockNotification.monitorId); + expect(res).to.deep.equal(mockNotifications.length); + }); + it("should handle an error", async () => { + const err = new Error("test error"); + notificationDeleteManyStub.rejects(err); + try { + await deleteNotificationsByMonitorId(mockNotification.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/Server/tests/db/statusPageModule.test.js b/Server/tests/db/statusPageModule.test.js new file mode 100644 index 000000000..8450ac5fc --- /dev/null +++ b/Server/tests/db/statusPageModule.test.js @@ -0,0 +1,67 @@ +import sinon from "sinon"; +import { + createStatusPage, + getStatusPageByUrl, +} from "../../db/mongo/modules/statusPageModule.js"; +import StatusPage from "../../db/models/StatusPage.js"; +import { errorMessages } from "../../utils/messages.js"; + +describe("statusPageModule", () => { + let statusPageFindOneStub, statusPageSaveStub, statusPageFindStub; + beforeEach(() => { + statusPageSaveStub = sinon.stub(StatusPage.prototype, "save"); + statusPageFindOneStub = sinon.stub(StatusPage, "findOne"); + statusPageFindStub = sinon.stub(StatusPage, "find"); + }); + afterEach(() => { + sinon.restore(); + }); + describe("createStatusPage", () => { + it("should throw an error if a non-unique url is provided", async () => { + statusPageFindOneStub.resolves(true); + try { + await createStatusPage({ url: "test" }); + } catch (error) { + expect(error.status).to.equal(400); + expect(error.message).to.equal(errorMessages.STATUS_PAGE_URL_NOT_UNIQUE); + } + }); + it("should handle duplicate URL errors", async () => { + const err = new Error("test"); + err.code = 11000; + statusPageSaveStub.rejects(err); + try { + await createStatusPage({ url: "test" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + it("should return a status page if a unique url is provided", async () => { + statusPageFindOneStub.resolves(null); + statusPageFindStub.resolves([]); + const mockStatusPage = { url: "test" }; + const statusPage = await createStatusPage(mockStatusPage); + expect(statusPage).to.exist; + expect(statusPage.url).to.equal(mockStatusPage.url); + }); + }); + describe("getStatusPageByUrl", () => { + it("should throw an error if a status page is not found", async () => { + statusPageFindOneStub.resolves(null); + try { + await getStatusPageByUrl("test"); + } catch (error) { + expect(error.status).to.equal(404); + expect(error.message).to.equal(errorMessages.STATUS_PAGE_NOT_FOUND); + } + }); + + it("should return a status page if a status page is found", async () => { + const mockStatusPage = { url: "test" }; + statusPageFindOneStub.resolves(mockStatusPage); + const statusPage = await getStatusPageByUrl(mockStatusPage.url); + expect(statusPage).to.exist; + expect(statusPage).to.deep.equal(mockStatusPage); + }); + }); +}); diff --git a/Server/utils/messages.js b/Server/utils/messages.js index dd7f0f76a..c75c617d2 100644 --- a/Server/utils/messages.js +++ b/Server/utils/messages.js @@ -51,6 +51,10 @@ const errorMessages = { // PING Operations PING_CANNOT_RESOLVE: "No response", + + // Status Page Errors + STATUS_PAGE_NOT_FOUND: "Status page not found", + STATUS_PAGE_URL_NOT_UNIQUE: "Status page url must be unique", }; const successMessages = { @@ -115,6 +119,10 @@ const successMessages = { // App Settings GET_APP_SETTINGS: "Got app settings successfully", UPDATE_APP_SETTINGS: "Updated app settings successfully", + + // Status Page + STATUS_PAGE_BY_URL: "Got status page by url successfully", + STATUS_PAGE_CREATE: "Status page created successfully", }; export { errorMessages, successMessages }; diff --git a/Server/validation/joi.js b/Server/validation/joi.js index 06804975e..07521c7e0 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -410,6 +410,32 @@ const updateAppSettingsBodyValidation = joi.object({ systemEmailPassword: joi.string().allow(""), }); +//**************************************** +// Status Page Validation +//**************************************** + +const getStatusPageParamValidation = joi.object({ + url: joi.string().required(), +}); + +const createStatusPageBodyValidation = joi.object({ + companyName: joi.string().required(), + url: joi.string().required(), + timezone: joi.string().required(), + color: joi.string().required(), + theme: joi.string().required(), + monitors: joi + .array() + .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) + .required() + .messages({ + "string.pattern.base": "Must be a valid monitor ID", + "array.base": "Monitors must be an array", + "array.empty": "At least one monitor is required", + "any.required": "Monitors are required", + }), +}); + export { roleValidatior, loginValidation, @@ -465,4 +491,6 @@ export { editMaintenanceWindowByIdParamValidation, editMaintenanceByIdWindowBodyValidation, updateAppSettingsBodyValidation, + createStatusPageBodyValidation, + getStatusPageParamValidation, };