Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions constants/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const APPLICATION_ERROR_MESSAGES = {
APPLICATION_ALREADY_REVIEWED: "Application has already been reviewed",
NUDGE_TOO_SOON: "Nudge unavailable. You'll be able to nudge again after 24 hours.",
NUDGE_ONLY_PENDING_ALLOWED: "Nudge unavailable. Only pending applications can be nudged.",
EDIT_TOO_SOON: "You can edit your application again after 24 hours from the last edit.",
EMPTY_UPDATE_PAYLOAD: "Update payload must contain at least one allowed field.",
};

const APPLICATION_LOG_MESSAGES = {
Expand Down
67 changes: 49 additions & 18 deletions controllers/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,61 @@ const addApplication = async (req: CustomRequest, res: CustomResponse) => {
}
};

const buildApplicationUpdatePayload = (body: Record<string, any>) => {
const dataToUpdate: Record<string, any> = {};

if (body.imageUrl !== undefined) dataToUpdate.imageUrl = body.imageUrl;
if (body.foundFrom !== undefined) dataToUpdate.foundFrom = body.foundFrom;
if (body.socialLink !== undefined) dataToUpdate.socialLink = body.socialLink;

if (body.introduction !== undefined) dataToUpdate["intro.introduction"] = body.introduction;
if (body.forFun !== undefined) dataToUpdate["intro.forFun"] = body.forFun;
if (body.funFact !== undefined) dataToUpdate["intro.funFact"] = body.funFact;
if (body.whyRds !== undefined) dataToUpdate["intro.whyRds"] = body.whyRds;
if (body.numberOfHours !== undefined) dataToUpdate["intro.numberOfHours"] = body.numberOfHours;

if (body.professional && typeof body.professional === "object") {
if (body.professional.institution !== undefined) dataToUpdate["professional.institution"] = body.professional.institution;
if (body.professional.skills !== undefined) dataToUpdate["professional.skills"] = body.professional.skills;
}

return dataToUpdate;
};

