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/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/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/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/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/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, };