Skip to content
Merged
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
20 changes: 19 additions & 1 deletion Backend/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,24 @@ export class AuthController {
}
}

async updateProfile(req: Request, res: Response, next: NextFunction) {
try {
const userId = (req as any).user?.userId;
if (!userId) {
throw new AppError('User not authenticated', 401);
}

const user = await authService.updateProfile(userId, req.body);
res.status(200).json({
success: true,
message: 'Profile updated successfully',
data: { user },
});
} catch (error) {
next(error);
}
}

async logout(req: Request, res: Response, next: NextFunction) {
try {
const userId = (req as any).user?.userId;
Expand Down Expand Up @@ -147,4 +165,4 @@ export class AuthController {
}
}

export const authController = new AuthController();
export const authController = new AuthController();
4 changes: 3 additions & 1 deletion Backend/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
verifyEmailSchema,
resendVerificationEmailSchema,
logoutSchema,
updateProfileSchema,
} from '../validators/auth.validator';

const router = Router();
Expand All @@ -28,6 +29,7 @@ router.post('/logout', authenticate, sessionRateLimiter, validate(logoutSchema),

// Protected routes
router.get('/profile', authenticate, authController.getProfile);
router.put('/profile', authenticate, validate(updateProfileSchema), authController.updateProfile);
router.post('/logout-all', authenticate, sessionRateLimiter, authController.logoutAll);

export const authRouter = router;
export const authRouter = router;
36 changes: 35 additions & 1 deletion Backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ResetPasswordInput,
VerifyEmailInput,
ResendVerificationEmailInput,
UpdateProfileInput,
} from '../validators/auth.validator';

const EMAIL_VERIFICATION_TOKEN_EXPIRY_MINUTES = 60 * 24;
Expand Down Expand Up @@ -336,6 +337,39 @@ export class AuthService {
return user;
}

async updateProfile(userId: string, data: UpdateProfileInput) {
const user = await userRepository.findById(userId);
if (!user) {
throw new AppError('User not found', 404);
}

const updateData: any = {};
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;

// If phone number is being updated, check for uniqueness
if (data.phoneNumber !== undefined) {
// Normalize the phone number (it's already normalized by the validator, but just to be safe)
const normalizedPhone = data.phoneNumber;

// Check if another user has this phone number
const existingUserWithPhone = await userRepository.findByPhoneNumber(normalizedPhone);
if (existingUserWithPhone && existingUserWithPhone.id !== userId) {
throw new AppError('User with this phone number already exists', 409);
}

updateData.phoneNumber = normalizedPhone;
}

// If there's nothing to update, just return the current user
if (Object.keys(updateData).length === 0) {
return user;
}

const updatedUser = await userRepository.update(userId, updateData);
return updatedUser;
}

/**
* Logs out the session identified by the presented refresh token. The token
* must still be valid (present and unrevoked) so an attacker cannot use the
Expand Down Expand Up @@ -394,4 +428,4 @@ export class AuthService {
}
}

export const authService = new AuthService();
export const authService = new AuthService();
7 changes: 7 additions & 0 deletions Backend/src/validators/auth.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export const logoutSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});

export const updateProfileSchema = z.object({
firstName: z.string().min(1, 'First name must be at least 1 character').optional(),
lastName: z.string().min(1, 'Last name must be at least 1 character').optional(),
phoneNumber: phoneNumberField.optional(),
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
export type RefreshTokenInput = z.infer<typeof refreshTokenSchema>;
Expand All @@ -86,3 +92,4 @@ export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
export type ResendVerificationEmailInput = z.infer<typeof resendVerificationEmailSchema>;
export type LogoutInput = z.infer<typeof logoutSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
160 changes: 160 additions & 0 deletions Backend/tests/integration/profile.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/database';
import { hashPassword } from '../../src/utils/crypto';
import { normalizePhoneNumber } from '../../src/utils/identity';

describe('Profile Integration Tests', () => {
let authToken: string;
let userId: string;
const testEmail = 'testprofile@example.com';
const testPassword = 'Test12345';

beforeEach(async () => {
// Clean up existing user
await prisma.user.deleteMany({ where: { email: testEmail } });

// Create test user
const passwordHash = await hashPassword(testPassword);
const user = await prisma.user.create({
data: {
email: testEmail,
passwordHash,
firstName: 'Original',
lastName: 'Name',
phoneNumber: null,
},
});
userId = user.id;

// Login to get auth token
const loginResponse = await request(app)
.post('/api/v1/auth/login')
.send({ email: testEmail, password: testPassword });

authToken = loginResponse.body.data.accessToken;
});

afterEach(async () => {
await prisma.user.deleteMany({ where: { email: testEmail } });
});

describe('GET /api/v1/auth/profile', () => {
it('should return user profile when authenticated', async () => {
const response = await request(app)
.get('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`);

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.user.email).toBe(testEmail);
expect(response.body.data.user.firstName).toBe('Original');
expect(response.body.data.user.passwordHash).toBeUndefined();
});

it('should reject unauthenticated requests', async () => {
const response = await request(app).get('/api/v1/auth/profile');
expect(response.status).toBe(401);
});
});

describe('PUT /api/v1/auth/profile', () => {
it('should update first and last name successfully', async () => {
const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ firstName: 'Updated', lastName: 'User' });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.user.firstName).toBe('Updated');
expect(response.body.data.user.lastName).toBe('User');
});

it('should update phone number successfully', async () => {
const phoneNumber = '08012345678';
const normalizedPhone = normalizePhoneNumber(phoneNumber);

const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ phoneNumber });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.user.phoneNumber).toBe(normalizedPhone);
});

it('should reject duplicate phone number', async () => {
// Create second user
const secondEmail = 'seconduser@example.com';
const secondPhone = '08098765432';
const normalizedSecondPhone = normalizePhoneNumber(secondPhone);

await prisma.user.create({
data: {
email: secondEmail,
passwordHash: await hashPassword('Test12345'),
phoneNumber: normalizedSecondPhone,
},
});

// Try to update first user's phone to the same number
const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ phoneNumber: secondPhone });

expect(response.status).toBe(409);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('User with this phone number already exists');

// Clean up second user
await prisma.user.deleteMany({ where: { email: secondEmail } });
});

it('should allow updating to the same phone number (no conflict)', async () => {
const phoneNumber = '08012345678';
const normalizedPhone = normalizePhoneNumber(phoneNumber);

// First set the phone number
await prisma.user.update({
where: { id: userId },
data: { phoneNumber: normalizedPhone },
});

// Try to update to the same number again
const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ phoneNumber });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});

it('should reject invalid phone number', async () => {
const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ phoneNumber: 'invalidphone' });

expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});

it('should return the same safe user shape as GET profile', async () => {
const response = await request(app)
.put('/api/v1/auth/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ firstName: 'TestUpdate' });

expect(response.status).toBe(200);
expect(response.body.data.user.passwordHash).toBeUndefined();
expect(response.body.data.user.id).toBeDefined();
expect(response.body.data.user.email).toBeDefined();
expect(response.body.data.user.createdAt).toBeDefined();
expect(response.body.data.user.updatedAt).toBeDefined();
});
});
});
Loading