const updateApplication = async (req: CustomRequest, res: CustomResponse) => {
try {
const { applicationId } = req.params;
const rawBody = req.body;
const dataToUpdate = buildApplicationUpdatePayload(rawBody);
const userId = req.userData.id;

const applicationLog = {
type: logType.APPLICATION_UPDATED,
meta: {
applicationId,
username: req.userData.username,
userId: req.userData.id,
},
body: rawBody,
};

const promises = [
ApplicationModel.updateApplication(rawBody, applicationId),
addLog(applicationLog.type, applicationLog.meta, applicationLog.body),
];
const result = await ApplicationModel.updateApplication(dataToUpdate, applicationId, userId);

await Promise.all(promises);
return res.json({
message: "Application updated successfully!",
});
switch (result.status) {
case APPLICATION_STATUS.notFound:
return res.boom.notFound("Application not found");
case APPLICATION_STATUS.unauthorized:
return res.boom.unauthorized("You are not authorized to edit this application");
case APPLICATION_STATUS.tooSoon:
return res.boom.conflict(APPLICATION_ERROR_MESSAGES.EDIT_TOO_SOON);
case APPLICATION_STATUS.success: {
const applicationLog = {
type: logType.APPLICATION_UPDATED,
meta: {
applicationId,
username: req.userData.username,
userId,
},
body: rawBody,
};
await addLog(applicationLog.type, applicationLog.meta, applicationLog.body);
return res.json({
message: "Application updated successfully!",
});
}
default:
return res.boom.badImplementation(INTERNAL_SERVER_ERROR);
}
} catch (err) {
logger.error(`Error while updating the application: ${err}`);
return res.boom.badImplementation(INTERNAL_SERVER_ERROR);
Expand Down
65 changes: 64 additions & 1 deletion middlewares/validators/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const validateApplicationData = async (req: CustomRequest, res: CustomResponse,
}
};

const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => {
const validateApplicationFeedbackData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update this validator name as need separate validator that valid user update flow and this one will validate the feedback api flow

const schema = joi
.object({
status: joi
Expand Down Expand Up @@ -113,6 +113,68 @@ const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResp
}
};

const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => {
if (req.body.socialLink?.phoneNo) {
req.body.socialLink.phoneNo = req.body.socialLink.phoneNo.trim();
}

const socialLinkSchema = joi
.object({
phoneNo: joi.string().optional().regex(phoneNumberRegex).message('"phoneNo" must be in a valid format'),
github: joi.string().min(1).optional(),
instagram: joi.string().min(1).optional(),
linkedin: joi.string().min(1).optional(),
twitter: joi.string().min(1).optional(),
peerlist: joi.string().min(1).optional(),
behance: joi.string().min(1).optional(),
dribbble: joi.string().min(1).optional(),
})
.optional();

const professionalSchema = joi
.object({
institution: joi.string().min(1).optional(),
skills: joi.string().min(5).optional(),
})
.optional();

const schema = joi
.object()
.strict()
.min(1)
.keys({
imageUrl: joi.string().uri().optional(),
foundFrom: joi.string().min(1).optional(),
introduction: joi.string().min(1).optional(),
forFun: joi
.string()
.custom((value, helpers) => customWordCountValidator(value, helpers, 100))
.optional(),
funFact: joi
.string()
.custom((value, helpers) => customWordCountValidator(value, helpers, 100))
.optional(),
whyRds: joi
.string()
.custom((value, helpers) => customWordCountValidator(value, helpers, 100))
.optional(),
numberOfHours: joi.number().min(1).max(100).optional(),
professional: professionalSchema,
socialLink: socialLinkSchema,
})
.messages({
"object.min": "Update payload must contain at least one allowed field.",
});

try {
await schema.validateAsync(req.body);
next();
} catch (error) {
logger.error(`Error in validating application update data: ${error}`);
res.boom.badRequest(error.details[0].message);
}
};

const validateApplicationQueryParam = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => {
const schema = joi.object().strict().keys({
userId: joi.string().optional(),
Expand All @@ -133,6 +195,7 @@ const validateApplicationQueryParam = async (req: CustomRequest, res: CustomResp

module.exports = {
validateApplicationData,
validateApplicationFeedbackData,
validateApplicationUpdateData,
validateApplicationQueryParam,
};
49 changes: 42 additions & 7 deletions models/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,48 @@ const addApplication = async (data: application) => {
}
};

const updateApplication = async (dataToUpdate: object, applicationId: string) => {
try {
await ApplicationsModel.doc(applicationId).update(dataToUpdate);
} catch (err) {
logger.error("Error in updating intro", err);
throw err;
}
const updateApplication = async (
dataToUpdate: object,
applicationId: string,
userId: string
) => {
const currentTime = Date.now();
const twentyFourHoursInMilliseconds = convertDaysToMilliseconds(1);

const result = await firestore.runTransaction(async (transaction) => {
const applicationRef = ApplicationsModel.doc(applicationId);
const applicationDoc = await transaction.get(applicationRef);

if (!applicationDoc.exists) {
return { status: APPLICATION_STATUS.notFound };
}

const application = applicationDoc.data();

if (application.userId !== userId) {
return { status: APPLICATION_STATUS.unauthorized };
}

const lastEditAt = application.lastEditAt;
if (lastEditAt) {
const lastEditTimestamp = new Date(lastEditAt).getTime();
const timeDifference = currentTime - lastEditTimestamp;

if (timeDifference < twentyFourHoursInMilliseconds) {
return { status: APPLICATION_STATUS.tooSoon };
}
}

const requestBody = {
...dataToUpdate,
lastEditAt: new Date(currentTime).toISOString(),
};
transaction.update(applicationRef, requestBody);

return { status: APPLICATION_STATUS.success };
});

return result;
};

const nudgeApplication = async ({ applicationId, userId }: { applicationId: string; userId: string }) => {
Expand Down
3 changes: 2 additions & 1 deletion routes/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
);
router.get("/:applicationId", authenticate, authorizeRoles([SUPERUSER]), applications.getApplicationById);
router.post("/", authenticate, applicationValidator.validateApplicationData, applications.addApplication);
router.patch("/:applicationId", authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 1 day ago

In general, the problem is fixed by adding a rate-limiting middleware to routes that perform authenticated, potentially expensive operations (like updating an application). In an Express app, this is typically done by using a well-known library such as express-rate-limit and applying a limiter either to all routes in the router or specifically to sensitive routes (e.g., write operations).

For this file, the least intrusive and most effective fix is to:

  1. Import express-rate-limit.
  2. Define a limiter instance tailored for these routes (e.g., a reasonable per-IP cap over a time window).
  3. Insert that limiter as middleware in the relevant routes’ middleware chains.

To cover the flagged route and the other similar, authenticated write routes, we can define a single limiter and apply it to all modifying endpoints (POST /, PATCH /:applicationId, PATCH /:applicationId/feedback, and PATCH /:applicationId/nudge). This preserves existing authentication/authorization/validation and only adds an extra guard against abuse. Concretely in routes/applications.ts, we will:

  • Add const rateLimit = require("express-rate-limit"); near the top.
  • Define a limiter, e.g. const applicationWriteLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }); after the router is created.
  • Update the relevant route definitions to include applicationWriteLimiter before authenticate (or immediately after, either is acceptable as long as it runs early) in the middleware arrays.
Suggested changeset 2
routes/applications.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/routes/applications.ts b/routes/applications.ts
--- a/routes/applications.ts
+++ b/routes/applications.ts
@@ -5,9 +5,15 @@
 const applications = require("../controllers/applications");
 const { authorizeOwnOrSuperUser } = require("../middlewares/authorizeOwnOrSuperUser");
 const applicationValidator = require("../middlewares/validators/application");
+const rateLimit = require("express-rate-limit");
 
 const router = express.Router();
 
+const applicationWriteLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 100, // limit each IP to 100 write requests per windowMs
+});
+
 router.get(
   "/",
   authenticate,
@@ -16,15 +19,16 @@
   applications.getAllOrUserApplication
 );
 router.get("/:applicationId", authenticate, authorizeRoles([SUPERUSER]), applications.getApplicationById);
-router.post("/", authenticate, applicationValidator.validateApplicationData, applications.addApplication);
-router.patch("/:applicationId", authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication);
+router.post("/", applicationWriteLimiter, authenticate, applicationValidator.validateApplicationData, applications.addApplication);
+router.patch("/:applicationId", applicationWriteLimiter, authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication);
 router.patch(
   "/:applicationId/feedback",
+  applicationWriteLimiter,
   authenticate,
   authorizeRoles([SUPERUSER]),
   applicationValidator.validateApplicationFeedbackData,
   applications.submitApplicationFeedback
 );
-router.patch("/:applicationId/nudge", authenticate, applications.nudgeApplication);
+router.patch("/:applicationId/nudge", applicationWriteLimiter, authenticate, applications.nudgeApplication);
 
 module.exports = router;
EOF
@@ -5,9 +5,15 @@
const applications = require("../controllers/applications");
const { authorizeOwnOrSuperUser } = require("../middlewares/authorizeOwnOrSuperUser");
const applicationValidator = require("../middlewares/validators/application");
const rateLimit = require("express-rate-limit");

const router = express.Router();

const applicationWriteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 write requests per windowMs
});

router.get(
"/",
authenticate,
@@ -16,15 +19,16 @@
applications.getAllOrUserApplication
);
router.get("/:applicationId", authenticate, authorizeRoles([SUPERUSER]), applications.getApplicationById);
router.post("/", authenticate, applicationValidator.validateApplicationData, applications.addApplication);
router.patch("/:applicationId", authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication);
router.post("/", applicationWriteLimiter, authenticate, applicationValidator.validateApplicationData, applications.addApplication);
router.patch("/:applicationId", applicationWriteLimiter, authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication);
router.patch(
"/:applicationId/feedback",
applicationWriteLimiter,
authenticate,
authorizeRoles([SUPERUSER]),
applicationValidator.validateApplicationFeedbackData,
applications.submitApplicationFeedback
);
router.patch("/:applicationId/nudge", authenticate, applications.nudgeApplication);
router.patch("/:applicationId/nudge", applicationWriteLimiter, authenticate, applications.nudgeApplication);

module.exports = router;
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -42,7 +42,8 @@
     "passport-github2": "0.1.12",
     "passport-google-oauth20": "^2.0.0",
     "rate-limiter-flexible": "5.0.3",
-    "winston": "3.13.0"
+    "winston": "3.13.0",
+    "express-rate-limit": "^8.2.1"
   },
   "devDependencies": {
     "@types/chai": "4.3.16",
EOF
@@ -42,7 +42,8 @@
"passport-github2": "0.1.12",
"passport-google-oauth20": "^2.0.0",
"rate-limiter-flexible": "5.0.3",
"winston": "3.13.0"
"winston": "3.13.0",
"express-rate-limit": "^8.2.1"
},
"devDependencies": {
"@types/chai": "4.3.16",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.2.1 None
Copilot is powered by AI and may make mistakes. Always verify output.
router.patch(
"/:applicationId/feedback",
authenticate,
authorizeRoles([SUPERUSER]),
applicationValidator.validateApplicationUpdateData,
applicationValidator.validateApplicationFeedbackData,
applications.submitApplicationFeedback
);
router.patch("/:applicationId/nudge", authenticate, applications.nudgeApplication);
Expand Down
2 changes: 1 addition & 1 deletion test/integration/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ describe("Application", function () {
expect(res.body.nudgeCount).to.be.equal(1);

const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
applicationModel.updateApplication({ lastNudgeAt: twentyFiveHoursAgo }, nudgeApplicationId).then(() => {
applicationModel.updateApplication({ lastNudgeAt: twentyFiveHoursAgo }, nudgeApplicationId, userId).then(() => {
chai
Comment on lines 691 to 693
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Decouple nudge test setup from edit‑cooldown logic.

updateApplication now enforces edit cooldown and updates lastEditAt, so this setup mutation is coupled to edit logic and may become brittle if lastEditAt is present. Prefer a direct document update or a dedicated test helper for backdating lastNudgeAt.

🤖 Prompt for AI Agents
In `@test/integration/application.test.ts` around lines 691 - 693, The test is
mutating lastNudgeAt via applicationModel.updateApplication which now enforces
edit cooldown and touches lastEditAt, coupling the test to edit logic; change
the setup to directly update the document (bypassing updateApplication) or use a
dedicated test helper to backdate lastNudgeAt so you only modify lastNudgeAt
without triggering cooldown logic—locate the call using
applicationModel.updateApplication, nudgeApplicationId and userId and replace it
with a direct DB/document update or helper that sets lastNudgeAt to
twentyFiveHoursAgo while leaving lastEditAt untouched.

.request(app)
.patch(`/applications/${nudgeApplicationId}/nudge`)
Expand Down
24 changes: 12 additions & 12 deletions test/unit/middlewares/application-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,23 +111,23 @@ describe("application validator test", function () {
status: "accepted",
feedback: "some feedback",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to update these as changes the validator name

expect(nextSpy.callCount).to.equal(1);
});

it("should not call next function if any value other than status and feedback is passed", async function () {
req.body = {
batman: true,
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});

it("should not call the next function if any value which is not allowed is sent in status", async function () {
req.body = {
status: "something",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});

Expand All @@ -136,7 +136,7 @@ describe("application validator test", function () {
status: "accepted",
feedback: "Great work!",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(1);
});

Expand All @@ -145,7 +145,7 @@ describe("application validator test", function () {
status: "rejected",
feedback: "Not a good fit",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(1);
});

Expand All @@ -154,15 +154,15 @@ describe("application validator test", function () {
status: "changes_requested",
feedback: "Please update your skills section",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(1);
});

it("should not call next function when status is changes_requested without feedback", async function () {
req.body = {
status: "changes_requested",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});

Expand All @@ -171,7 +171,7 @@ describe("application validator test", function () {
status: "changes_requested",
feedback: "",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});

Expand All @@ -180,7 +180,7 @@ describe("application validator test", function () {
status: "accepted",
feedback: "",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(1);
});

Expand All @@ -189,23 +189,23 @@ describe("application validator test", function () {
status: "rejected",
feedback: "",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(1);
});

it("should not call next function when status is missing", async function () {
req.body = {
feedback: "Some feedback",
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});

it("should not call next function when status is null", async function () {
req.body = {
status: null,
};
await applicationValidator.validateApplicationUpdateData(req, res, nextSpy);
await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy);
expect(nextSpy.callCount).to.equal(0);
});
});
Expand Down
10 changes: 0 additions & 10 deletions test/unit/models/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,6 @@ describe("applications", function () {
});
});

describe("updateApplication", function () {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will refactor and add this test in the test pr of the edit applications

it("should update a particular application", async function () {
const dataToUpdate = { status: "accepted" };
await ApplicationModel.updateApplication(dataToUpdate, applicationId1);
const application = await ApplicationModel.getApplicationById(applicationId1);

expect(application.status).to.be.equal("accepted");
});
});

describe("addApplicationFeedback", function () {
let testApplicationId: string;
const reviewerName = "test-reviewer";
Expand Down
Loading