feat: delete users - not tested
This commit is contained in:
162
backend/src/features/auth/README.md
Normal file
162
backend/src/features/auth/README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Auth Feature
|
||||
|
||||
User signup and email verification workflow using Auth0.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature provides API endpoints for user registration and email verification management. It integrates with Auth0 for authentication and manages user profiles in the local database.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **API Layer**: Controllers and routes for HTTP request/response handling
|
||||
- **Domain Layer**: Business logic in AuthService
|
||||
- **Integration**: Auth0 Management API client and UserProfileRepository
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/auth/signup (Public)
|
||||
Create a new user account. Auth0 automatically sends verification email upon account creation.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Email: Valid email format required
|
||||
- Password: Minimum 8 characters, at least one uppercase letter and one number
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"userId": "auth0|123456",
|
||||
"email": "user@example.com",
|
||||
"message": "Account created successfully. Please check your email to verify your account."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 400: Invalid email or weak password
|
||||
- 409: Email already exists
|
||||
- 500: Auth0 API error or database error
|
||||
|
||||
---
|
||||
|
||||
### GET /api/auth/verify-status (Protected)
|
||||
Check email verification status. Updates local database if status changed in Auth0.
|
||||
|
||||
**Authentication:** Requires JWT
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"emailVerified": true,
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Unauthorized (no JWT or invalid JWT)
|
||||
- 500: Auth0 API error
|
||||
|
||||
---
|
||||
|
||||
### POST /api/auth/resend-verification (Protected)
|
||||
Resend email verification. Skips if email is already verified.
|
||||
|
||||
**Authentication:** Requires JWT
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"message": "Verification email sent. Please check your inbox."
|
||||
}
|
||||
```
|
||||
|
||||
or if already verified:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Email is already verified"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Unauthorized (no JWT or invalid JWT)
|
||||
- 500: Auth0 API error
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Signup Flow
|
||||
1. Validate email and password format
|
||||
2. Create user in Auth0 via Management API
|
||||
3. Auth0 automatically sends verification email
|
||||
4. Create local user profile with `emailVerified=false`
|
||||
5. Return success with user ID
|
||||
|
||||
### Verify Status Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Query Auth0 Management API for `email_verified` status
|
||||
3. Update local database if status changed
|
||||
4. Return current verification status
|
||||
|
||||
### Resend Verification Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Check if already verified (skip if true)
|
||||
3. Call Auth0 Management API to resend verification email
|
||||
4. Return success message
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Auth0 Management API**: User creation, verification status, resend email
|
||||
- **User Profile Repository**: Local user profile management
|
||||
- **Core Logger**: Structured logging for all operations
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Validation errors** (400): Invalid email format, weak password
|
||||
- **Conflict errors** (409): Email already exists in Auth0
|
||||
- **Unauthorized** (401): Missing or invalid JWT for protected endpoints
|
||||
- **Server errors** (500): Auth0 API failures, database errors
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
Location: `tests/unit/auth.service.test.ts`
|
||||
|
||||
Tests business logic with mocked Auth0 client and repository:
|
||||
- User creation success and failure scenarios
|
||||
- Email verification status retrieval and updates
|
||||
- Resend verification logic
|
||||
|
||||
### Integration Tests
|
||||
Location: `tests/integration/auth.integration.test.ts`
|
||||
|
||||
Tests complete API workflows with test database:
|
||||
- Signup with valid and invalid inputs
|
||||
- Verification status checks
|
||||
- Resend verification email
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
npm test -- features/auth
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Auth0 Management API client (`core/auth/auth0-management.client.ts`)
|
||||
- UserProfileRepository (`features/user-profile/data/user-profile.repository.ts`)
|
||||
- Core logger (`core/logging/logger.ts`)
|
||||
- Database pool (`core/config/database.ts`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Auth0 Management API credentials are configured via environment variables:
|
||||
- `AUTH0_DOMAIN`
|
||||
- `AUTH0_MANAGEMENT_CLIENT_ID`
|
||||
- `AUTH0_MANAGEMENT_CLIENT_SECRET`
|
||||
|
||||
See `core/config/config-loader.ts` for configuration details.
|
||||
122
backend/src/features/auth/api/auth.controller.ts
Normal file
122
backend/src/features/auth/api/auth.controller.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for auth API
|
||||
* @ai-context HTTP request/response handling with Fastify reply methods
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { AuthService } from '../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { signupSchema } from './auth.validation';
|
||||
|
||||
export class AuthController {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
this.authService = new AuthService(userProfileRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* Create new user account
|
||||
* Public endpoint - no JWT required
|
||||
*/
|
||||
async signup(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const validation = signupSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: validation.error.errors[0]?.message || 'Invalid input',
|
||||
details: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
const result = await this.authService.signup({ email, password });
|
||||
|
||||
logger.info('User signup successful', { email, userId: result.userId });
|
||||
|
||||
return reply.code(201).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Signup failed', { error, email: (request.body as any)?.email });
|
||||
|
||||
if (error.message === 'Email already exists') {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'An account with this email already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Auth0 API errors
|
||||
if (error.message?.includes('Auth0')) {
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to create account. Please try again later.',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to create account',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/verify-status
|
||||
* Check email verification status
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.getVerifyStatus(userId);
|
||||
|
||||
logger.info('Verification status checked', { userId, emailVerified: result.emailVerified });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get verification status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to check verification status',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/resend-verification
|
||||
* Resend verification email
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.resendVerification(userId);
|
||||
|
||||
logger.info('Verification email resent', { userId });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to resend verification email', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to resend verification email',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
30
backend/src/features/auth/api/auth.routes.ts
Normal file
30
backend/src/features/auth/api/auth.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for auth API
|
||||
* @ai-context Route definitions with Zod validation and authentication
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const authController = new AuthController();
|
||||
|
||||
// POST /api/auth/signup - Create new user (public, no JWT required)
|
||||
fastify.post('/auth/signup', authController.signup.bind(authController));
|
||||
|
||||
// GET /api/auth/verify-status - Check verification status (requires JWT)
|
||||
fastify.get('/auth/verify-status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.getVerifyStatus.bind(authController),
|
||||
});
|
||||
|
||||
// POST /api/auth/resend-verification - Resend verification email (requires JWT)
|
||||
fastify.post('/auth/resend-verification', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.resendVerification.bind(authController),
|
||||
});
|
||||
};
|
||||
23
backend/src/features/auth/api/auth.validation.ts
Normal file
23
backend/src/features/auth/api/auth.validation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for auth API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Password requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least one uppercase letter
|
||||
// - At least one number
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number');
|
||||
|
||||
export const signupSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
export type SignupInput = z.infer<typeof signupSchema>;
|
||||
130
backend/src/features/auth/domain/auth.service.ts
Normal file
130
backend/src/features/auth/domain/auth.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @ai-summary Auth service business logic
|
||||
* @ai-context Coordinates between Auth0 Management API and local user profile database
|
||||
*/
|
||||
|
||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
} from './auth.types';
|
||||
|
||||
export class AuthService {
|
||||
constructor(private userProfileRepository: UserProfileRepository) {}
|
||||
|
||||
/**
|
||||
* Create a new user account
|
||||
* 1. Create user in Auth0 (which automatically sends verification email)
|
||||
* 2. Create local user profile with emailVerified=false
|
||||
*/
|
||||
async signup(request: SignupRequest): Promise<SignupResponse> {
|
||||
const { email, password } = request;
|
||||
|
||||
try {
|
||||
// Create user in Auth0 Management API
|
||||
// Auth0 automatically sends verification email on user creation
|
||||
const auth0UserId = await auth0ManagementClient.createUser({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info('Auth0 user created', { auth0UserId, email });
|
||||
|
||||
// Create local user profile
|
||||
const userProfile = await this.userProfileRepository.create(
|
||||
auth0UserId,
|
||||
email,
|
||||
undefined // displayName is optional
|
||||
);
|
||||
|
||||
logger.info('User profile created', { userId: userProfile.id, email });
|
||||
|
||||
return {
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: 'Account created successfully. Please check your email to verify your account.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Signup failed', { email, error });
|
||||
|
||||
// Check for duplicate email error from Auth0
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email verification status
|
||||
* Queries Auth0 for current verification status and updates local database if changed
|
||||
*/
|
||||
async getVerifyStatus(auth0Sub: string): Promise<VerifyStatusResponse> {
|
||||
try {
|
||||
// Get user details from Auth0
|
||||
const userDetails = await auth0ManagementClient.getUser(auth0Sub);
|
||||
|
||||
logger.info('Retrieved user verification status from Auth0', {
|
||||
auth0Sub,
|
||||
emailVerified: userDetails.emailVerified,
|
||||
});
|
||||
|
||||
// Update local database if verification status changed
|
||||
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
|
||||
|
||||
if (localProfile && localProfile.emailVerified !== userDetails.emailVerified) {
|
||||
await this.userProfileRepository.updateEmailVerified(
|
||||
auth0Sub,
|
||||
userDetails.emailVerified
|
||||
);
|
||||
logger.info('Local email verification status updated', {
|
||||
auth0Sub,
|
||||
emailVerified: userDetails.emailVerified,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
emailVerified: userDetails.emailVerified,
|
||||
email: userDetails.email,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get verification status', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
* Calls Auth0 Management API to trigger verification email
|
||||
*/
|
||||
async resendVerification(auth0Sub: string): Promise<ResendVerificationResponse> {
|
||||
try {
|
||||
// Check if already verified
|
||||
const verified = await auth0ManagementClient.checkEmailVerified(auth0Sub);
|
||||
|
||||
if (verified) {
|
||||
logger.info('Email already verified, skipping resend', { auth0Sub });
|
||||
return {
|
||||
message: 'Email is already verified',
|
||||
};
|
||||
}
|
||||
|
||||
// Request Auth0 to resend verification email
|
||||
await auth0ManagementClient.resendVerificationEmail(auth0Sub);
|
||||
|
||||
logger.info('Verification email resent', { auth0Sub });
|
||||
|
||||
return {
|
||||
message: 'Verification email sent. Please check your inbox.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend verification email', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
backend/src/features/auth/domain/auth.types.ts
Normal file
28
backend/src/features/auth/domain/auth.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @ai-summary Auth domain types
|
||||
* @ai-context Type definitions for authentication and signup workflows
|
||||
*/
|
||||
|
||||
// Request to create a new user account
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Response from signup endpoint
|
||||
export interface SignupResponse {
|
||||
userId: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Response from verify status endpoint
|
||||
export interface VerifyStatusResponse {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Response from resend verification endpoint
|
||||
export interface ResendVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
18
backend/src/features/auth/index.ts
Normal file
18
backend/src/features/auth/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Public API for auth feature capsule
|
||||
* @ai-note This is the ONLY file other features should import from
|
||||
*/
|
||||
|
||||
// Export service for use by other features (if needed)
|
||||
export { AuthService } from './domain/auth.service';
|
||||
|
||||
// Export types needed by other features
|
||||
export type {
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
} from './domain/auth.types';
|
||||
|
||||
// Internal: Register routes with Fastify app
|
||||
export { authRoutes } from './api/auth.routes';
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @ai-summary Integration tests for auth API endpoints
|
||||
* @ai-context Tests complete request/response cycle with mocked Auth0
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { buildApp } from '../../../../app';
|
||||
import { pool } from '../../../../core/config/database';
|
||||
import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
// Mock Auth0 Management client
|
||||
jest.mock('../../../../core/auth/auth0-management.client');
|
||||
const mockAuth0Client = jest.mocked(auth0ManagementClient);
|
||||
|
||||
// Mock auth plugin for protected routes
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
return {
|
||||
default: fastifyPlugin(async function (fastify) {
|
||||
fastify.decorate('authenticate', async function (request, _reply) {
|
||||
request.user = { sub: 'auth0|test-user-123' };
|
||||
});
|
||||
}, { name: 'auth-plugin' }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Auth Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Ensure user_profiles table exists (should be created by migrations)
|
||||
// We don't need to run migration here as it should already exist
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Clean up test data before each test
|
||||
await pool.query('DELETE FROM user_profiles WHERE auth0_sub = $1', [
|
||||
'auth0|test-user-123',
|
||||
]);
|
||||
await pool.query('DELETE FROM user_profiles WHERE email = $1', [
|
||||
'newuser@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/auth/signup', () => {
|
||||
it('should create a new user successfully', async () => {
|
||||
const email = 'newuser@example.com';
|
||||
const password = 'Password123';
|
||||
const auth0UserId = 'auth0|new-user-456';
|
||||
|
||||
mockAuth0Client.createUser.mockResolvedValue(auth0UserId);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email, password })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: expect.stringContaining('check your email'),
|
||||
});
|
||||
|
||||
expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password });
|
||||
|
||||
// Verify user was created in local database
|
||||
const userResult = await pool.query(
|
||||
'SELECT * FROM user_profiles WHERE auth0_sub = $1',
|
||||
[auth0UserId]
|
||||
);
|
||||
expect(userResult.rows).toHaveLength(1);
|
||||
expect(userResult.rows[0].email).toBe(email);
|
||||
expect(userResult.rows[0].email_verified).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject signup with invalid email', async () => {
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email: 'invalid-email', password: 'Password123' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject signup with weak password', async () => {
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email: 'test@example.com', password: 'weak' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject signup when email already exists', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(
|
||||
new Error('User already exists')
|
||||
);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email, password })
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.error).toBe('Conflict');
|
||||
expect(response.body.message).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/verify-status', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user profile
|
||||
await pool.query(
|
||||
'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)',
|
||||
['auth0|test-user-123', 'test@example.com', false]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return verification status', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: 'auth0|test-user-123',
|
||||
email,
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
const response = await request(app.server)
|
||||
.get('/api/auth/verify-status')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
emailVerified: false,
|
||||
email,
|
||||
});
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith('auth0|test-user-123');
|
||||
});
|
||||
|
||||
it('should update local database when verification status changes', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: 'auth0|test-user-123',
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
const response = await request(app.server)
|
||||
.get('/api/auth/verify-status')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.emailVerified).toBe(true);
|
||||
|
||||
// Verify local database was updated
|
||||
const userResult = await pool.query(
|
||||
'SELECT email_verified FROM user_profiles WHERE auth0_sub = $1',
|
||||
['auth0|test-user-123']
|
||||
);
|
||||
expect(userResult.rows[0].email_verified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/resend-verification', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user profile
|
||||
await pool.query(
|
||||
'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)',
|
||||
['auth0|test-user-123', 'test@example.com', false]
|
||||
);
|
||||
});
|
||||
|
||||
it('should resend verification email when user is not verified', async () => {
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(false);
|
||||
mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/resend-verification')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toContain('Verification email sent');
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(
|
||||
'auth0|test-user-123'
|
||||
);
|
||||
expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith(
|
||||
'auth0|test-user-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip sending email when user is already verified', async () => {
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(true);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/resend-verification')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toContain('already verified');
|
||||
expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for AuthService
|
||||
* @ai-context Tests business logic with mocked dependencies
|
||||
*/
|
||||
|
||||
import { AuthService } from '../../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../../user-profile/data/user-profile.repository';
|
||||
import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../user-profile/data/user-profile.repository');
|
||||
jest.mock('../../../../core/auth/auth0-management.client');
|
||||
|
||||
const mockUserProfileRepository = jest.mocked(UserProfileRepository);
|
||||
const mockAuth0Client = jest.mocked(auth0ManagementClient);
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let repositoryInstance: jest.Mocked<UserProfileRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
repositoryInstance = {
|
||||
create: jest.fn(),
|
||||
getByAuth0Sub: jest.fn(),
|
||||
updateEmailVerified: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockUserProfileRepository.mockImplementation(() => repositoryInstance);
|
||||
service = new AuthService(repositoryInstance);
|
||||
});
|
||||
|
||||
describe('signup', () => {
|
||||
it('creates user in Auth0 and local database successfully', async () => {
|
||||
const email = 'test@example.com';
|
||||
const password = 'Password123';
|
||||
const auth0UserId = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.createUser.mockResolvedValue(auth0UserId);
|
||||
repositoryInstance.create.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub: auth0UserId,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.signup({ email, password });
|
||||
|
||||
expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password });
|
||||
expect(repositoryInstance.create).toHaveBeenCalledWith(auth0UserId, email, undefined);
|
||||
expect(result).toEqual({
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: 'Account created successfully. Please check your email to verify your account.',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when email already exists in Auth0', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(
|
||||
new Error('User already exists')
|
||||
);
|
||||
|
||||
await expect(service.signup({ email, password })).rejects.toThrow('Email already exists');
|
||||
});
|
||||
|
||||
it('propagates other Auth0 errors', async () => {
|
||||
const email = 'test@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(new Error('Auth0 API error'));
|
||||
|
||||
await expect(service.signup({ email, password })).rejects.toThrow('Auth0 API error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVerifyStatus', () => {
|
||||
it('returns verification status from Auth0 and updates local database', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: auth0Sub,
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
repositoryInstance.getByAuth0Sub.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
repositoryInstance.updateEmailVerified.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: true,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.getVerifyStatus(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(repositoryInstance.updateEmailVerified).toHaveBeenCalledWith(auth0Sub, true);
|
||||
expect(result).toEqual({
|
||||
emailVerified: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update local database if verification status unchanged', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: auth0Sub,
|
||||
email,
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
repositoryInstance.getByAuth0Sub.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.getVerifyStatus(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(repositoryInstance.updateEmailVerified).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
emailVerified: false,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendVerification', () => {
|
||||
it('sends verification email when user is not verified', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(false);
|
||||
mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.resendVerification(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(result).toEqual({
|
||||
message: 'Verification email sent. Please check your inbox.',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips sending email when user is already verified', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(true);
|
||||
|
||||
const result = await service.resendVerification(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
message: 'Email is already verified',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates Auth0 errors', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockRejectedValue(new Error('Auth0 API error'));
|
||||
|
||||
await expect(service.resendVerification(auth0Sub)).rejects.toThrow('Auth0 API error');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user