feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View 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.

View 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',
});
}
}
}

View 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),
});
};

View 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>;

View 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;
}
}
}

View 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;
}

View 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';

View File

@@ -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();
});
});
});

View 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');
});
});
});