diff --git a/lint_output.json b/lint_output.json new file mode 100644 index 0000000..c716141 Binary files /dev/null and b/lint_output.json differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9ddd7d2..b2e0274 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,14 +9,18 @@ datasource db { model User { id String @id @default(uuid()) email String @unique - name String + username String @unique + password String + role Role @default(LEARNER) walletAddress String? @unique - passwordHash String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + lastLoginAt DateTime? completions Completion[] credentials Credential[] transactions Transaction[] + + @@map("users") } model Module { @@ -71,6 +75,14 @@ model Transaction { updatedAt DateTime @updatedAt } +enum Role { + ADMIN + LEARNER + INSTRUCTOR +} + + + model WebhookEndpoint { id String @id @default(uuid()) url String diff --git a/src/config/database.ts b/src/config/database.ts index 067616d..719caf2 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -1,9 +1,14 @@ import { PrismaClient } from '@prisma/client' const globalForPrisma = globalThis as unknown as { - prisma: any | undefined + prisma: PrismaClient | undefined } -export const prisma = globalForPrisma.prisma ?? new PrismaClient() +const prisma = globalForPrisma.prisma ?? new PrismaClient() -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma \ No newline at end of file +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} + +export default prisma +export { prisma } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index e69de29..bea9947 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,163 @@ +import { Request, Response } from 'express' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import prisma from '../config/database' +import { loginSchema, registerSchema } from '../schemas/auth.schema' +import { UserRole } from '../types/user.types' + +const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret' +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' + +export class AuthController { + /** + * @route POST /api/v1/auth/register + * @desc Register a new user + * @access Public + */ + async register(req: Request, res: Response): Promise { + try { + // Validate input + const validation = registerSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const { email, password, username, role } = validation.data + + // Check if user already exists + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { email }, + { username } + ] + } + }) + + if (existingUser) { + res.status(409).json({ error: 'User with this email or username already exists' }) + + return + } + + // Hash password + const salt = await bcrypt.genSalt(10) + const hashedPassword = await bcrypt.hash(password, salt) + + // Create user + const user = await prisma.user.create({ + data: { + email, + username, + password: hashedPassword, + role: (role as any) || UserRole.LEARNER, + } + }) + + // Generate token + const token = this.generateToken(user.id, user.role) + + res.status(201).json({ + message: 'User registered successfully', + token, + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role + } + }) + } catch (error) { + console.error('Registration error:', error) + res.status(500).json({ error: 'Internal server error during registration' }) + } + } + + /** + * @route POST /api/v1/auth/login + * @desc Login a user + * @access Public + */ + async login(req: Request, res: Response): Promise { + try { + // Validate input + const validation = loginSchema.safeParse(req.body) + if (!validation.success) { + res.status(400).json({ + error: 'Validation failed', + details: validation.error.format() + }) + + return + } + + const { email, password } = validation.data + + // Find user + const user = await prisma.user.findUnique({ + where: { email } + }) + + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + // Verify password + const isMatch = await bcrypt.compare(password, user.password) + if (!isMatch) { + res.status(401).json({ error: 'Invalid credentials' }) + + return + } + + // Update last login + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() } + }) + + // Generate token + const token = this.generateToken(user.id, user.role) + + res.status(200).json({ + message: 'Login successful', + token, + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role + } + }) + } catch (error) { + console.error('Login error:', error) + res.status(500).json({ error: 'Internal server error during login' }) + } + } + + /** + * @route POST /api/v1/auth/logout + * @desc Logout user (client-side usually handles this by deleting token, but can track server-side) + * @access Private (optional, here public) + */ + async logout(req: Request, res: Response): Promise { + // For stateless JWT, we can't truly "logout" unless we blacklist tokens. + // For now, let's just return success message as the client will clear the token. + res.status(200).json({ message: 'Logged out successfully. Please clear your token client-side.' }) + } + + private generateToken(userId: string, role: string): string { + return jwt.sign( + { id: userId, role }, + JWT_SECRET as string, + { expiresIn: JWT_EXPIRES_IN as any } + ) + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 931519b..b7a91b8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,16 +1,30 @@ -import express, { Router } from 'express' -import userRoutes from './v1/users.routes' -import rewardRoutes from './v1/rewards.routes' -import moduleRoutes from './v1/modules.routes' - -const router: express.Router = Router() - -router.get('/', (req, res) => { - res.json({ message: 'API is running' }) -}) - -router.use('/v1/users', userRoutes) -router.use('/v1/rewards', rewardRoutes) -router.use('/v1/modules', moduleRoutes) - -export default router +import { Router } from 'express' +import userRoutes from './v1/users.routes' +import authRoutes from './v1/auth.routes' + +const router: Router = Router() + +router.get('/', (req, res) => { + res.json({ message: 'API is running' }) +}) + +router.use('/v1/users', userRoutes) +router.use('/v1/auth', authRoutes) + +export default router +import express, { Router } from 'express' +import userRoutes from './v1/users.routes' +import rewardRoutes from './v1/rewards.routes' +import moduleRoutes from './v1/modules.routes' + +const router: express.Router = Router() + +router.get('/', (req, res) => { + res.json({ message: 'API is running' }) +}) + +router.use('/v1/users', userRoutes) +router.use('/v1/rewards', rewardRoutes) +router.use('/v1/modules', moduleRoutes) + +export default router diff --git a/src/routes/v1/auth.routes.ts b/src/routes/v1/auth.routes.ts index e69de29..6c06d60 100644 --- a/src/routes/v1/auth.routes.ts +++ b/src/routes/v1/auth.routes.ts @@ -0,0 +1,28 @@ +import { Router } from 'express' +import { AuthController } from '../../controllers/auth.controller' + +const router: Router = Router() +const authController = new AuthController() + +/** + * @route POST /api/v1/auth/register + * @desc Register a new user + * @access Public + */ +router.post('/register', authController.register.bind(authController)) + +/** + * @route POST /api/v1/auth/login + * @desc Login user + * @access Public + */ +router.post('/login', authController.login.bind(authController)) + +/** + * @route POST /api/v1/auth/logout + * @desc Logout user + * @access Public + */ +router.post('/logout', authController.logout.bind(authController)) + +export default router diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts new file mode 100644 index 0000000..7b552a4 --- /dev/null +++ b/src/schemas/auth.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { UserRole } from '../types/user.types' + +export const registerSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters long'), + username: z.string().min(3, 'Username must be at least 3 characters long'), + role: z.nativeEnum(UserRole).optional().default(UserRole.LEARNER), +}) + +export const loginSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string(), +}) + +export type RegisterInput = z.infer; +export type LoginInput = z.infer; diff --git a/tests/auth.controller.test.ts b/tests/auth.controller.test.ts new file mode 100644 index 0000000..f916d4c --- /dev/null +++ b/tests/auth.controller.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { AuthController } from '../src/controllers/auth.controller' +import prisma from '../src/config/database' +import bcrypt from 'bcryptjs' +// import jwt from 'jsonwebtoken' + +// Mock dependencies +vi.mock('../src/config/database', () => ({ + default: { + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('bcryptjs', () => ({ + default: { + genSalt: vi.fn().mockResolvedValue('salt'), + hash: vi.fn().mockResolvedValue('hashed_password'), + compare: vi.fn(), + }, +})) + +vi.mock('jsonwebtoken', () => ({ + default: { + sign: vi.fn().mockReturnValue('mock_token'), + }, +})) + +describe('AuthController', () => { + let authController: AuthController + let mockRequest: Partial + let mockResponse: Partial + + beforeEach(() => { + authController = new AuthController() + mockRequest = {} + mockResponse = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + vi.clearAllMocks() + }) + + describe('register', () => { + it('should register a new user successfully', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + username: 'testuser', + }; + + (prisma.user.findFirst as any).mockResolvedValue(null); + (prisma.user.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + username: 'testuser', + role: 'LEARNER', + }) + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(prisma.user.create).toHaveBeenCalled() + expect(mockResponse.status).toHaveBeenCalledWith(201) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + message: 'User registered successfully', + token: 'mock_token', + })) + }) + + it('should return 400 for invalid input', async () => { + mockRequest.body = { + email: 'invalid-email', + password: 'short', + } + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(400) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + error: 'Validation failed', + })) + }) + + it('should return 409 if user already exists', async () => { + mockRequest.body = { + email: 'exists@example.com', + password: 'Password123!', + username: 'exists', + }; + + (prisma.user.findFirst as any).mockResolvedValue({ id: '1' }) + + await authController.register(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(409) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'User with this email or username already exists' }) + }) + }) + + describe('login', () => { + it('should login successfully with valid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'Password123!', + } + + const mockUser = { + id: '1', + email: 'test@example.com', + password: 'hashed_password', + username: 'testuser', + role: 'LEARNER', + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + (bcrypt.compare as any).mockResolvedValue(true); + (prisma.user.update as any).mockResolvedValue(mockUser) + + await authController.login(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Login successful', + token: 'mock_token', + })) + }) + + it('should return 401 for invalid credentials', async () => { + mockRequest.body = { + email: 'test@example.com', + password: 'wrong_password', + }; + + (prisma.user.findUnique as any).mockResolvedValue({ id: '1', password: 'hashed' }); + (bcrypt.compare as any).mockResolvedValue(false) + + await authController.login(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(401) + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid credentials' }) + }) + }) + + describe('logout', () => { + it('should return success message', async () => { + await authController.logout(mockRequest as Request, mockResponse as Response) + + expect(mockResponse.status).toHaveBeenCalledWith(200) + expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Logged out successfully. Please clear your token client-side.' }) + }) + }) +})