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
17 changes: 9 additions & 8 deletions backend/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,15 @@ const resendVerificationEmail = async (req, res) => {

const user = await User.findOne({ email: email.trim().toLowerCase() });

// Return success even if user not found — avoids exposing which emails are registered
if (!user) {
return res.json({ success: true, message: "If this email is registered, a verification link has been sent." });
}
// Generic message returned for every non-validation outcome so that an
// attacker cannot infer whether an email is registered or already
// verified (prevents email enumeration). A link is only actually sent
// when the account exists and is still unverified.
const genericMessage =
"If this email is registered and unverified, a verification link has been sent.";

// If already verified, no need to resend
if (user.isEmailVerified) {
return res.status(400).json({ success: false, message: "This email is already verified. Please log in." });
if (!user || user.isEmailVerified) {
return res.json({ success: true, message: genericMessage });
}

// Generate a fresh token and reset expiry to 24 hours from now
Expand All @@ -384,7 +385,7 @@ const resendVerificationEmail = async (req, res) => {
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${user.emailVerificationToken}`;
await sendVerificationEmail(user.email, verificationUrl);

res.json({ success: true, message: "Verification email resent. Please check your inbox." });
res.json({ success: true, message: genericMessage });
} catch (error) {
console.error("Resend verification error:", error);
res.status(500).json({ success: false, message: "Internal server error occurred" });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import express from "express";
import request from "supertest";

// Auto-mock the User model (same pattern as the repo's
// interviewExperienceController test: auto-mock + overwrite findOne). The email
// subsystem is stubbed by patching nodemailer.createTransport BEFORE the
// controller loads, so sendEmail.js captures a fake transporter whose sendMail
// is a spy — no real SMTP connection is attempted.
vi.mock("../models/User.js");

const nodemailer = require("nodemailer");
const sendMail = vi.fn().mockResolvedValue({});
nodemailer.createTransport = () => ({ sendMail });

const User = require("../models/User.js");
const { resendVerificationEmail } = require("../controllers/authController.js");

function buildApp() {
const app = express();
app.use(express.json());
app.post("/api/auth/resend-verification", resendVerificationEmail);
return app;
}

function fakeUser({ isEmailVerified = false } = {}) {
return {
email: "victim@example.com",
isEmailVerified,
save: vi.fn().mockResolvedValue(),
emailVerificationToken: "old",
emailVerificationExpires: new Date(),
};
}

const GENERIC_MESSAGE =
"If this email is registered and unverified, a verification link has been sent.";

beforeEach(() => {
vi.clearAllMocks();
User.findOne = vi.fn();
sendMail.mockResolvedValue({});
});

describe("resend-verification — email enumeration prevention (#1414)", () => {
it("returns 200 with the generic message when the email is not registered", async () => {
User.findOne.mockResolvedValue(null);
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "nobody@example.com" });

expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, message: GENERIC_MESSAGE });
expect(sendMail).not.toHaveBeenCalled();
});

it("returns the SAME 200 generic message when the email is already verified", async () => {
User.findOne.mockResolvedValue(fakeUser({ isEmailVerified: true }));
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "victim@example.com" });

expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, message: GENERIC_MESSAGE });
expect(sendMail).not.toHaveBeenCalled();
});

it("returns the SAME generic message for an unverified user but actually sends the email", async () => {
const user = fakeUser({ isEmailVerified: false });
User.findOne.mockResolvedValue(user);
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "Victim@Example.com" });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toBe(GENERIC_MESSAGE);
// A fresh token must be issued and the verification email actually sent.
expect(user.save).toHaveBeenCalled();
expect(sendMail).toHaveBeenCalledTimes(1);
Comment on lines +68 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the values persisted by user.save.

The test passes if the handler saves the existing "old" token. Capture the token and expiry inside the user.save mock. Assert that the saved token is a new 64-character hex value and that the saved expiry is in the future.

Proposed test update
   it("returns the SAME generic message for an unverified user but actually sends the email", async () => {
     const user = fakeUser({ isEmailVerified: false });
+    let savedToken;
+    let savedExpiry;
+    user.save.mockImplementation(async () => {
+      savedToken = user.emailVerificationToken;
+      savedExpiry = user.emailVerificationExpires;
+    });
     User.findOne.mockResolvedValue(user);
     const res = await request(buildApp())
       .post("/api/auth/resend-verification")
       .send({ email: "Victim@Example.com" });
 
     expect(res.status).toBe(200);
     expect(res.body.success).toBe(true);
     expect(res.body.message).toBe(GENERIC_MESSAGE);
-    // A fresh token must be issued and the verification email actually sent.
     expect(user.save).toHaveBeenCalled();
+    expect(savedToken).not.toBe("old");
+    expect(savedToken).toMatch(/^[0-9a-f]{64}$/);
+    expect(savedExpiry.getTime()).toBeGreaterThan(Date.now());
     expect(sendMail).toHaveBeenCalledTimes(1);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("returns the SAME generic message for an unverified user but actually sends the email", async () => {
const user = fakeUser({ isEmailVerified: false });
User.findOne.mockResolvedValue(user);
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "Victim@Example.com" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toBe(GENERIC_MESSAGE);
// A fresh token must be issued and the verification email actually sent.
expect(user.save).toHaveBeenCalled();
expect(sendMail).toHaveBeenCalledTimes(1);
it("returns the SAME generic message for an unverified user but actually sends the email", async () => {
const user = fakeUser({ isEmailVerified: false });
let savedToken;
let savedExpiry;
user.save.mockImplementation(async () => {
savedToken = user.emailVerificationToken;
savedExpiry = user.emailVerificationExpires;
});
User.findOne.mockResolvedValue(user);
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "Victim@Example.com" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toBe(GENERIC_MESSAGE);
expect(user.save).toHaveBeenCalled();
expect(savedToken).not.toBe("old");
expect(savedToken).toMatch(/^[0-9a-f]{64}$/);
expect(savedExpiry.getTime()).toBeGreaterThan(Date.now());
expect(sendMail).toHaveBeenCalledTimes(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/authController.resendVerification.enumeration.unit.test.js`
around lines 68 - 80, Strengthen the resend-verification test around the
user.save mock by capturing the token and expiry values persisted by the
handler. Assert that the saved token is a newly generated 64-character
hexadecimal value and that the saved expiry is later than the current time,
while retaining the existing response and email assertions.

});

it("produces indistinguishable responses for not-found vs already-verified", async () => {
User.findOne.mockResolvedValue(null);
const r1 = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "a@example.com" });

User.findOne.mockResolvedValue(fakeUser({ isEmailVerified: true }));
const r2 = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "b@example.com" });

// An attacker must not be able to tell these two states apart.
expect(r1.status).toBe(r2.status);
expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body));
});

it("still rejects an empty email with 400", async () => {
const res = await request(buildApp())
.post("/api/auth/resend-verification")
.send({ email: "" });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
Loading