diff --git a/jest.config.js b/jest.config.js index c0f1215..ad3844e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,8 @@ module.exports = { testEnvironment: 'node', + setupFiles: ['./jest.setup.js'], coverageDirectory: 'coverage', - collectCoverageFrom: [ - 'src/**/*.js', - '!src/server.js', - ], + collectCoverageFrom: ['src/**/*.js', '!src/server.js'], coverageThreshold: { global: { branches: 0, @@ -13,8 +11,5 @@ module.exports = { statements: 0, }, }, - testMatch: [ - '**/__tests__/**/*.js', - '**/?(*.)+(spec|test).js', - ], + testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'], }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..db91633 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +process.env.JWT_SECRET = 'test-secret'; diff --git a/src/__tests__/auth.register.test.js b/src/__tests__/auth.register.test.js new file mode 100644 index 0000000..05a53ab --- /dev/null +++ b/src/__tests__/auth.register.test.js @@ -0,0 +1,152 @@ +const request = require('supertest'); + +// Mock dependencies before requiring app +jest.mock('../models/User.model', () => { + const mockSave = jest.fn(); + const MockUser = jest.fn().mockImplementation(function (data) { + Object.assign(this, data); + this._id = '507f1f77bcf86cd799439011'; + this.role = 'user'; + this.isVerified = false; + this.createdAt = new Date('2026-01-01T00:00:00.000Z'); + this.save = mockSave; + }); + MockUser.findOne = jest.fn(); + MockUser._mockSave = mockSave; + return MockUser; +}); + +jest.mock('../services/email.service', () => ({ + sendEmail: jest.fn(), +})); + +const User = require('../models/User.model'); +const { sendEmail } = require('../services/email.service'); +const app = require('../app'); + +describe('POST /api/auth/register', () => { + beforeEach(() => { + jest.clearAllMocks(); + User._mockSave.mockResolvedValue(undefined); + }); + + const validBody = { + fullName: 'Jane Doe', + email: 'jane@example.com', + password: 'securePass1', + }; + + describe('successful registration', () => { + it('returns 201 with user data and sends a verification email', async () => { + User.findOne.mockResolvedValue(null); // no existing user + sendEmail.mockResolvedValue({ messageId: 'test-id' }); + + const response = await request(app) + .post('/api/auth/register') + .send(validBody) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.message).toBe( + 'User registered successfully. Please verify your email.' + ); + + const { user } = response.body.data; + expect(user.email).toBe('jane@example.com'); + expect(user.isVerified).toBe(false); + // Token must NOT be exposed in the response + expect(user.emailVerificationToken).toBeUndefined(); + expect(user.emailVerificationExpires).toBeUndefined(); + + // Verify the email was sent + expect(sendEmail).toHaveBeenCalledTimes(1); + const emailCall = sendEmail.mock.calls[0][0]; + expect(emailCall.to).toBe('jane@example.com'); + expect(emailCall.subject).toContain('erif'); // "Verify" + expect(emailCall.html).toBeDefined(); + }); + + it('stores emailVerificationToken and emailVerificationExpires on the user', async () => { + User.findOne.mockResolvedValue(null); + sendEmail.mockResolvedValue({}); + + let capturedInstance; + User.mockImplementationOnce(function (data) { + Object.assign(this, data); + this._id = '507f1f77bcf86cd799439011'; + this.role = 'user'; + this.isVerified = false; + this.createdAt = new Date(); + this.save = User._mockSave; + capturedInstance = this; + }); + + await request(app).post('/api/auth/register').send(validBody).expect(201); + + expect(capturedInstance.emailVerificationToken).toBeDefined(); + expect(typeof capturedInstance.emailVerificationToken).toBe('string'); + expect(capturedInstance.emailVerificationToken).toHaveLength(64); // 32 bytes hex + expect(capturedInstance.emailVerificationExpires).toBeInstanceOf(Date); + // Expiry should be ~24 hours from now + const msUntilExpiry = + capturedInstance.emailVerificationExpires.getTime() - Date.now(); + expect(msUntilExpiry).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(msUntilExpiry).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 1000); + }); + }); + + describe('duplicate email', () => { + it('returns 409 when the email is already registered', async () => { + User.findOne.mockResolvedValue({ email: 'jane@example.com' }); + + const response = await request(app) + .post('/api/auth/register') + .send(validBody) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Email already exists'); + expect(sendEmail).not.toHaveBeenCalled(); + }); + }); + + describe('validation errors', () => { + it('returns 400 when email is missing', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ fullName: 'Jane', password: 'securePass1' }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + it('returns 400 when password is too short', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + fullName: 'Jane', + email: 'jane@example.com', + password: 'short', + }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('email send failure', () => { + it('still returns 201 even when the email service throws', async () => { + User.findOne.mockResolvedValue(null); + sendEmail.mockRejectedValue(new Error('SMTP unavailable')); + + const response = await request(app) + .post('/api/auth/register') + .send(validBody) + .expect(201); + + // Registration succeeds — the token is stored in the DB even if email fails + expect(response.body.success).toBe(true); + expect(response.body.data.user.email).toBe('jane@example.com'); + }); + }); +}); diff --git a/src/__tests__/auth.verify.test.js b/src/__tests__/auth.verify.test.js new file mode 100644 index 0000000..d9c420c --- /dev/null +++ b/src/__tests__/auth.verify.test.js @@ -0,0 +1,108 @@ +const request = require('supertest'); + +// Mock User model before requiring app so the mock is in place at module load time +jest.mock('../models/User.model', () => ({ + findOne: jest.fn(), +})); + +const User = require('../models/User.model'); +const app = require('../app'); + +describe('GET /api/auth/verify-email/:token', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('successful verification', () => { + it('returns 200 and marks the user as verified', async () => { + const token = 'a'.repeat(64); // 64 hex chars + const user = { + isVerified: false, + emailVerificationToken: token, + emailVerificationExpires: new Date(Date.now() + 60_000), + save: jest.fn().mockResolvedValue(undefined), + }; + + User.findOne.mockResolvedValue(user); + + const response = await request(app) + .get(`/api/auth/verify-email/${token}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.message).toBe( + 'Email verified successfully. You can now log in.' + ); + + // Verify the model was queried with the correct token and expiry check + expect(User.findOne).toHaveBeenCalledWith({ + emailVerificationToken: token, + emailVerificationExpires: { $gt: expect.any(Date) }, + }); + + // Verify the user fields were updated before save + expect(user.isVerified).toBe(true); + expect(user.emailVerificationToken).toBeNull(); + expect(user.emailVerificationExpires).toBeNull(); + expect(user.save).toHaveBeenCalledTimes(1); + }); + }); + + describe('invalid token', () => { + it('returns 400 when no user matches the token', async () => { + User.findOne.mockResolvedValue(null); + + const response = await request(app) + .get('/api/auth/verify-email/invalidtoken123') + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe( + 'Invalid or expired verification token' + ); + }); + }); + + describe('expired token', () => { + it('returns 400 when the token exists but has already expired', async () => { + // When the token is expired, the $gt query returns null — simulate that + User.findOne.mockResolvedValue(null); + + const expiredToken = 'b'.repeat(64); + + const response = await request(app) + .get(`/api/auth/verify-email/${expiredToken}`) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe( + 'Invalid or expired verification token' + ); + + // Confirm the expiry filter was passed to the query + expect(User.findOne).toHaveBeenCalledWith({ + emailVerificationToken: expiredToken, + emailVerificationExpires: { $gt: expect.any(Date) }, + }); + }); + }); + + describe('already verified token (token cleared)', () => { + it('returns 400 when token fields have already been nulled out', async () => { + // After a successful verification the token is set to null on the document, + // so a second attempt finds no matching document. + User.findOne.mockResolvedValue(null); + + const usedToken = 'c'.repeat(64); + + const response = await request(app) + .get(`/api/auth/verify-email/${usedToken}`) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe( + 'Invalid or expired verification token' + ); + }); + }); +}); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 8352933..9fae7c4 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -3,6 +3,8 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const User = require('../models/User.model'); const { sendSuccess } = require('../utils/response'); +const { sendEmail } = require('../services/email.service'); +const emailVerificationTemplate = require('../services/templates/emailVerification.template'); /** * Logout user by invalidating refresh token @@ -33,7 +35,7 @@ const logout = async (req, res, next) => { }; /** - * Register a new user + * Register a new user and send a verification email * POST /api/auth/register */ const register = async (req, res, next) => { @@ -49,29 +51,41 @@ const register = async (req, res, next) => { return next(error); } - // Generate email verification token - const verificationToken = crypto.randomBytes(32).toString('hex'); - const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + // Generate a secure random email verification token (32 bytes = 64 hex chars) + const emailVerificationToken = crypto.randomBytes(32).toString('hex'); + const emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - // Create new user + // Create new user with verification token fields const user = new User({ fullName, email: email.toLowerCase(), password, - verificationToken, - verificationTokenExpires, + emailVerificationToken, + emailVerificationExpires, }); await user.save(); - // Return user info (exclude password) + // Send verification email (non-blocking — registration still succeeds if email fails) + try { + const verificationLink = `${process.env.APP_BASE_URL || 'http://localhost:3000'}/api/auth/verify-email/${emailVerificationToken}`; + await sendEmail({ + to: user.email, + subject: 'Verify your email address', + html: emailVerificationTemplate(user.fullName, verificationLink), + }); + } catch (emailError) { + // Log but don't fail the registration — the token is stored and can be resent + console.error('Failed to send verification email:', emailError.message); + } + + // Return user info (exclude password and internal token fields) const userResponse = { id: user._id, fullName: user.fullName, email: user.email, role: user.role, isVerified: user.isVerified, - verificationToken, createdAt: user.createdAt, }; @@ -86,6 +100,45 @@ const register = async (req, res, next) => { } }; +/** + * Verify email address using token from verification email + * GET /api/auth/verify-email/:token + */ +const verifyEmail = async (req, res, next) => { + try { + const { token } = req.params; + + // Find user with a matching, non-expired verification token + const user = await User.findOne({ + emailVerificationToken: token, + emailVerificationExpires: { $gt: new Date() }, + }); + + if (!user) { + const error = new Error('Invalid or expired verification token'); + error.statusCode = 400; + error.isOperational = true; + return next(error); + } + + // Mark email as verified and clear the token fields + user.isVerified = true; + user.emailVerificationToken = null; + user.emailVerificationExpires = null; + + await user.save(); + + return sendSuccess( + res, + {}, + 200, + 'Email verified successfully. You can now log in.' + ); + } catch (error) { + return next(error); + } +}; + /** * Login user and issue access + refresh tokens * POST /api/auth/login @@ -95,7 +148,11 @@ const login = async (req, res, next) => { const { email, password } = req.body; const normalizedEmail = email.toLowerCase(); - const user = await User.findOne({ email: normalizedEmail }).select('+password +refreshTokenHash'); + const user = await User.findOne({ email: normalizedEmail }).select( + '+password +refreshTokenHash' + ); + + // Reject if user does not exist or has not verified their email if (!user || !user.isVerified) { const error = new Error('Invalid credentials'); error.statusCode = 401; @@ -115,7 +172,12 @@ const login = async (req, res, next) => { const refreshTokenExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; const accessToken = jwt.sign( - { sub: user._id.toString(), email: user.email, role: user.role, type: 'access' }, + { + sub: user._id.toString(), + email: user.email, + role: user.role, + type: 'access', + }, process.env.JWT_SECRET, { expiresIn: accessTokenExpiresIn } ); @@ -125,8 +187,11 @@ const login = async (req, res, next) => { process.env.JWT_REFRESH_SECRET, { expiresIn: refreshTokenExpiresIn } ); - - const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + + const refreshTokenHash = crypto + .createHash('sha256') + .update(refreshToken) + .digest('hex'); const decodedRefreshToken = jwt.decode(refreshToken); user.refreshTokenHash = refreshTokenHash; @@ -211,4 +276,5 @@ module.exports = { login, logout, resetPassword, + verifyEmail, }; diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js index 880fd78..ea1c47b 100644 --- a/src/middlewares/auth.middleware.js +++ b/src/middlewares/auth.middleware.js @@ -9,7 +9,7 @@ const authMiddleware = (req, res, next) => { try { // Get token from Authorization header const authHeader = req.header('Authorization'); - + if (!authHeader) { return res.status(401).json({ success: false, @@ -37,10 +37,10 @@ const authMiddleware = (req, res, next) => { // Verify and decode the JWT token const decoded = jwt.verify(token, process.env.JWT_SECRET); - + // Attach user information to request object req.user = decoded; - + // Continue to the next middleware or route handler next(); } catch (error) { @@ -51,11 +51,11 @@ const authMiddleware = (req, res, next) => { message: 'Access denied. Invalid token.', }); } - + if (error.name === 'TokenExpiredError') { return res.status(401).json({ success: false, - message: 'Access denied. Token expired.', + message: 'Access denied. Token expired. Please log in again.', }); } diff --git a/src/models/User.model.js b/src/models/User.model.js index cceaa20..fe98d81 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -35,13 +35,13 @@ const userSchema = new mongoose.Schema( type: Boolean, default: false, }, - // Email verification token - verificationToken: { + // Secure random token sent to user's email for verification + emailVerificationToken: { type: String, default: null, }, - // Token expiration date - verificationTokenExpires: { + // Expiry date for the email verification token (24 hours from registration) + emailVerificationExpires: { type: Date, default: null, }, diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 2fa51f7..ec6f37e 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -1,8 +1,18 @@ const express = require('express'); -const { register, login, logout, resetPassword } = require('../controllers/auth.controller'); +const { + register, + login, + logout, + resetPassword, + verifyEmail, +} = require('../controllers/auth.controller'); const validate = require('../middlewares/validate'); const authenticate = require('../middlewares/auth'); -const { registerSchema, loginSchema, resetPasswordSchema } = require('../validators/auth.validators'); +const { + registerSchema, + loginSchema, + resetPasswordSchema, +} = require('../validators/auth.validators'); const router = express.Router(); @@ -14,7 +24,15 @@ router.post('/login', validate(loginSchema), login); // POST /api/auth/logout - Logout user (requires authentication) router.post('/logout', authenticate, logout); + // PATCH /api/auth/reset-password/:token - Reset user password with token -router.patch('/reset-password/:token', validate(resetPasswordSchema), resetPassword); +router.patch( + '/reset-password/:token', + validate(resetPasswordSchema), + resetPassword +); + +// GET /api/auth/verify-email/:token - Verify email address using token from verification email +router.get('/verify-email/:token', verifyEmail); module.exports = router; diff --git a/src/utils/logger.js b/src/utils/logger.js index f50f4e4..b9d75c6 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -9,12 +9,28 @@ */ const getLoggerStream = () => { return { - write: (message) => { + write: message => { if (message && typeof message === 'string') { console.log(message.trim()); } - } + }, }; }; -module.exports = { getLoggerStream }; +/** + * Logs an informational message + * @param {...any} args - Message and optional metadata + */ +const info = (...args) => { + console.log(...args); +}; + +/** + * Logs an error message + * @param {...any} args - Message and optional metadata + */ +const error = (...args) => { + console.error(...args); +}; + +module.exports = { getLoggerStream, info, error };