feat: implement user registration functionality

- Created registerUser server action with email/password validation
- Implemented password hashing with bcrypt (10 rounds)
- Added email uniqueness check to prevent duplicate registrations
- Created session after successful registration using NextAuth
- Added comprehensive validation for email format and password length (min 8 chars)
- Added email and password validation utilities to lib/validation.ts
- Removed PrismaAdapter from auth.ts (using JWT sessions only)
- Added vitest testing framework and configuration
- Created comprehensive unit tests for registration functionality
- All tests passing (6/6)

Requirements covered: 1.1, 1.2, 1.3, 1.4, 1.5
This commit is contained in:
2025-12-02 00:03:23 +00:00
parent 174a6601b2
commit 70121982a0
8 changed files with 1661 additions and 6 deletions

View File

@@ -0,0 +1,127 @@
/**
* Tests for user registration functionality
* This file tests the core registration logic without session creation
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
import { prisma } from '../db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
// Mock the auth module to avoid Next.js dependencies in tests
vi.mock('@/auth', () => ({
signIn: vi.fn(),
}))
// Import after mocking
import { registerUser } from '../auth-actions'
describe('registerUser', () => {
// Clean up test users after each test
afterEach(async () => {
await prisma.user.deleteMany({
where: {
email: {
contains: 'test-',
},
},
})
})
it('should create a new user with valid credentials', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const name = 'Test User'
const result = await registerUser(email, password, name)
expect(result.success).toBe(true)
if (result.success) {
expect(result.userId).toBeDefined()
expect(typeof result.userId).toBe('string')
// Verify user was created in database
const user = await prisma.user.findUnique({
where: { email },
})
expect(user).toBeDefined()
expect(user?.email).toBe(email)
expect(user?.name).toBe(name)
expect(user?.passwordHash).toBeDefined()
expect(user?.passwordHash).not.toBe(password) // Password should be hashed
}
})
it('should reject registration with duplicate email', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
// Create first user
const result1 = await registerUser(email, password)
expect(result1.success).toBe(true)
// Try to create second user with same email
const result2 = await registerUser(email, password)
expect(result2.success).toBe(false)
if (!result2.success) {
expect(result2.error).toContain('already registered')
}
})
it('should hash the password before storing', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(true)
const user = await prisma.user.findUnique({
where: { email },
})
expect(user?.passwordHash).toBeDefined()
expect(user?.passwordHash).not.toBe(password)
// Verify the hash is valid
const isValid = await bcrypt.compare(password, user!.passwordHash)
expect(isValid).toBe(true)
})
it('should reject password shorter than 8 characters', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'short'
const result = await registerUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('at least 8 characters')
}
})
it('should reject invalid email format', async () => {
const email = 'invalid-email'
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toContain('Invalid email')
}
})
it('should create user without name (optional field)', async () => {
const email = `test-${Date.now()}@example.com`
const password = 'password123'
const result = await registerUser(email, password)
expect(result.success).toBe(true)
const user = await prisma.user.findUnique({
where: { email },
})
expect(user).toBeDefined()
expect(user?.name).toBeNull()
})
})

83
lib/auth-actions.ts Normal file
View File

@@ -0,0 +1,83 @@
'use server'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { signIn } from '@/auth'
// Validation schemas
const registerSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
name: z.string().optional(),
})
export type RegisterResult =
| { success: true; userId: string }
| { success: false; error: string }
/**
* Register a new user with email and password
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
*/
export async function registerUser(
email: string,
password: string,
name?: string
): Promise<RegisterResult> {
try {
// Validate input (Requirement 1.5 - password length validation)
const validation = registerSchema.safeParse({ email, password, name })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { email: validEmail, password: validPassword, name: validName } = validation.data
// Check if email already exists (Requirement 1.2 - duplicate email rejection)
const existingUser = await prisma.user.findUnique({
where: { email: validEmail },
})
if (existingUser) {
return { success: false, error: 'Email already registered' }
}
// Hash password (Requirement 1.3 - password hashing)
const passwordHash = await bcrypt.hash(validPassword, 10)
// Create user (Requirement 1.1 - create user with unique identifier)
const user = await prisma.user.create({
data: {
email: validEmail,
passwordHash,
name: validName || null,
},
})
// Create session after successful registration (Requirement 1.4)
try {
await signIn('credentials', {
email: validEmail,
password: validPassword,
redirect: false,
})
} catch (error) {
// If session creation fails, still return success since user was created
console.error('Session creation failed after registration:', error)
}
return { success: true, userId: user.id }
} catch (error) {
console.error('Registration error:', error)
// Handle unique constraint violation (shouldn't happen due to check above, but just in case)
if (error instanceof Error && error.message.includes('Unique constraint')) {
return { success: false, error: 'Email already registered' }
}
return { success: false, error: 'Registration failed. Please try again.' }
}
}

View File

@@ -301,3 +301,46 @@ export function validateItemData(data: {
},
}
}
/**
* Validate email format
*/
export function validateEmail(email: string): ValidationResult {
const sanitized = sanitizeString(email)
if (!sanitized) {
return { isValid: false, error: 'Email is required' }
}
// Basic email regex pattern
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(sanitized)) {
return { isValid: false, error: 'Invalid email format' }
}
if (sanitized.length > 255) {
return { isValid: false, error: 'Email must be less than 255 characters' }
}
return { isValid: true }
}
/**
* Validate password strength
*/
export function validatePassword(password: string): ValidationResult {
if (!password) {
return { isValid: false, error: 'Password is required' }
}
if (password.length < 8) {
return { isValid: false, error: 'Password must be at least 8 characters' }
}
if (password.length > 128) {
return { isValid: false, error: 'Password must be less than 128 characters' }
}
return { isValid: true }
}