From 5dd60fd4ba0c955571c57b5ad059d1ca7bf28b12 Mon Sep 17 00:00:00 2001 From: YakovchukIvan <81960402+YakovchukIvan@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:51:59 +0300 Subject: [PATCH 1/3] feat(auth): implement Auth0 management API and Redis token revocation --- src/modules/auth/auth.module.ts | 14 ++- .../auth/repositories/auth.repository.ts | 4 + .../auth/services/auth0-management.service.ts | 111 ++++++++++++++++++ src/modules/users/users.service.ts | 46 ++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/modules/auth/services/auth0-management.service.ts diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 7e76af3..381e13b 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -16,6 +16,7 @@ import { Auth0Strategy } from './strategies/auth0.strategy'; import { RolesGuard } from './guards/roles.guard'; import { AuthFacadeService } from './services/auth-facade.service'; import { AuthOAuthService } from './services/auth-oauth.service'; +import { Auth0ManagementService } from './services/auth0-management.service'; import { UserAuthRepository } from './repositories/user-auth.repository'; import { AuthRepository } from './repositories/auth.repository'; import { TokenRedisRepository } from './repositories/token-redis.repository'; @@ -44,7 +45,18 @@ import { TokenRedisRepository } from './repositories/token-redis.repository'; RolesGuard, AuthFacadeService, AuthOAuthService, + Auth0ManagementService, + ], + exports: [ + AuthCredentialsService, + PasswordService, + TokenService, + JwtModule, + PassportModule, + RolesGuard, + TokenRedisRepository, + AuthRepository, + Auth0ManagementService, ], - exports: [AuthCredentialsService, PasswordService, TokenService, JwtModule, PassportModule, RolesGuard], }) export class AuthModule {} diff --git a/src/modules/auth/repositories/auth.repository.ts b/src/modules/auth/repositories/auth.repository.ts index a3e3c6f..812532f 100644 --- a/src/modules/auth/repositories/auth.repository.ts +++ b/src/modules/auth/repositories/auth.repository.ts @@ -14,4 +14,8 @@ export class AuthRepository extends Repository { relations: ['user'], }); } + + async findByUserId(userId: string): Promise { + return this.findOne({ where: { userId } }); + } } diff --git a/src/modules/auth/services/auth0-management.service.ts b/src/modules/auth/services/auth0-management.service.ts new file mode 100644 index 0000000..be7d77a --- /dev/null +++ b/src/modules/auth/services/auth0-management.service.ts @@ -0,0 +1,111 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppLogger } from '@/common/logger/app-logger'; + +interface Auth0ManagementToken { + access_token: string; + expires_at: number; +} + +@Injectable() +export class Auth0ManagementService { + private readonly domain: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly audience: string; + + private cachedToken: Auth0ManagementToken | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly logger: AppLogger, + ) { + this.logger.setLoggerContext(Auth0ManagementService.name); + this.domain = this.configService.getOrThrow('AUTH0_DOMAIN'); + this.clientId = this.configService.getOrThrow('AUTH0_CLIENT_ID'); + this.clientSecret = this.configService.getOrThrow('AUTH0_CLIENT_SECRET'); + this.audience = `https://${this.domain}/api/v2/`; + } + + async deleteUser(auth0UserId: string): Promise { + const token = await this.getManagementToken(); + + const url = `https://${this.domain}/api/v2/users/${encodeURIComponent(auth0UserId)}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok && response.status !== 204) { + const body = await response.text(); + this.logger.error({ auth0UserId, status: response.status, body }, 'Failed to delete user from Auth0'); + throw new InternalServerErrorException('Failed to delete user from Auth0'); + } + + this.logger.log({ auth0UserId }, 'User deleted from Auth0 successfully'); + } + + async updateUser( + auth0UserId: string, + data: { given_name?: string; family_name?: string; name?: string }, + ): Promise { + const token = await this.getManagementToken(); + + const url = `https://${this.domain}/api/v2/users/${encodeURIComponent(auth0UserId)}`; + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error({ auth0UserId, status: response.status, body }, 'Failed to update user in Auth0'); + throw new InternalServerErrorException('Failed to update user in Auth0'); + } + + this.logger.log({ auth0UserId, data }, 'User updated in Auth0 successfully'); + } + + private async getManagementToken(): Promise { + const now = Date.now(); + + if (this.cachedToken && now < this.cachedToken.expires_at) { + return this.cachedToken.access_token; + } + + const response = await fetch(`https://${this.domain}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: this.clientId, + client_secret: this.clientSecret, + audience: this.audience, + }), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error({ status: response.status, body }, 'Failed to obtain Auth0 Management token'); + throw new InternalServerErrorException('Failed to obtain Auth0 Management API token'); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + this.cachedToken = { + access_token: data.access_token, + expires_at: now + (data.expires_in - 60) * 1000, + }; + + this.logger.log('Auth0 Management token obtained and cached'); + return this.cachedToken.access_token; + } +} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 25f06ba..8da61e2 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -11,6 +11,9 @@ import { PaginatedResultResponseDto } from '@/common/dto/res/paginated-result.dt import { UserRepository } from './repositories/user.repository'; import { GuardAssertions } from '@/common/helpers/assert.helper'; import { S3Service } from '@/modules/s3/s3.service'; +import { TokenRedisRepository } from '@/modules/auth/repositories/token-redis.repository'; +import { AuthRepository } from '@/modules/auth/repositories/auth.repository'; +import { Auth0ManagementService } from '@/modules/auth/services/auth0-management.service'; @Injectable() export class UsersService { @@ -20,6 +23,9 @@ export class UsersService { private readonly authCredentialsService: AuthCredentialsService, private readonly logger: AppLogger, private readonly s3Service: S3Service, + private readonly tokenRedisRepository: TokenRedisRepository, + private readonly authRepository: AuthRepository, + private readonly auth0ManagementService: Auth0ManagementService, ) { this.logger.setLoggerContext(UsersService.name); } @@ -72,6 +78,9 @@ export class UsersService { const updated = await manager.getRepository(User).save(user); this.logger.log({ userId: updated.id }, 'User updated successfully'); + + await this.syncNameToAuth0IfOAuthUser(id, dto); + return updated; }); } @@ -95,6 +104,14 @@ export class UsersService { async remove(id: string): Promise { const user = await this.userRepository.findByIdOrFail(id); + await this.tokenRedisRepository.revokeAllUserTokens(id); + this.logger.log({ userId: id }, 'All user tokens revoked from Redis'); + + const auth = await this.authRepository.findByUserId(id); + if (auth?.googleId) { + await this.auth0ManagementService.deleteUser(auth.googleId); + } + if (user.avatarUrl) { await this.s3Service.deleteFile(user.avatarUrl); } @@ -102,4 +119,33 @@ export class UsersService { await this.userRepository.remove(user); this.logger.log({ userId: id, email: user.email }, 'User removed successfully'); } + + private async syncNameToAuth0IfOAuthUser( + userId: string, + dto: Pick, + ): Promise { + const hasNameChange = dto.firstName !== undefined || dto.lastName !== undefined; + if (!hasNameChange) { + return; + } + + const auth = await this.authRepository.findByUserId(userId); + if (!auth?.googleId) { + return; + } + + try { + const updatedUser = await this.userRepository.findByIdOrFail(userId); + await this.auth0ManagementService.updateUser(auth.googleId, { + given_name: updatedUser.firstName, + family_name: updatedUser.lastName, + name: `${updatedUser.firstName} ${updatedUser.lastName}`, + }); + } catch (err) { + this.logger.error( + { userId, error: (err as Error).message }, + 'Failed to sync name update to Auth0 (non-critical)', + ); + } + } } From a36fb8632c26352a1ef642e792467bdeaa6ac911 Mon Sep 17 00:00:00 2001 From: YakovchukIvan <81960402+YakovchukIvan@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:00:12 +0300 Subject: [PATCH 2/3] feat(tests): add mocks for TokenRedisRepository, AuthRepository, and Auth0ManagementService in UsersService tests --- .../users/__tests__/users.service.spec.ts | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/src/modules/users/__tests__/users.service.spec.ts b/src/modules/users/__tests__/users.service.spec.ts index b9e1d74..ee7205a 100644 --- a/src/modules/users/__tests__/users.service.spec.ts +++ b/src/modules/users/__tests__/users.service.spec.ts @@ -7,8 +7,12 @@ import { AuthCredentialsService } from '@/modules/auth/services/auth-credentials import { AppLogger } from '@/common/logger/app-logger'; import { S3Service } from '@/modules/s3/s3.service'; import { User } from '../entities/user.entity'; +import { Auth } from '@/modules/auth/entities/auth.entity'; import { UserRole } from '@/common/enums/user-role.enum'; import { UpdateUserDto } from '../dto/req/update-user.req.dto'; +import { TokenRedisRepository } from '@/modules/auth/repositories/token-redis.repository'; +import { AuthRepository } from '@/modules/auth/repositories/auth.repository'; +import { Auth0ManagementService } from '@/modules/auth/services/auth0-management.service'; // === Factory functions === @@ -24,6 +28,19 @@ const makeUser = (overrides: Partial = {}): User => ...overrides, }) as User; +const makeAuth0 = (overrides: Partial = {}): Auth => + ({ + id: 'auth-uuid-1', + userId: 'user-uuid-1', + passwordHash: 'hashed-password', + googleId: null, + resetPasswordTokenHash: null, + resetPasswordExpiresAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides, + }) as Auth; + // === Tests === describe('UsersService', () => { @@ -31,6 +48,9 @@ describe('UsersService', () => { let userRepository: jest.Mocked; let authCredentialsService: jest.Mocked; let dataSource: { transaction: jest.Mock }; + let tokenRedisRepository: jest.Mocked; + let authRepository: jest.Mocked; + let auth0ManagementService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -42,6 +62,7 @@ describe('UsersService', () => { findByIdOrFail: jest.fn(), findPaginated: jest.fn(), remove: jest.fn(), + save: jest.fn(), }, }, { @@ -51,7 +72,6 @@ describe('UsersService', () => { setPasswordTx: jest.fn(), }, }, - // Typed as plain object to avoid DataSource transaction overload conflict { provide: DataSource, useValue: { @@ -64,6 +84,7 @@ describe('UsersService', () => { setLoggerContext: jest.fn(), log: jest.fn(), logError: jest.fn(), + error: jest.fn(), }, }, { @@ -73,6 +94,25 @@ describe('UsersService', () => { deleteFile: jest.fn(), }, }, + { + provide: TokenRedisRepository, + useValue: { + revokeAllUserTokens: jest.fn(), + }, + }, + { + provide: AuthRepository, + useValue: { + findByUserId: jest.fn(), + }, + }, + { + provide: Auth0ManagementService, + useValue: { + deleteUser: jest.fn(), + updateUser: jest.fn(), + }, + }, ], }).compile(); @@ -80,6 +120,9 @@ describe('UsersService', () => { userRepository = module.get(UserRepository); authCredentialsService = module.get(AuthCredentialsService); dataSource = module.get<{ transaction: jest.Mock }>(DataSource); + tokenRedisRepository = module.get(TokenRedisRepository); + authRepository = module.get(AuthRepository); + auth0ManagementService = module.get(Auth0ManagementService); }); // === findOne === @@ -88,7 +131,6 @@ describe('UsersService', () => { it('should return user by id; throw NotFoundException if not found', async () => { const user = makeUser(); - // === Success case === userRepository.findByIdOrFail.mockResolvedValueOnce(user); const result = await service.findOne(user.id); @@ -96,9 +138,6 @@ describe('UsersService', () => { expect(result.id).toBe(user.id); expect(result.email).toBe(user.email); - // === User not found === - // findByIdOrFail internally calls GuardAssertions.exists which throws NotFoundException - userRepository.findByIdOrFail.mockRejectedValueOnce(new NotFoundException('User with ID not found')); await expect(service.findOne('nonexistent-id')).rejects.toThrow(NotFoundException); @@ -108,12 +147,15 @@ describe('UsersService', () => { // === update === describe('update', () => { - it('should update firstName/lastName; call setPasswordTx if password provided', async () => { + it('should update firstName/lastName; call setPasswordTx if password provided; sync to Auth0', async () => { const user = makeUser(); + const auth = makeAuth0({ googleId: 'auth0|123' }); // === Update name fields === const nameDto: UpdateUserDto = { firstName: 'UpdatedName', lastName: 'UpdatedLastName' }; + authRepository.findByUserId.mockResolvedValueOnce(auth); + // Transaction mock: invoke callback with fake EntityManager dataSource.transaction.mockImplementationOnce(async (cb: (manager: EntityManager) => Promise) => { const updatedUser = { ...user, ...nameDto }; @@ -126,10 +168,21 @@ describe('UsersService', () => { return cb(fakeManager); }); + userRepository.findByIdOrFail.mockResolvedValueOnce({ ...user, ...nameDto }); + const nameResult = await service.update(user.id, nameDto); expect(nameResult.firstName).toBe('UpdatedName'); expect(nameResult.lastName).toBe('UpdatedLastName'); + // Auth0 sync check + expect(auth0ManagementService.updateUser).toHaveBeenCalledWith( + 'auth0|123', + expect.objectContaining({ + given_name: 'UpdatedName', + family_name: 'UpdatedLastName', + }), + ); + // Password was not provided — setPasswordTx must not be called expect(authCredentialsService.setPasswordTx).not.toHaveBeenCalled(); @@ -147,7 +200,7 @@ describe('UsersService', () => { return cb(fakeManager); }); - authCredentialsService.setPasswordTx.mockResolvedValue({ id: 'auth-uuid-1' } as never); + authCredentialsService.setPasswordTx.mockResolvedValue(auth); await service.update(user.id, passwordDto); @@ -159,14 +212,24 @@ describe('UsersService', () => { // === remove === describe('remove', () => { - it('should call userRepository.remove with the found user', async () => { + it('should call userRepository.remove with the found user and clean up external services', async () => { const user = makeUser(); + const auth = makeAuth0({ googleId: 'auth0|123' }); + // Mock user existence userRepository.findByIdOrFail.mockResolvedValue(user); userRepository.remove.mockResolvedValue(user); + // Mock auth presence (Auth0 user) + authRepository.findByUserId.mockResolvedValue(auth); await service.remove(user.id); + // Check Redis revocation + expect(tokenRedisRepository.revokeAllUserTokens).toHaveBeenCalledWith(user.id); + + // Check Auth0 deletion + expect(auth0ManagementService.deleteUser).toHaveBeenCalledWith('auth0|123'); + // Ensure the correct user entity is passed to remove expect(userRepository.remove).toHaveBeenCalledWith(user); }); From 7ad6a2e05fe659338127eb527a9800f92dbfc122 Mon Sep 17 00:00:00 2001 From: YakovchukIvan <81960402+YakovchukIvan@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:05:50 +0300 Subject: [PATCH 3/3] feat(users): enhance user update functionality and sync avatar to Auth0 --- .../auth/services/auth0-management.service.ts | 2 +- .../users/__tests__/users.service.spec.ts | 195 +++++++++++++----- src/modules/users/users.service.ts | 45 +++- 3 files changed, 178 insertions(+), 64 deletions(-) diff --git a/src/modules/auth/services/auth0-management.service.ts b/src/modules/auth/services/auth0-management.service.ts index be7d77a..dbe3a4b 100644 --- a/src/modules/auth/services/auth0-management.service.ts +++ b/src/modules/auth/services/auth0-management.service.ts @@ -50,7 +50,7 @@ export class Auth0ManagementService { async updateUser( auth0UserId: string, - data: { given_name?: string; family_name?: string; name?: string }, + data: { given_name?: string; family_name?: string; name?: string; picture?: string }, ): Promise { const token = await this.getManagementToken(); diff --git a/src/modules/users/__tests__/users.service.spec.ts b/src/modules/users/__tests__/users.service.spec.ts index ee7205a..2464657 100644 --- a/src/modules/users/__tests__/users.service.spec.ts +++ b/src/modules/users/__tests__/users.service.spec.ts @@ -23,12 +23,14 @@ const makeUser = (overrides: Partial = {}): User => firstName: 'Ivan', lastName: 'Test', role: UserRole.USER, + avatarUrl: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, }) as User; -const makeAuth0 = (overrides: Partial = {}): Auth => +// Auth record for a regular email/password user (no googleId) +const makeLocalAuth = (overrides: Partial = {}): Auth => ({ id: 'auth-uuid-1', userId: 'user-uuid-1', @@ -41,12 +43,17 @@ const makeAuth0 = (overrides: Partial = {}): Auth => ...overrides, }) as Auth; +// Auth record for an Auth0 OAuth user (has googleId, no passwordHash) +const makeOAuthAuth = (overrides: Partial = {}): Auth => + makeLocalAuth({ passwordHash: null, googleId: 'google-oauth2|123456', ...overrides }); + // === Tests === describe('UsersService', () => { let service: UsersService; let userRepository: jest.Mocked; let authCredentialsService: jest.Mocked; + let s3Service: jest.Mocked; let dataSource: { transaction: jest.Mock }; let tokenRedisRepository: jest.Mocked; let authRepository: jest.Mocked; @@ -74,16 +81,13 @@ describe('UsersService', () => { }, { provide: DataSource, - useValue: { - transaction: jest.fn(), - }, + useValue: { transaction: jest.fn() }, }, { provide: AppLogger, useValue: { setLoggerContext: jest.fn(), log: jest.fn(), - logError: jest.fn(), error: jest.fn(), }, }, @@ -96,15 +100,11 @@ describe('UsersService', () => { }, { provide: TokenRedisRepository, - useValue: { - revokeAllUserTokens: jest.fn(), - }, + useValue: { revokeAllUserTokens: jest.fn() }, }, { provide: AuthRepository, - useValue: { - findByUserId: jest.fn(), - }, + useValue: { findByUserId: jest.fn() }, }, { provide: Auth0ManagementService, @@ -119,6 +119,7 @@ describe('UsersService', () => { service = module.get(UsersService); userRepository = module.get(UserRepository); authCredentialsService = module.get(AuthCredentialsService); + s3Service = module.get(S3Service); dataSource = module.get<{ transaction: jest.Mock }>(DataSource); tokenRedisRepository = module.get(TokenRedisRepository); authRepository = module.get(AuthRepository); @@ -128,16 +129,17 @@ describe('UsersService', () => { // === findOne === describe('findOne', () => { - it('should return user by id; throw NotFoundException if not found', async () => { + it('should return user by id', async () => { const user = makeUser(); - userRepository.findByIdOrFail.mockResolvedValueOnce(user); const result = await service.findOne(user.id); expect(result.id).toBe(user.id); expect(result.email).toBe(user.email); + }); + it('should throw NotFoundException if user not found', async () => { userRepository.findByIdOrFail.mockRejectedValueOnce(new NotFoundException('User with ID not found')); await expect(service.findOne('nonexistent-id')).rejects.toThrow(NotFoundException); @@ -147,50 +149,64 @@ describe('UsersService', () => { // === update === describe('update', () => { - it('should update firstName/lastName; call setPasswordTx if password provided; sync to Auth0', async () => { - const user = makeUser(); - const auth = makeAuth0({ googleId: 'auth0|123' }); - - // === Update name fields === - const nameDto: UpdateUserDto = { firstName: 'UpdatedName', lastName: 'UpdatedLastName' }; - - authRepository.findByUserId.mockResolvedValueOnce(auth); - - // Transaction mock: invoke callback with fake EntityManager + const mockTransaction = (updatedUser: User): void => { dataSource.transaction.mockImplementationOnce(async (cb: (manager: EntityManager) => Promise) => { - const updatedUser = { ...user, ...nameDto }; const fakeManager = { - findOneOrFail: jest.fn().mockResolvedValue(user), + findOneOrFail: jest.fn().mockResolvedValue(updatedUser), getRepository: jest.fn().mockReturnValue({ save: jest.fn().mockResolvedValue(updatedUser), }), } as unknown as EntityManager; return cb(fakeManager); }); + }; + + it('should update firstName/lastName for a regular user (no Auth0 sync)', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { firstName: 'Updated', lastName: 'Name' }; + mockTransaction({ ...user, ...dto }); + + // Regular user — no googleId + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); - userRepository.findByIdOrFail.mockResolvedValueOnce({ ...user, ...nameDto }); + const result = await service.update(user.id, dto); - const nameResult = await service.update(user.id, nameDto); + expect(result.firstName).toBe('Updated'); + expect(result.lastName).toBe('Name'); + expect(authCredentialsService.setPasswordTx).not.toHaveBeenCalled(); + // No Auth0 sync for local user + expect(auth0ManagementService.updateUser).not.toHaveBeenCalled(); + }); + + it('should sync name to Auth0 when updating an OAuth user', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { firstName: 'UpdatedName', lastName: 'UpdatedLastName' }; + const updatedUser = { ...user, ...dto }; + mockTransaction(updatedUser); - expect(nameResult.firstName).toBe('UpdatedName'); - expect(nameResult.lastName).toBe('UpdatedLastName'); - // Auth0 sync check + // Auth0 user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); + // Called inside syncNameToAuth0IfOAuthUser to read current names + userRepository.findByIdOrFail.mockResolvedValueOnce(updatedUser); + + const result = await service.update(user.id, dto); + + expect(result.firstName).toBe('UpdatedName'); expect(auth0ManagementService.updateUser).toHaveBeenCalledWith( - 'auth0|123', + 'google-oauth2|123456', expect.objectContaining({ given_name: 'UpdatedName', family_name: 'UpdatedLastName', }), ); + }); - // Password was not provided — setPasswordTx must not be called - expect(authCredentialsService.setPasswordTx).not.toHaveBeenCalled(); - - // === Update with password === - const passwordDto: UpdateUserDto = { password: 'NewStrongPass123!' }; + it('should call setPasswordTx when password is provided', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { password: 'NewStrongPass123!' }; + const userWithAuth = { ...user, auth: { passwordHash: 'oldhash' } }; dataSource.transaction.mockImplementationOnce(async (cb: (manager: EntityManager) => Promise) => { - const userWithAuth = { ...user, auth: { passwordHash: 'oldhash' } }; const fakeManager = { findOneOrFail: jest.fn().mockResolvedValue(userWithAuth), getRepository: jest.fn().mockReturnValue({ @@ -200,38 +216,111 @@ describe('UsersService', () => { return cb(fakeManager); }); - authCredentialsService.setPasswordTx.mockResolvedValue(auth); + authCredentialsService.setPasswordTx.mockResolvedValue(makeLocalAuth()); - await service.update(user.id, passwordDto); + await service.update(user.id, dto); - // Password was provided — setPasswordTx must be called once expect(authCredentialsService.setPasswordTx).toHaveBeenCalledTimes(1); + expect(authRepository.findByUserId).not.toHaveBeenCalled(); + }); + }); + + // === updateAvatar === + + describe('updateAvatar', () => { + const fakeFile = { originalname: 'avatar.jpg', buffer: Buffer.from('') } as Express.Multer.File; + const newAvatarUrl = 'https://s3.example.com/users/avatar.jpg'; + + it('should upload new avatar, delete old one, and return updated user', async () => { + const user = makeUser({ avatarUrl: 'https://s3.example.com/users/old.jpg' }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + + // Local user — no Auth0 sync + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + const result = await service.updateAvatar(user.id, fakeFile); + + expect(s3Service.uploadFile).toHaveBeenCalledWith(fakeFile, 'users'); + expect(s3Service.deleteFile).toHaveBeenCalledWith('https://s3.example.com/users/old.jpg'); + expect(result.avatarUrl).toBe(newAvatarUrl); + expect(auth0ManagementService.updateUser).not.toHaveBeenCalled(); + }); + + it('should sync avatar (picture) to Auth0 for an OAuth user', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + + // OAuth user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); + + const result = await service.updateAvatar(user.id, fakeFile); + + expect(result.avatarUrl).toBe(newAvatarUrl); + expect(auth0ManagementService.updateUser).toHaveBeenCalledWith( + 'google-oauth2|123456', + expect.objectContaining({ picture: newAvatarUrl }), + ); + }); + + it('should not delete old avatar from S3 if user had no avatar', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + await service.updateAvatar(user.id, fakeFile); + + // No old avatar — deleteFile must not be called + expect(s3Service.deleteFile).not.toHaveBeenCalled(); }); }); // === remove === describe('remove', () => { - it('should call userRepository.remove with the found user and clean up external services', async () => { - const user = makeUser(); - const auth = makeAuth0({ googleId: 'auth0|123' }); - - // Mock user existence - userRepository.findByIdOrFail.mockResolvedValue(user); - userRepository.remove.mockResolvedValue(user); - // Mock auth presence (Auth0 user) - authRepository.findByUserId.mockResolvedValue(auth); + it('should revoke tokens, remove user and avatar from S3, skip Auth0 for regular user', async () => { + const user = makeUser({ avatarUrl: 'https://s3.example.com/users/avatar.jpg' }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + // Regular user — no googleId + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); await service.remove(user.id); - // Check Redis revocation expect(tokenRedisRepository.revokeAllUserTokens).toHaveBeenCalledWith(user.id); + expect(s3Service.deleteFile).toHaveBeenCalledWith(user.avatarUrl); + expect(auth0ManagementService.deleteUser).not.toHaveBeenCalled(); + expect(userRepository.remove).toHaveBeenCalledWith(user); + }); + + it('should delete user from Auth0 when removing an OAuth user', async () => { + const user = makeUser(); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + // OAuth user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); - // Check Auth0 deletion - expect(auth0ManagementService.deleteUser).toHaveBeenCalledWith('auth0|123'); + await service.remove(user.id); - // Ensure the correct user entity is passed to remove + expect(tokenRedisRepository.revokeAllUserTokens).toHaveBeenCalledWith(user.id); + expect(auth0ManagementService.deleteUser).toHaveBeenCalledWith('google-oauth2|123456'); expect(userRepository.remove).toHaveBeenCalledWith(user); }); + + it('should not delete avatar from S3 if user had no avatar', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + await service.remove(user.id); + + expect(s3Service.deleteFile).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 8da61e2..170834d 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -59,7 +59,7 @@ export class UsersService { } async update(id: string, dto: UpdateUserDto): Promise { - return this.dataSource.transaction(async (manager) => { + const updated = await this.dataSource.transaction(async (manager) => { const relations = dto.password ? ['auth'] : []; const user = await manager.findOneOrFail(User, { where: { id }, relations }); GuardAssertions.exists(user, `User with ID ${id} not found`); @@ -76,13 +76,14 @@ export class UsersService { await this.authCredentialsService.setPasswordTx(manager, user, dto.password); } - const updated = await manager.getRepository(User).save(user); - this.logger.log({ userId: updated.id }, 'User updated successfully'); + const result = await manager.getRepository(User).save(user); + this.logger.log({ userId: result.id }, 'User updated successfully'); + return result; + }); - await this.syncNameToAuth0IfOAuthUser(id, dto); + await this.syncNameToAuth0IfOAuthUser(id, dto); - return updated; - }); + return updated; } async updateAvatar(userId: string, file: Express.Multer.File): Promise { @@ -98,6 +99,8 @@ export class UsersService { await this.s3Service.deleteFile(oldUrl); } + await this.syncAvatarToAuth0IfOAuthUser(userId, updated.avatarUrl); + return updated; } @@ -135,11 +138,11 @@ export class UsersService { } try { - const updatedUser = await this.userRepository.findByIdOrFail(userId); + const user = await this.userRepository.findByIdOrFail(userId); await this.auth0ManagementService.updateUser(auth.googleId, { - given_name: updatedUser.firstName, - family_name: updatedUser.lastName, - name: `${updatedUser.firstName} ${updatedUser.lastName}`, + given_name: user.firstName, + family_name: user.lastName, + name: `${user.firstName} ${user.lastName}`, }); } catch (err) { this.logger.error( @@ -148,4 +151,26 @@ export class UsersService { ); } } + + private async syncAvatarToAuth0IfOAuthUser(userId: string, avatarUrl: string | null): Promise { + if (!avatarUrl) { + return; + } + + const auth = await this.authRepository.findByUserId(userId); + if (!auth?.googleId) { + return; + } + + try { + await this.auth0ManagementService.updateUser(auth.googleId, { + picture: avatarUrl, + }); + } catch (err) { + this.logger.error( + { userId, error: (err as Error).message }, + 'Failed to sync avatar update to Auth0 (non-critical)', + ); + } + } }