From 438d04636aa3fbaa08667b447f19eee2e5bca713 Mon Sep 17 00:00:00 2001 From: arandomogg <139588083+arandomogg@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:26:49 +0100 Subject: [PATCH] feat: implement user registration API endpoint Add a POST /api/v1/auth/register endpoint following the Controller -> Service -> Model architecture. Passwords are securely hashed with bcrypt and the hash is never returned to clients. - User model with schema validation, a unique email index, a bcrypt pre-save hook, a comparePassword method, and a toJSON transform that strips the password hash and exposes id instead of _id - authService.registerUser handles duplicate-email detection (including the unique-index race condition) and returns sanitized user data - authController.register validates input and returns 201 with the user - authValidator performs strict, typed validation of the request body - ApiError and asyncHandler utilities for consistent error handling - Wire auth routes under /api/v1/auth - Integration tests covering success, duplicate email, and validation Closes #9 --- src/controllers/authController.ts | 25 +++++++ src/models/User.ts | 98 ++++++++++++++++++++++++++++ src/routes/authRoutes.ts | 13 ++++ src/routes/index.ts | 5 +- src/services/authService.ts | 38 +++++++++++ src/utils/ApiError.ts | 28 ++++++++ src/utils/asyncHandler.ts | 15 +++++ src/validators/authValidator.ts | 49 ++++++++++++++ tests/auth.test.ts | 105 ++++++++++++++++++++++++++++++ 9 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 src/controllers/authController.ts create mode 100644 src/models/User.ts create mode 100644 src/routes/authRoutes.ts create mode 100644 src/services/authService.ts create mode 100644 src/utils/ApiError.ts create mode 100644 src/utils/asyncHandler.ts create mode 100644 src/validators/authValidator.ts create mode 100644 tests/auth.test.ts diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts new file mode 100644 index 0000000..8215b37 --- /dev/null +++ b/src/controllers/authController.ts @@ -0,0 +1,25 @@ +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { registerUser } from '../services/authService'; +import { validateRegisterInput } from '../validators/authValidator'; +import asyncHandler from '../utils/asyncHandler'; + +/** + * POST /api/v1/auth/register + * + * Registers a new user. The request body is validated, the password is + * securely hashed by the model layer, and the created user is returned + * without the password hash. + */ +export const register = asyncHandler(async (req: Request, res: Response): Promise => { + const input = validateRegisterInput(req.body); + const user = await registerUser(input); + + res.status(StatusCodes.CREATED).json({ + status: 'success', + message: 'User registered successfully', + data: { user }, + }); +}); + +export default { register }; diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..47f9598 --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,98 @@ +import { Schema, model, type Document, type Model } from 'mongoose'; +import bcrypt from 'bcryptjs'; + +const DEFAULT_BCRYPT_ROUNDS = 10; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export type UserRole = 'user' | 'admin'; + +/** + * Plain user properties as stored in the database. + */ +export interface IUser { + name: string; + email: string; + password: string; + role: UserRole; + createdAt: Date; + updatedAt: Date; +} + +/** + * Mongoose document for a user, including instance methods. + */ +export interface IUserDocument extends IUser, Document { + comparePassword(candidate: string): Promise; +} + +const userSchema = new Schema( + { + name: { + type: String, + required: [true, 'Name is required'], + trim: true, + minlength: [2, 'Name must be at least 2 characters long'], + maxlength: [100, 'Name must not exceed 100 characters'], + }, + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + trim: true, + lowercase: true, + match: [EMAIL_REGEX, 'A valid email address is required'], + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [8, 'Password must be at least 8 characters long'], + select: false, + }, + role: { + type: String, + enum: ['user', 'admin'], + default: 'user', + }, + }, + { + timestamps: true, + toJSON: { + virtuals: true, + transform: (_doc, ret): Record => { + ret.id = ret._id?.toString(); + delete ret._id; + delete ret.password; + delete ret.__v; + return ret; + }, + }, + }, +); + +/** + * Hash the password with bcrypt before persisting whenever it changes. + */ +userSchema.pre('save', async function hashPassword(next) { + if (!this.isModified('password')) { + next(); + return; + } + + const rounds = Number(process.env.BCRYPT_ROUNDS) || DEFAULT_BCRYPT_ROUNDS; + const salt = await bcrypt.genSalt(rounds); + this.password = await bcrypt.hash(this.password, salt); + next(); +}); + +/** + * Compare a plaintext candidate password against the stored hash. + */ +userSchema.methods.comparePassword = async function comparePassword( + candidate: string, +): Promise { + return bcrypt.compare(candidate, this.password); +}; + +const User: Model = model('User', userSchema); + +export default User; diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts new file mode 100644 index 0000000..a8a5aae --- /dev/null +++ b/src/routes/authRoutes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { register } from '../controllers/authController'; + +const router = Router(); + +/** + * @route POST /api/v1/auth/register + * @desc Register a new user + * @access Public + */ +router.post('/register', register); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 68e0f11..88f39cf 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,9 +1,8 @@ import { Router } from 'express'; +import authRoutes from './authRoutes'; const router = Router(); -// Define your routes here -// router.use('/auth', authRoutes); -// router.use('/users', userRoutes); +router.use('/auth', authRoutes); export default router; diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..6807899 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,38 @@ +import User, { type IUser } from '../models/User'; +import ApiError from '../utils/ApiError'; +import type { RegisterInput } from '../validators/authValidator'; + +/** + * Public-facing representation of a user, with the password hash removed. + */ +export type SafeUser = Omit & { id: string }; + +/** + * Register a new user. + * + * Persists the user (the password is hashed by the model's pre-save hook) + * and returns a sanitized representation that never exposes the hash. + * + * @throws {ApiError} 409 if a user with the same email already exists. + */ +export const registerUser = async (input: RegisterInput): Promise => { + const existingUser = await User.findOne({ email: input.email }).lean().exec(); + if (existingUser) { + throw ApiError.conflict('A user with this email already exists'); + } + + try { + const user = await User.create(input); + // `toJSON` strips the password hash and internal fields. + return user.toJSON() as unknown as SafeUser; + } catch (error) { + // Guard against a race condition where the unique index rejects a + // concurrent insert after the existence check above. + if (error instanceof Error && 'code' in error && error.code === 11000) { + throw ApiError.conflict('A user with this email already exists'); + } + throw error; + } +}; + +export default { registerUser }; diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts new file mode 100644 index 0000000..168654f --- /dev/null +++ b/src/utils/ApiError.ts @@ -0,0 +1,28 @@ +/** + * Application-level error carrying an HTTP status code. + * + * Thrown by services/controllers and translated into a structured JSON + * response by the global error handler middleware. + */ +export class ApiError extends Error { + public readonly statusCode: number; + public readonly isOperational: boolean; + + constructor(statusCode: number, message: string, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + this.name = new.target.name; + Error.captureStackTrace(this, this.constructor); + } + + static badRequest(message: string): ApiError { + return new ApiError(400, message); + } + + static conflict(message: string): ApiError { + return new ApiError(409, message); + } +} + +export default ApiError; diff --git a/src/utils/asyncHandler.ts b/src/utils/asyncHandler.ts new file mode 100644 index 0000000..c9a0515 --- /dev/null +++ b/src/utils/asyncHandler.ts @@ -0,0 +1,15 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; + +/** + * Wraps an async Express route handler so that any rejected promise is + * forwarded to the global error-handling middleware via `next()`. + */ +const asyncHandler = + ( + handler: (req: Request, res: Response, next: NextFunction) => Promise, + ): RequestHandler => + (req, res, next) => { + handler(req, res, next).catch(next); + }; + +export default asyncHandler; diff --git a/src/validators/authValidator.ts b/src/validators/authValidator.ts new file mode 100644 index 0000000..df6f80a --- /dev/null +++ b/src/validators/authValidator.ts @@ -0,0 +1,49 @@ +import ApiError from '../utils/ApiError'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 8; +const MIN_NAME_LENGTH = 2; + +export interface RegisterInput { + name: string; + email: string; + password: string; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Validate and normalize the registration request body. + * + * Throws an {@link ApiError} (400) describing the first validation failure. + */ +export const validateRegisterInput = (body: unknown): RegisterInput => { + if (typeof body !== 'object' || body === null) { + throw ApiError.badRequest('Request body must be a JSON object'); + } + + const { name, email, password } = body as Record; + + if (!isNonEmptyString(name) || name.trim().length < MIN_NAME_LENGTH) { + throw ApiError.badRequest( + `Name is required and must be at least ${MIN_NAME_LENGTH} characters`, + ); + } + + if (!isNonEmptyString(email) || !EMAIL_REGEX.test(email.trim())) { + throw ApiError.badRequest('A valid email address is required'); + } + + if (!isNonEmptyString(password) || password.length < MIN_PASSWORD_LENGTH) { + throw ApiError.badRequest( + `Password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`, + ); + } + + return { + name: name.trim(), + email: email.trim().toLowerCase(), + password, + }; +}; diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..661f6fa --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,105 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Express } from 'express'; + +// Mock the database connection so app.ts does not open its own connection; +// the in-memory server is connected explicitly below. +jest.mock('../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +// Mock the logger to keep test output clean +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +})); + +let app: Express; +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + const mod = await import('../src/app'); + app = mod.default; +}); + +afterEach(async () => { + await mongoose.connection.collection('users').deleteMany({}); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +const validUser = { + name: 'Ada Lovelace', + email: 'ada@example.com', + password: 'sup3rSecret!', +}; + +describe('POST /api/v1/auth/register', () => { + it('registers a new user and returns sanitized data', async () => { + const res = await request(app).post('/api/v1/auth/register').send(validUser); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('status', 'success'); + expect(res.body.data.user).toMatchObject({ + name: validUser.name, + email: validUser.email, + role: 'user', + }); + expect(res.body.data.user).toHaveProperty('id'); + // The password hash must never be returned + expect(res.body.data.user).not.toHaveProperty('password'); + }); + + it('persists the user with a bcrypt-hashed password', async () => { + await request(app).post('/api/v1/auth/register').send(validUser); + + const stored = await mongoose.connection + .collection('users') + .findOne({ email: validUser.email }); + + expect(stored).not.toBeNull(); + expect(stored?.password).toBeDefined(); + expect(stored?.password).not.toBe(validUser.password); + expect(stored?.password).toMatch(/^\$2[aby]\$/); // bcrypt hash prefix + }); + + it('rejects a duplicate email with 409', async () => { + await request(app).post('/api/v1/auth/register').send(validUser); + const res = await request(app).post('/api/v1/auth/register').send(validUser); + + expect(res.status).toBe(409); + expect(res.body).toHaveProperty('status', 'error'); + }); + + it('rejects an invalid email with 400', async () => { + const res = await request(app) + .post('/api/v1/auth/register') + .send({ ...validUser, email: 'not-an-email' }); + + expect(res.status).toBe(400); + }); + + it('rejects a weak password with 400', async () => { + const res = await request(app) + .post('/api/v1/auth/register') + .send({ ...validUser, password: 'short' }); + + expect(res.status).toBe(400); + }); + + it('rejects a missing name with 400', async () => { + const res = await request(app) + .post('/api/v1/auth/register') + .send({ email: validUser.email, password: validUser.password }); + + expect(res.status).toBe(400); + }); +});