diff --git a/backend/controllers/interviewExperienceController.js b/backend/controllers/interviewExperienceController.js index b703c733..214ce790 100644 --- a/backend/controllers/interviewExperienceController.js +++ b/backend/controllers/interviewExperienceController.js @@ -8,6 +8,12 @@ const SECURE_CLIENT_KEY = const isSecureClientKey = (value) => typeof value === "string" && SECURE_CLIENT_KEY.test(value); +// Dedup must be scoped to the author so a re-used idempotencyKey can never +// return another submitter's experience: the submitting user when +// authenticated, otherwise the anonymous clientKey. +const buildAuthorFilter = (req, clientKey) => + req.user?._id ? { userId: req.user._id } : { clientKey }; + const toClientShape = (doc) => { const obj = typeof doc.toObject === "function" ? doc.toObject() : doc; return { @@ -70,7 +76,10 @@ const createInterviewExperience = async (req, res) => { payload.color = `hsl(${(payload.company.charCodeAt(0) * 37) % 360}, 55%, 50%)`; } - const existing = await InterviewExperience.findOne({ idempotencyKey }); + const existing = await InterviewExperience.findOne({ + idempotencyKey, + ...buildAuthorFilter(req, payload.clientKey), + }); if (existing) { return res.status(200).json({ success: true, @@ -92,6 +101,7 @@ const createInterviewExperience = async (req, res) => { try { const existing = await InterviewExperience.findOne({ idempotencyKey: req.body.idempotencyKey, + ...buildAuthorFilter(req, req.body.clientKey), }); if (existing) { return res.status(200).json({ diff --git a/backend/models/InterviewExperience.js b/backend/models/InterviewExperience.js index e4344558..5fbfe4a0 100644 --- a/backend/models/InterviewExperience.js +++ b/backend/models/InterviewExperience.js @@ -95,11 +95,26 @@ const interviewExperienceSchema = new mongoose.Schema( { timestamps: true }, ); +// Idempotency is scoped to the author so a re-used key can never collide +// with another submitter's document: authenticated submissions dedupe by +// userId, anonymous submissions by clientKey. interviewExperienceSchema.index( - { idempotencyKey: 1 }, + { userId: 1, idempotencyKey: 1 }, { unique: true, partialFilterExpression: { + userId: { $type: "objectId" }, + idempotencyKey: { $type: "string", $gt: "" }, + }, + }, +); + +interviewExperienceSchema.index( + { clientKey: 1, idempotencyKey: 1 }, + { + unique: true, + partialFilterExpression: { + clientKey: { $type: "string", $gt: "" }, idempotencyKey: { $type: "string", $gt: "" }, }, }, diff --git a/backend/tests/interviewExperienceController.unit.test.js b/backend/tests/interviewExperienceController.unit.test.js index 0dbfcbd7..f52adbf4 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); @@ -176,6 +177,90 @@ describe("createInterviewExperience", () => { }), ); }); + + it("scopes the dedup lookup to the authenticated user", async () => { + InterviewExperience.findOne = vi.fn().mockResolvedValue(sampleDoc); + InterviewExperience.create = vi.fn(); + + const req = makeReq( + { + company: "Google", + role: "SDE-2", + summary: "Tough but fair process", + idempotencyKey: "submit-key-abc12345", + }, + {}, + {}, + { _id: "507f1f77bcf86cd799439011" }, + ); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.findOne).toHaveBeenCalledWith({ + idempotencyKey: "submit-key-abc12345", + userId: "507f1f77bcf86cd799439011", + }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("lets a different author reuse the same idempotencyKey", async () => { + InterviewExperience.findOne = vi.fn().mockResolvedValue(null); + InterviewExperience.create = vi.fn().mockResolvedValue(sampleDoc); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Tough but fair process", + clientKey: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "submit-key-abc12345", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.findOne).toHaveBeenCalledWith({ + idempotencyKey: "submit-key-abc12345", + clientKey: "11111111-1111-4111-8111-111111111111", + }); + expect(InterviewExperience.create).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it("scopes the unique-index race lookup to the author", async () => { + InterviewExperience.findOne = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(sampleDoc); + InterviewExperience.create = vi + .fn() + .mockRejectedValue({ code: 11000 }); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Tough but fair process", + clientKey: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "submit-key-abc12345", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.findOne).toHaveBeenCalledTimes(2); + expect(InterviewExperience.findOne).toHaveBeenNthCalledWith(1, { + idempotencyKey: "submit-key-abc12345", + clientKey: "11111111-1111-4111-8111-111111111111", + }); + expect(InterviewExperience.findOne).toHaveBeenNthCalledWith(2, { + idempotencyKey: "submit-key-abc12345", + clientKey: "11111111-1111-4111-8111-111111111111", + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ); + }); }); describe("getMyInterviewExperiences", () => {