From 4a95e16fc835005c3f3842849d4ca852b5c3933e Mon Sep 17 00:00:00 2001 From: iamitprakash Date: Fri, 16 Jan 2026 13:36:03 +0530 Subject: [PATCH] feat: Implement restricted user status self-update endpoint, preventing direct modification of state and until fields, and update the config package. --- middlewares/validators/userStatus.js | 50 ++++++++-- models/userStatus.js | 3 + routes/userStatus.js | 3 +- .../userStatusSelfRestricted.test.js | 95 +++++++++++++++++++ 4 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 test/integration/userStatusSelfRestricted.test.js diff --git a/middlewares/validators/userStatus.js b/middlewares/validators/userStatus.js index 926e38feb7..39a8fbc568 100644 --- a/middlewares/validators/userStatus.js +++ b/middlewares/validators/userStatus.js @@ -2,6 +2,12 @@ const Joi = require("joi"); const { userState, CANCEL_OOO } = require("../../constants/userStatus"); const threeDaysInMilliseconds = 172800000; +const cancelOooSchema = Joi.object() + .keys({ + cancelOoo: Joi.boolean().valid(true).required(), + }) + .unknown(false); + const validateUserStatusData = async (todaysTime, req, res, next) => { const validUserStates = [userState.OOO, userState.ONBOARDING]; @@ -58,12 +64,6 @@ const validateUserStatusData = async (todaysTime, req, res, next) => { }), }); - const cancelOooSchema = Joi.object() - .keys({ - cancelOoo: Joi.boolean().valid(true).required(), - }) - .unknown(false); - let schema; try { if (Object.keys(req.body).includes(CANCEL_OOO)) { @@ -86,6 +86,43 @@ const validateUserStatus = (req, res, next) => { validateUserStatusData(todaysTime, req, res, next); }; +const validateUserStatusSelf = async (req, res, next) => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const todaysTime = today.getTime(); + + const selfStatusSchema = Joi.object({ + currentStatus: Joi.object().keys({ + state: Joi.forbidden().error(new Error("Updating 'state' is not allowed via this endpoint.")), + until: Joi.forbidden().error(new Error("Updating 'until' is not allowed via this endpoint.")), + updatedAt: Joi.number().required(), + from: Joi.number() + .min(todaysTime) + .required() + .error(new Error(`The 'from' field must have a value that is either today or a date that follows today.`)), + message: Joi.string().allow("").optional(), + }), + monthlyHours: Joi.object().keys({ + committed: Joi.number().required(), + updatedAt: Joi.number().required(), + }), + }); + + let schema; + try { + if (Object.keys(req.body).includes(CANCEL_OOO)) { + schema = cancelOooSchema; + } else { + schema = selfStatusSchema; + } + await schema.validateAsync(req.body); + next(); + } catch (error) { + logger.error(`Error validating UserStatus ${error}`); + res.boom.badRequest(error); + } +}; + const validateMassUpdate = async (req, res, next) => { const schema = Joi.object() .keys({ @@ -139,4 +176,5 @@ module.exports = { validateUserStatus, validateMassUpdate, validateGetQueryParams, + validateUserStatusSelf, }; diff --git a/models/userStatus.js b/models/userStatus.js index 85c8112b9a..0a188a20be 100644 --- a/models/userStatus.js +++ b/models/userStatus.js @@ -219,6 +219,9 @@ const updateUserStatus = async (userId, updatedStatusData) => { const previousUntil = previousCurrentStatus.until; let requestedNextState; if (Object.keys(newStatusData).includes("currentStatus")) { + if (!newStatusData.currentStatus.state && previousCurrentStatus.state) { + newStatusData.currentStatus = { ...previousCurrentStatus, ...newStatusData.currentStatus }; + } requestedNextState = newStatusData.currentStatus?.state; const newUserState = requestedNextState; const isNewStateOoo = newUserState === userState.OOO; diff --git a/routes/userStatus.js b/routes/userStatus.js index cbc5c7ce26..2c20d32766 100644 --- a/routes/userStatus.js +++ b/routes/userStatus.js @@ -16,6 +16,7 @@ const { validateUserStatus, validateMassUpdate, validateGetQueryParams, + validateUserStatusSelf, } = require("../middlewares/validators/userStatus"); const { authorizeAndAuthenticate } = require("../middlewares/authorizeUsersAndService"); const ROLES = require("../constants/roles"); @@ -24,7 +25,7 @@ const { Services } = require("../constants/bot"); router.get("/", validateGetQueryParams, getUserStatusControllers); router.get("/self", authenticate, getUserStatus); router.get("/:userId", getUserStatus); -router.patch("/self", authenticate, validateUserStatus, updateUserStatusController); // this route is being deprecated, please use /users/status/:userId PATCH endpoint instead. +router.patch("/self", authenticate, validateUserStatusSelf, updateUserStatusController); // this route is being deprecated, please use /users/status/:userId PATCH endpoint instead. router.patch("/update", authorizeAndAuthenticate([ROLES.SUPERUSER], [Services.CRON_JOB_HANDLER]), updateAllUserStatus); router.patch( "/batch", diff --git a/test/integration/userStatusSelfRestricted.test.js b/test/integration/userStatusSelfRestricted.test.js new file mode 100644 index 0000000000..a03802da2a --- /dev/null +++ b/test/integration/userStatusSelfRestricted.test.js @@ -0,0 +1,95 @@ +const chai = require("chai"); +const { expect } = chai; +const chaiHttp = require("chai-http"); +const app = require("../../server"); +const authService = require("../../services/authService"); +const addUser = require("../utils/addUser"); +const cleanDb = require("../utils/cleanDb"); +const { updateUserStatus } = require("../../models/userStatus"); +const { generateUserStatusData } = require("../fixtures/userStatus/userStatus"); +const config = require("config"); +const cookieName = config.get("userToken.cookieName"); +const firestore = require("../../utils/firestore"); + +chai.use(chaiHttp); + +describe("Restricted PATCH /users/status/self", function () { + let jwt; + let userId = ""; + + beforeEach(async function () { + userId = await addUser(); + jwt = authService.generateAuthToken({ userId }); + const initialStatus = generateUserStatusData("OOO", Date.now(), Date.now(), Date.now() + 86400000); + await updateUserStatus(userId, initialStatus); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("Should return 400 when trying to update 'state'", async function () { + const res = await chai + .request(app) + .patch("/users/status/self") + .set("cookie", `${cookieName}=${jwt}`) + .send({ + currentStatus: { + state: "ACTIVE", + updatedAt: Date.now(), + from: Date.now(), + }, + }); + + expect(res).to.have.status(400); + expect(res.body.error).to.equal("Bad Request"); + expect(res.body.message).to.include("Updating 'state' is not allowed via this endpoint"); + }); + + it("Should return 400 when trying to update 'until'", async function () { + const res = await chai + .request(app) + .patch("/users/status/self") + .set("cookie", `${cookieName}=${jwt}`) + .send({ + currentStatus: { + until: Date.now() + 100000, + updatedAt: Date.now(), + from: Date.now(), + }, + }); + + expect(res).to.have.status(400); + expect(res.body.error).to.equal("Bad Request"); + expect(res.body.message).to.include("Updating 'until' is not allowed via this endpoint"); + }); + + it("Should allow updating other fields (message) and preserve state/until", async function () { + // Current state is OOO. We update message. + const newMessage = "Updated message via restricted endpoint"; + const now = Date.now(); + + const res = await chai + .request(app) + .patch("/users/status/self") + .set("cookie", `${cookieName}=${jwt}`) + .send({ + currentStatus: { + message: newMessage, + updatedAt: now, + from: now, + }, + }); + + expect(res).to.have.status(200); + expect(res.body.message).to.equal("User Status updated successfully."); + + // Verify persistence + const doc = await firestore.collection("usersStatus").where("userId", "==", userId).get(); + const data = doc.docs[0].data(); + + expect(data.currentStatus.state).to.equal("OOO"); + expect(data.currentStatus.message).to.equal(newMessage); + expect(data.currentStatus.until).to.not.equal(undefined); + }); +});