diff --git a/backend/controllers/interviewExperienceController.js b/backend/controllers/interviewExperienceController.js index b703c733..37f69e5d 100644 --- a/backend/controllers/interviewExperienceController.js +++ b/backend/controllers/interviewExperienceController.js @@ -70,7 +70,15 @@ const createInterviewExperience = async (req, res) => { payload.color = `hsl(${(payload.company.charCodeAt(0) * 37) % 360}, 55%, 50%)`; } - const existing = await InterviewExperience.findOne({ idempotencyKey }); + // Scope dedup to userId (authenticated) or clientKey (anonymous). + // This prevents one user from retrieving another user's submission by re-using the same key. + const dedupFilter = { idempotencyKey }; + if (payload.userId) { + dedupFilter.userId = payload.userId; + } else { + dedupFilter.clientKey = payload.clientKey; + } + const existing = await InterviewExperience.findOne(dedupFilter); if (existing) { return res.status(200).json({ success: true, @@ -87,12 +95,16 @@ const createInterviewExperience = async (req, res) => { experience: toClientShape(experience), }); } catch (error) { - // Concurrent retry won the unique index race — return the first write. + // Concurrent retry won the unique index race — return the first write, scoped to the same user/client. if (error?.code === 11000 && req.body?.idempotencyKey) { try { - const existing = await InterviewExperience.findOne({ - idempotencyKey: req.body.idempotencyKey, - }); + const retryFilter = { idempotencyKey: req.body.idempotencyKey.trim() }; + if (req.user?._id) { + retryFilter.userId = req.user._id; + } else if (isSecureClientKey(req.body.clientKey)) { + retryFilter.clientKey = req.body.clientKey.trim(); + } + const existing = await InterviewExperience.findOne(retryFilter); if (existing) { return res.status(200).json({ success: true, diff --git a/backend/models/InterviewExperience.js b/backend/models/InterviewExperience.js index e4344558..edbf1929 100644 --- a/backend/models/InterviewExperience.js +++ b/backend/models/InterviewExperience.js @@ -95,12 +95,26 @@ const interviewExperienceSchema = new mongoose.Schema( { timestamps: true }, ); +// Compound indexes scope idempotency dedup per-user and per-clientKey, preventing +// one user from retrieving another user's submission by re-using a key. interviewExperienceSchema.index( - { idempotencyKey: 1 }, + { idempotencyKey: 1, userId: 1 }, { unique: true, partialFilterExpression: { idempotencyKey: { $type: "string", $gt: "" }, + userId: { $type: "objectId" }, + }, + }, +); + +interviewExperienceSchema.index( + { idempotencyKey: 1, clientKey: 1 }, + { + unique: true, + partialFilterExpression: { + idempotencyKey: { $type: "string", $gt: "" }, + clientKey: { $type: "string", $gt: "" }, }, }, ); diff --git a/backend/tests/interviewExperienceController.unit.test.js b/backend/tests/interviewExperienceController.unit.test.js index 0dbfcbd7..1ec5ce91 100644 --- a/backend/tests/interviewExperienceController.unit.test.js +++ b/backend/tests/interviewExperienceController.unit.test.js @@ -164,6 +164,7 @@ describe("createInterviewExperience", () => { expect(InterviewExperience.findOne).toHaveBeenCalledWith({ idempotencyKey: "submit-key-abc12345", + clientKey: "11111111-1111-4111-8111-111111111111", }); expect(InterviewExperience.create).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200);