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
25 changes: 25 additions & 0 deletions src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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 };
98 changes: 98 additions & 0 deletions src/models/User.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

const userSchema = new Schema<IUserDocument>(
{
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<string, unknown> => {
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<IUserDocument>('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<boolean> {
return bcrypt.compare(candidate, this.password);
};

const User: Model<IUserDocument> = model<IUserDocument>('User', userSchema);

export default User;
13 changes: 13 additions & 0 deletions src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 2 additions & 3 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions src/services/authService.ts
Original file line number Diff line number Diff line change
@@ -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<IUser, 'password'> & { 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<SafeUser> => {
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 };
28 changes: 28 additions & 0 deletions src/utils/ApiError.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions src/utils/asyncHandler.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>,
): RequestHandler =>
(req, res, next) => {
handler(req, res, next).catch(next);
};

export default asyncHandler;
49 changes: 49 additions & 0 deletions src/validators/authValidator.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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,
};
};
105 changes: 105 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});