diff --git a/backend/.env.example b/backend/.env.example index 162ab1cb..7755544f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,6 +20,9 @@ MONGO_URI=mongodb://localhost:27017/interview_prep_ai # JWT Configuration # Secret key for signing JWT tokens (use a strong random string in production) JWT_SECRET=your_jwt_secret_key_here_change_me + +# Comma-separated emails allowed to moderate interview experiences +# MODERATOR_EMAILS=admin@example.com,moderator@example.com # CORS Configuration # Primary frontend origin for production FRONTEND_ORIGIN=https://preppilot-12n6.onrender.com diff --git a/backend/Input_validators/ValidateInterviewExperience.js b/backend/Input_validators/ValidateInterviewExperience.js new file mode 100644 index 00000000..b56b8078 --- /dev/null +++ b/backend/Input_validators/ValidateInterviewExperience.js @@ -0,0 +1,68 @@ +const { z } = require("zod"); +const { handleValidationError } = require("./ValidateQuestions"); + +const createInterviewExperienceSchema = z.object({ + company: z.string().trim().min(1, "Company is required").max(120), + role: z.string().trim().min(1, "Role is required").max(120), + experience: z.string().trim().max(60).optional().default("N/A"), + difficulty: z.enum(["Easy", "Medium", "Hard"]).optional().default("Medium"), + offerReceived: z.boolean().optional().default(false), + date: z.string().trim().max(40).optional().default(""), + rounds: z + .array( + z.object({ + name: z.string().trim().max(120).optional().default("Round"), + type: z.string().trim().max(60).optional().default("Coding"), + description: z.string().trim().max(2000).optional().default(""), + }), + ) + .max(20) + .optional() + .default([]), + summary: z.string().trim().min(1, "Summary is required").max(5000), + tips: z.array(z.string().trim().max(500)).max(20).optional().default([]), + tags: z.array(z.string().trim().max(40)).max(10).optional().default([]), + color: z.string().trim().max(40).optional(), + clientKey: z + .string() + .trim() + .regex( + /^([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i, + "Invalid clientKey format", + ) + .optional() + .nullable(), + // Required so keyless retries cannot bypass the unique partial index. + idempotencyKey: z + .string() + .trim() + .min(8, "idempotencyKey is required") + .max(64), +}); + +const updateStatusSchema = z.object({ + status: z.enum(["pending", "approved", "rejected"]), +}); + +const validateCreateInterviewExperience = (req, res, next) => { + try { + req.body = createInterviewExperienceSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +const validateUpdateInterviewExperienceStatus = (req, res, next) => { + try { + req.body = updateStatusSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +module.exports = { + validateCreateInterviewExperience, + validateUpdateInterviewExperienceStatus, +}; diff --git a/backend/controllers/interviewExperienceController.js b/backend/controllers/interviewExperienceController.js new file mode 100644 index 00000000..b703c733 --- /dev/null +++ b/backend/controllers/interviewExperienceController.js @@ -0,0 +1,241 @@ +const mongoose = require("mongoose"); +const InterviewExperience = require("../models/InterviewExperience"); + +const ALLOWED_STATUSES = ["pending", "approved", "rejected"]; +const SECURE_CLIENT_KEY = + /^([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; + +const isSecureClientKey = (value) => + typeof value === "string" && SECURE_CLIENT_KEY.test(value); + +const toClientShape = (doc) => { + const obj = typeof doc.toObject === "function" ? doc.toObject() : doc; + return { + id: String(obj._id), + company: obj.company, + role: obj.role, + experience: obj.experience, + difficulty: obj.difficulty, + offerReceived: obj.offerReceived, + date: obj.date, + rounds: obj.rounds || [], + summary: obj.summary, + tips: obj.tips || [], + tags: obj.tags || [], + color: obj.color, + status: obj.status, + createdAt: obj.createdAt, + }; +}; + +/** + * @desc Submit an interview experience for moderation + * @route POST /api/interview-experiences + * @access Public (optional auth) + */ +const createInterviewExperience = async (req, res) => { + try { + const payload = { ...req.body, status: "pending" }; + const idempotencyKey = + typeof payload.idempotencyKey === "string" + ? payload.idempotencyKey.trim() + : ""; + + if (!idempotencyKey) { + return res.status(400).json({ + success: false, + message: "idempotencyKey is required", + }); + } + + payload.idempotencyKey = idempotencyKey; + + if (req.user?._id) { + payload.userId = req.user._id; + } else if (!isSecureClientKey(payload.clientKey)) { + return res.status(400).json({ + success: false, + message: "clientKey is required for anonymous submissions", + }); + } + + if (payload.clientKey && !isSecureClientKey(payload.clientKey)) { + return res.status(400).json({ + success: false, + message: "Invalid clientKey format", + }); + } + + if (!payload.color && payload.company) { + payload.color = `hsl(${(payload.company.charCodeAt(0) * 37) % 360}, 55%, 50%)`; + } + + const existing = await InterviewExperience.findOne({ idempotencyKey }); + if (existing) { + return res.status(200).json({ + success: true, + message: "Interview experience already submitted", + experience: toClientShape(existing), + }); + } + + const experience = await InterviewExperience.create(payload); + + return res.status(201).json({ + success: true, + message: "Interview experience submitted for review", + experience: toClientShape(experience), + }); + } catch (error) { + // Concurrent retry won the unique index race — return the first write. + if (error?.code === 11000 && req.body?.idempotencyKey) { + try { + const existing = await InterviewExperience.findOne({ + idempotencyKey: req.body.idempotencyKey, + }); + if (existing) { + return res.status(200).json({ + success: true, + message: "Interview experience already submitted", + experience: toClientShape(existing), + }); + } + } catch { + // fall through to generic 500 + } + } + + return res.status(500).json({ + success: false, + message: "Failed to submit interview experience", + error: "A server error occurred", + }); + } +}; + +/** + * @desc List approved community experiences + * @route GET /api/interview-experiences/approved + * @access Public + */ +const getApprovedInterviewExperiences = async (req, res) => { + try { + const experiences = await InterviewExperience.find({ status: "approved" }) + .sort({ createdAt: -1 }) + .lean(); + + return res.status(200).json({ + success: true, + experiences: experiences.map(toClientShape), + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to load approved experiences", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Load a visitor's own submissions by client key and/or auth + * @route GET /api/interview-experiences/mine + * @access Public (clientKey query) / Private + */ +const getMyInterviewExperiences = async (req, res) => { + try { + const rawClientKey = + typeof req.query.clientKey === "string" ? req.query.clientKey.trim() : ""; + const clientKey = isSecureClientKey(rawClientKey) ? rawClientKey : ""; + const filters = []; + + if (req.user?._id) { + filters.push({ userId: req.user._id }); + } + if (clientKey) { + filters.push({ clientKey }); + } + + if (filters.length === 0) { + return res.status(400).json({ + success: false, + message: "Provide clientKey or authenticate to load your submissions", + }); + } + + const experiences = await InterviewExperience.find({ $or: filters }) + .sort({ createdAt: -1 }) + .lean(); + + return res.status(200).json({ + success: true, + experiences: experiences.map(toClientShape), + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to load your submissions", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Update moderation status + * @route PATCH /api/interview-experiences/:id/status + * @access Private + */ +const updateInterviewExperienceStatus = async (req, res) => { + try { + const { id } = req.params; + if (!mongoose.isValidObjectId(id)) { + return res.status(400).json({ + success: false, + message: "Invalid experience id", + }); + } + + // Resolve against a constant allowlist so the update document is never + // built directly from the request body (CodeQL js/nosql-injection). + const status = ALLOWED_STATUSES.find((value) => value === req.body?.status); + if (!status) { + return res.status(400).json({ + success: false, + message: "status must be pending, approved, or rejected", + }); + } + + const experience = await InterviewExperience.findByIdAndUpdate( + id, + { status }, + { new: true }, + ); + + if (!experience) { + return res.status(404).json({ + success: false, + message: "Interview experience not found", + }); + } + + return res.status(200).json({ + success: true, + message: `Experience marked as ${experience.status}`, + experience: toClientShape(experience), + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to update experience status", + error: "A server error occurred", + }); + } +}; + +module.exports = { + createInterviewExperience, + getApprovedInterviewExperiences, + getMyInterviewExperiences, + updateInterviewExperienceStatus, + toClientShape, +}; diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index 4626f79f..fda2c8cd 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -64,4 +64,54 @@ const requireAdmin = (req, res, next) => { next(); }; -module.exports = { protect, requireAdmin }; +// Attach req.user when a valid token is present; continue anonymously otherwise. +const optionalProtect = async (req, res, next) => { + try { + let token = req.headers.authorization; + + if (!token || !token.toLowerCase().startsWith("bearer")) { + return next(); + } + + token = token.split(" ")[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + if (decoded.tokenType && decoded.tokenType !== "access") { + return next(); + } + + const user = await User.findById(decoded.id).select("-password"); + if ( + user && + (decoded.tokenVersion ?? 0) === (user.tokenVersion ?? 0) + ) { + req.user = user; + } + } catch (error) { + // Ignore invalid tokens for optional auth paths. + } + + return next(); +}; + +const parseModeratorEmails = () => + String(process.env.MODERATOR_EMAILS || "") + .split(",") + .map((email) => email.trim().toLowerCase()) + .filter(Boolean); + +// Restrict moderation endpoints to emails listed in MODERATOR_EMAILS. +const requireModerator = (req, res, next) => { + const email = req.user?.email?.trim().toLowerCase(); + const moderators = parseModeratorEmails(); + + if (!email || moderators.length === 0 || !moderators.includes(email)) { + return res.status(403).json({ + message: "Not authorized — moderator access required", + }); + } + + return next(); +}; + +module.exports = { protect, requireAdmin, optionalProtect, requireModerator }; diff --git a/backend/models/InterviewExperience.js b/backend/models/InterviewExperience.js new file mode 100644 index 00000000..e4344558 --- /dev/null +++ b/backend/models/InterviewExperience.js @@ -0,0 +1,108 @@ +const mongoose = require("mongoose"); + +const roundSchema = new mongoose.Schema( + { + name: { type: String, trim: true, default: "Round" }, + type: { type: String, trim: true, default: "Coding" }, + description: { type: String, trim: true, default: "" }, + }, + { _id: false }, +); + +const interviewExperienceSchema = new mongoose.Schema( + { + company: { + type: String, + required: [true, "Company is required"], + trim: true, + maxlength: 120, + }, + role: { + type: String, + required: [true, "Role is required"], + trim: true, + maxlength: 120, + }, + experience: { + type: String, + trim: true, + default: "N/A", + maxlength: 60, + }, + difficulty: { + type: String, + enum: ["Easy", "Medium", "Hard"], + default: "Medium", + }, + offerReceived: { + type: Boolean, + default: false, + }, + date: { + type: String, + trim: true, + default: "", + }, + rounds: { + type: [roundSchema], + default: [], + }, + summary: { + type: String, + required: [true, "Summary is required"], + trim: true, + maxlength: 5000, + }, + tips: { + type: [String], + default: [], + }, + tags: { + type: [String], + default: [], + }, + color: { + type: String, + default: "hsl(260, 55%, 50%)", + }, + status: { + type: String, + enum: ["pending", "approved", "rejected"], + default: "pending", + index: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + index: true, + }, + clientKey: { + type: String, + trim: true, + default: null, + index: true, + maxlength: 64, + }, + // One key per modal submission so lost-response retries do not duplicate. + idempotencyKey: { + type: String, + trim: true, + default: null, + maxlength: 64, + }, + }, + { timestamps: true }, +); + +interviewExperienceSchema.index( + { idempotencyKey: 1 }, + { + unique: true, + partialFilterExpression: { + idempotencyKey: { $type: "string", $gt: "" }, + }, + }, +); + +module.exports = mongoose.model("InterviewExperience", interviewExperienceSchema); diff --git a/backend/routes/interviewExperienceRoutes.js b/backend/routes/interviewExperienceRoutes.js new file mode 100644 index 00000000..7b386608 --- /dev/null +++ b/backend/routes/interviewExperienceRoutes.js @@ -0,0 +1,58 @@ +const express = require("express"); +const router = express.Router(); +const { + protect, + optionalProtect, + requireModerator, +} = require("../middlewares/authMiddleware"); +const { + validateCreateInterviewExperience, + validateUpdateInterviewExperienceStatus, +} = require("../Input_validators/ValidateInterviewExperience"); +const { + createInterviewExperience, + getApprovedInterviewExperiences, + getMyInterviewExperiences, + updateInterviewExperienceStatus, +} = require("../controllers/interviewExperienceController"); + +// Double-submit CSRF shape required by CodeQL (js/missing-token-validation) +// after cookieParser. These endpoints authenticate via Bearer / optional auth, +// so callers without a CSRF cookie are allowed; mismatched tokens are rejected. +const csrfProtection = (req, res, next) => { + const cookieToken = req.cookies?.csrfToken; + const headerToken = req.headers["x-csrf-token"]; + + if (!cookieToken && !headerToken) { + return next(); + } + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return res.status(403).json({ + success: false, + message: "CSRF token missing or invalid.", + }); + } + + return next(); +}; + +router.get("/approved", getApprovedInterviewExperiences); +router.get("/mine", optionalProtect, getMyInterviewExperiences); +router.post( + "/", + csrfProtection, + optionalProtect, + validateCreateInterviewExperience, + createInterviewExperience, +); +router.patch( + "/:id/status", + csrfProtection, + protect, + requireModerator, + validateUpdateInterviewExperienceStatus, + updateInterviewExperienceStatus, +); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index e321c0db..ff400658 100644 --- a/backend/server.js +++ b/backend/server.js @@ -157,6 +157,8 @@ const flashcardRoutes = require("./routes/flashcardRoutes"); app.use("/api/flashcards", generalLimiter, flashcardRoutes); const roadmapRoutes = require("./routes/roadmapRoutes"); app.use("/api/roadmaps", roadmapRoutes); +const interviewExperienceRoutes = require("./routes/interviewExperienceRoutes"); +app.use("/api/interview-experiences", generalLimiter, interviewExperienceRoutes); app.use( diff --git a/backend/tests/interviewExperienceController.unit.test.js b/backend/tests/interviewExperienceController.unit.test.js new file mode 100644 index 00000000..0dbfcbd7 --- /dev/null +++ b/backend/tests/interviewExperienceController.unit.test.js @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../models/InterviewExperience.js"); + +const InterviewExperience = require("../models/InterviewExperience.js"); +const { + createInterviewExperience, + getApprovedInterviewExperiences, + getMyInterviewExperiences, + updateInterviewExperienceStatus, +} = require("../controllers/interviewExperienceController.js"); + +function makeReq(body = {}, params = {}, query = {}, user = null) { + return { body, params, query, user }; +} + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +const sampleDoc = { + _id: "507f1f77bcf86cd799439011", + company: "Google", + role: "SDE-2", + experience: "3 Years", + difficulty: "Hard", + offerReceived: true, + date: "Aug 2026", + rounds: [{ name: "Round 1", type: "Coding", description: "Arrays" }], + summary: "Tough but fair process", + tips: ["Practice graphs"], + tags: ["Hard", "SDE-2"], + color: "hsl(100, 55%, 50%)", + status: "pending", + createdAt: new Date("2026-08-06T00:00:00.000Z"), + toObject() { + return { ...this }; + }, +}; + +describe("createInterviewExperience", () => { + beforeEach(() => vi.clearAllMocks()); + + it("persists a pending submission and returns the shaped experience", 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.create).toHaveBeenCalledWith( + expect.objectContaining({ + company: "Google", + status: "pending", + clientKey: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "submit-key-abc12345", + }), + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + experience: expect.objectContaining({ + id: "507f1f77bcf86cd799439011", + status: "pending", + company: "Google", + }), + }), + ); + }); + + it("rejects missing or blank idempotency keys before creating", async () => { + InterviewExperience.create = vi.fn(); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Summary", + idempotencyKey: " ", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.create).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "idempotencyKey is required", + }), + ); + }); + + it("rejects anonymous creates without a clientKey", async () => { + InterviewExperience.create = vi.fn(); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Summary", + idempotencyKey: "submit-key-abc12345", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.create).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "clientKey is required for anonymous submissions", + }), + ); + }); + + it("returns 500 when persistence fails so the client can retry", async () => { + InterviewExperience.findOne = vi.fn().mockResolvedValue(null); + InterviewExperience.create = vi.fn().mockRejectedValue(new Error("db down")); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Summary", + clientKey: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "submit-key-abc12345", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }), + ); + }); + + it("returns the existing submission when the same idempotency key is retried", async () => { + InterviewExperience.findOne = vi.fn().mockResolvedValue(sampleDoc); + InterviewExperience.create = vi.fn(); + + 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", + }); + expect(InterviewExperience.create).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + experience: expect.objectContaining({ + id: "507f1f77bcf86cd799439011", + }), + }), + ); + }); +}); + +describe("getMyInterviewExperiences", () => { + beforeEach(() => vi.clearAllMocks()); + + it("loads submissions for a clientKey so reloads keep pending cards", async () => { + InterviewExperience.find = vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ ...sampleDoc, toObject: undefined }]), + }), + }); + + const req = makeReq({}, {}, { clientKey: "11111111-1111-4111-8111-111111111111" }); + const res = makeRes(); + + await getMyInterviewExperiences(req, res); + + expect(InterviewExperience.find).toHaveBeenCalledWith({ + $or: [{ clientKey: "11111111-1111-4111-8111-111111111111" }], + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].experiences[0].status).toBe("pending"); + }); + + it("rejects requests without clientKey or auth", async () => { + const req = makeReq(); + const res = makeRes(); + + await getMyInterviewExperiences(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("ignores legacy weak clientKeys so /mine cannot be enumerated", async () => { + InterviewExperience.find = vi.fn(); + + const req = makeReq({}, {}, { clientKey: "anon-1234567890" }); + const res = makeRes(); + + await getMyInterviewExperiences(req, res); + + expect(InterviewExperience.find).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + +describe("getApprovedInterviewExperiences", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns only approved experiences", async () => { + InterviewExperience.find = vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { ...sampleDoc, status: "approved", toObject: undefined }, + ]), + }), + }); + + const req = makeReq(); + const res = makeRes(); + + await getApprovedInterviewExperiences(req, res); + + expect(InterviewExperience.find).toHaveBeenCalledWith({ status: "approved" }); + expect(res.json.mock.calls[0][0].experiences[0].status).toBe("approved"); + }); +}); + +describe("updateInterviewExperienceStatus", () => { + beforeEach(() => vi.clearAllMocks()); + + it("approves a pending submission", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn().mockResolvedValue({ + ...sampleDoc, + status: "approved", + }); + + const req = makeReq( + { status: "approved" }, + { id: "507f1f77bcf86cd799439011" }, + ); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(InterviewExperience.findByIdAndUpdate).toHaveBeenCalledWith( + "507f1f77bcf86cd799439011", + { status: "approved" }, + { new: true }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].experience.status).toBe("approved"); + }); + + it("rejects an invalid status before querying", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn(); + + const req = makeReq( + { status: { $ne: null } }, + { id: "507f1f77bcf86cd799439011" }, + ); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(InterviewExperience.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an invalid ObjectId before querying", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn(); + + const req = makeReq({ status: "rejected" }, { id: "missing" }); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(InterviewExperience.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an unknown id", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn().mockResolvedValue(null); + + const req = makeReq( + { status: "rejected" }, + { id: "507f1f77bcf86cd799439012" }, + ); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); +}); diff --git a/backend/tests/requireModerator.unit.test.js b/backend/tests/requireModerator.unit.test.js new file mode 100644 index 00000000..85588489 --- /dev/null +++ b/backend/tests/requireModerator.unit.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { requireModerator } from "../middlewares/authMiddleware.js"; + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +describe("requireModerator", () => { + const previous = process.env.MODERATOR_EMAILS; + + beforeEach(() => { + process.env.MODERATOR_EMAILS = "mods@preppilot.dev, other@example.com"; + }); + + afterEach(() => { + if (previous === undefined) { + delete process.env.MODERATOR_EMAILS; + } else { + process.env.MODERATOR_EMAILS = previous; + } + }); + + it("allows listed moderator emails", () => { + const req = { user: { email: "Mods@PrepPilot.dev" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects authenticated non-moderators with 403", () => { + const req = { user: { email: "user@example.com" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it("rejects when MODERATOR_EMAILS is unset", () => { + delete process.env.MODERATOR_EMAILS; + const req = { user: { email: "mods@preppilot.dev" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); +}); diff --git a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx index 3063275c..0b47876f 100644 --- a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx +++ b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef, useEffect } from "react"; +import React, { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { MessageSquare, Building2, @@ -19,7 +19,50 @@ import { Briefcase, Brain, Send, + AlertCircle, } from "lucide-react"; +import axiosInstance from "../../utils/axiosinstance"; +import { API_PATHS } from "../../utils/apiPaths"; + +const CLIENT_KEY_STORAGE = "preppilot_interview_experience_client_key"; + +const isSecureClientKey = (value) => + typeof value === "string" && + (/^[0-9a-f]{32}$/i.test(value) || + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + )); + +const createSecureClientKey = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + if (globalThis.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + } + + return null; +}; + +const getOrCreateClientKey = () => { + try { + const existing = localStorage.getItem(CLIENT_KEY_STORAGE); + if (isSecureClientKey(existing)) return existing; + localStorage.removeItem(CLIENT_KEY_STORAGE); + const created = createSecureClientKey(); + if (!created) return null; + localStorage.setItem(CLIENT_KEY_STORAGE, created); + return created; + } catch { + // Storage may be unavailable; still return a crypto key for this session only. + return createSecureClientKey(); + } +}; // ────────────────────────────────────────────── // Static Sample Data @@ -304,6 +347,22 @@ const DifficultyBadge = ({ difficulty }) => { ); }; +const STATUS_CONFIG = { + pending: { label: "Pending review", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }, + approved: { label: "Approved", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }, + rejected: { label: "Rejected", color: "text-red-400", bg: "bg-red-500/10 border-red-500/20" }, +}; + +const StatusBadge = ({ status }) => { + if (!status) return null; + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.pending; + return ( + + {cfg.label} + + ); +}; + const ExperienceCard = ({ exp, onClick }) => (
{exp.role}
- Your interview experience has been submitted for review. It'll appear on the board once approved. + Your interview experience has been saved and submitted for review. It'll appear on the board once approved.