Skip to content

Commit

Permalink
feat: issue 1594 synchronize users ad app (#1596)
Browse files Browse the repository at this point in the history
  • Loading branch information
joaofrparreira authored Nov 27, 2024
1 parent e238d2e commit 1433045
Show file tree
Hide file tree
Showing 16 changed files with 360 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { buildTestFactory } from './generic-factory.mock';
import { AzureUserDTO } from 'src/modules/azure/dto/azure-user.dto';

const mockUserData = (): AzureUserDTO => {
const mail = faker.internet.email();
//xGeeks AD style, the '.' is mandatory for some tests
const firstName = faker.name.firstName();
const lastName = faker.name.lastName();
const mail = firstName[0].toLowerCase() + '.' + lastName.toLowerCase() + '@xgeeks.com';

return {
id: faker.datatype.uuid(),
displayName: faker.name.firstName() + faker.name.lastName(),
displayName: firstName + ' ' + lastName,
mail: mail,
userPrincipalName: mail,
createdDateTime: faker.date.past(5),
Expand Down
14 changes: 12 additions & 2 deletions backend/src/modules/azure/azure.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ import AuthModule from '../auth/auth.module';
import { CommunicationModule } from '../communication/communication.module';
import { StorageModule } from '../storage/storage.module';
import UsersModule from '../users/users.module';
import { authAzureService, checkUserUseCase, registerOrLoginUseCase } from './azure.providers';
import {
authAzureService,
checkUserUseCase,
registerOrLoginUseCase,
synchronizeADUsersCronUseCase
} from './azure.providers';
import AzureController from './controller/azure.controller';
import { JwtRegister } from 'src/infrastructure/config/jwt.register';

@Module({
imports: [UsersModule, AuthModule, CommunicationModule, StorageModule, JwtRegister],
controllers: [AzureController],
providers: [authAzureService, checkUserUseCase, registerOrLoginUseCase]
providers: [
authAzureService,
checkUserUseCase,
registerOrLoginUseCase,
synchronizeADUsersCronUseCase
]
})
export default class AzureModule {}
13 changes: 12 additions & 1 deletion backend/src/modules/azure/azure.providers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { CheckUserAzureUseCase } from './applications/check-user.azure.use-case';
import { RegisterOrLoginAzureUseCase } from './applications/register-or-login.azure.use-case';
import { AUTH_AZURE_SERVICE, CHECK_USER_USE_CASE, REGISTER_OR_LOGIN_USE_CASE } from './constants';
import {
AUTH_AZURE_SERVICE,
CHECK_USER_USE_CASE,
REGISTER_OR_LOGIN_USE_CASE,
SYNCHRONIZE_AD_USERS_CRON_USE_CASE
} from './constants';
import { SynchronizeADUsersCronUseCase } from './schedules/synchronize-ad-users.cron.azure.use-case';
import AuthAzureService from './services/auth.azure.service';

/* SERVICE */
Expand All @@ -21,3 +27,8 @@ export const registerOrLoginUseCase = {
provide: REGISTER_OR_LOGIN_USE_CASE,
useClass: RegisterOrLoginAzureUseCase
};

export const synchronizeADUsersCronUseCase = {
provide: SYNCHRONIZE_AD_USERS_CRON_USE_CASE,
useClass: SynchronizeADUsersCronUseCase
};
2 changes: 2 additions & 0 deletions backend/src/modules/azure/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export const AUTH_AZURE_SERVICE = 'AuthAzureService';
export const REGISTER_OR_LOGIN_USE_CASE = 'RegisterOrLoginUseCase';

export const CHECK_USER_USE_CASE = 'CheckUserUseCase';

export const SYNCHRONIZE_AD_USERS_CRON_USE_CASE = 'SynchronizeADUsersCronUseCase';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { UseCase } from 'src/libs/interfaces/use-case.interface';

export interface SynchronizeADUsersCronUseCaseInterface extends UseCase<void, void> {
execute(): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { AzureUserDTO } from '../../dto/azure-user.dto';
export interface AuthAzureServiceInterface {
getUserFromAzure(email: string): Promise<AzureUserDTO | undefined>;
fetchUserPhoto(userId: string): Promise<any>;
getADUsers(): Promise<Array<AzureUserDTO>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AUTH_AZURE_SERVICE } from '../constants';
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
import { DeleteUserUseCase } from 'src/modules/users/applications/delete-user.use-case';
import {
CREATE_USER_SERVICE,
DELETE_USER_USE_CASE,
GET_ALL_USERS_INCLUDE_DELETED_USE_CASE
} from 'src/modules/users/constants';
import { UserFactory } from 'src/libs/test-utils/mocks/factories/user-factory';
import { UseCase } from 'src/libs/interfaces/use-case.interface';
import { AzureUserFactory } from 'src/libs/test-utils/mocks/factories/azure-user-factory';
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';
import GetAllUsersIncludeDeletedUseCase from 'src/modules/users/applications/get-all-users-include-deleted.use-case';
import { SynchronizeADUsersCronUseCase } from './synchronize-ad-users.cron.azure.use-case';

const usersAD = AzureUserFactory.createMany(4, () => ({
deletedDateTime: null,
employeeLeaveDateTime: null
}));
const users = UserFactory.createMany(
4,
usersAD.map((u) => ({
email: u.mail,
firstName: u.displayName.split(' ')[0],
lastName: u.displayName.split(' ')[1]
})) as never
);

describe('SynchronizeAdUsersCronUseCase', () => {
let synchronizeADUsers: UseCase<void, void>;
let authAzureServiceMock: DeepMocked<AuthAzureServiceInterface>;
let getAllUsersMock: DeepMocked<GetAllUsersIncludeDeletedUseCase>;
let deleteUserMock: DeepMocked<DeleteUserUseCase>;
let createUserServiceMock: DeepMocked<CreateUserServiceInterface>;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SynchronizeADUsersCronUseCase,
{
provide: AUTH_AZURE_SERVICE,
useValue: createMock<AuthAzureServiceInterface>()
},
{
provide: GET_ALL_USERS_INCLUDE_DELETED_USE_CASE,
useValue: createMock<GetAllUsersIncludeDeletedUseCase>()
},
{
provide: DELETE_USER_USE_CASE,
useValue: createMock<DeleteUserUseCase>()
},
{
provide: CREATE_USER_SERVICE,
useValue: createMock<CreateUserServiceInterface>()
}
]
}).compile();

synchronizeADUsers = module.get(SynchronizeADUsersCronUseCase);
authAzureServiceMock = module.get(AUTH_AZURE_SERVICE);
getAllUsersMock = module.get(GET_ALL_USERS_INCLUDE_DELETED_USE_CASE);
deleteUserMock = module.get(DELETE_USER_USE_CASE);
createUserServiceMock = module.get(CREATE_USER_SERVICE);
});
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});

it('should be defined', () => {
expect(synchronizeADUsers).toBeDefined();
});
it('execute', async () => {
const userNotInApp = AzureUserFactory.create({
employeeLeaveDateTime: null,
deletedDateTime: null
});
const finalADUsers = [userNotInApp, ...usersAD];
authAzureServiceMock.getADUsers.mockResolvedValueOnce(finalADUsers);
const userNotInAD = UserFactory.create();
const finalAppUsers = [userNotInAD, ...users];
getAllUsersMock.execute.mockResolvedValueOnce(finalAppUsers);
await synchronizeADUsers.execute();
expect(deleteUserMock.execute).toBeCalledWith(userNotInAD._id);
expect(deleteUserMock.execute.mock.calls).toEqual([[userNotInAD._id]]);
expect(createUserServiceMock.create).toHaveBeenCalledWith({
email: userNotInApp.mail,
firstName: userNotInApp.displayName.split(' ')[0],
lastName: userNotInApp.displayName.split(' ')[1],
providerAccountCreatedAt: userNotInApp.createdDateTime
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { SynchronizeADUsersCronUseCaseInterface } from '../interfaces/schedules/synchronize-ad-users.cron.azure.use-case.interface';
import { AUTH_AZURE_SERVICE } from '../constants';
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
import {
CREATE_USER_SERVICE,
DELETE_USER_USE_CASE,
GET_ALL_USERS_INCLUDE_DELETED_USE_CASE
} from 'src/modules/users/constants';
import { UseCase } from 'src/libs/interfaces/use-case.interface';
import User from 'src/modules/users/entities/user.schema';
import { AzureUserDTO } from '../dto/azure-user.dto';
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';

@Injectable()
export class SynchronizeADUsersCronUseCase implements SynchronizeADUsersCronUseCaseInterface {
private readonly logger: Logger = new Logger(SynchronizeADUsersCronUseCase.name);
constructor(
@Inject(AUTH_AZURE_SERVICE)
private readonly authAzureService: AuthAzureServiceInterface,
@Inject(GET_ALL_USERS_INCLUDE_DELETED_USE_CASE)
private readonly getAllUsersIncludeDeletedUseCase: UseCase<void, Array<User>>,
@Inject(DELETE_USER_USE_CASE)
private readonly deleteUserUseCase: UseCase<string, boolean>,
@Inject(CREATE_USER_SERVICE)
private readonly createUserService: CreateUserServiceInterface
) {}

//Runs every saturday at mid-night
//@Cron('0 0 * * 6')
@Cron('0 14 * * *')
async execute() {
try {
const usersADAll = await this.authAzureService.getADUsers();

if (!usersADAll.length) {
throw new Error('Azure AD users list is empty.');
}

const usersApp = await this.getAllUsersIncludeDeletedUseCase.execute();

if (!usersApp.length) {
throw new Error('Split app users list is empty.');
}

const today = new Date();
//Filter out users that don't have a '.' in the beggining of the email
let usersADFiltered = usersADAll.filter((u) =>
/[a-z]+\.[a-zA-Z0-9]+@/.test(u.userPrincipalName)
);

//Filter out users that have a deletedDateTime bigger than 'today'
usersADFiltered = usersADFiltered.filter((u) =>
'deletedDateTime' in u ? u.deletedDateTime === null || u.deletedDateTime >= today : true
);

//Filter out users that have a employeeLeaveDateTime bigger than 'today'
usersADFiltered = usersADFiltered.filter((u) =>
'employeeLeaveDateTime' in u
? u.employeeLeaveDateTime === null || u.employeeLeaveDateTime >= today
: true
);

await this.removeUsersFromApp(usersADFiltered, usersApp);
await this.addUsersToApp(usersADFiltered, usersApp);
} catch (err) {
this.logger.error(
`An error occurred while synchronizing users between AD and Aplit Application. Message: ${err.message}`
);
}
}

private async removeUsersFromApp(usersADFiltered: Array<AzureUserDTO>, usersApp: Array<User>) {
const notIntersectedUsers = usersApp.filter(
(userApp) =>
userApp.isDeleted === false &&
usersADFiltered.findIndex(
(userAd) => (userAd.mail ?? userAd.userPrincipalName) === userApp.email
) === -1
);

for (const user of notIntersectedUsers) {
try {
await this.deleteUserUseCase.execute(user._id);
} catch (err) {
this.logger.error(
`An error occurred while deleting user with id '${user._id}' through the syncronize AD Users Cron. Message: ${err.message}`
);
}
}
}
private async addUsersToApp(usersADFiltered: Array<AzureUserDTO>, usersApp: Array<User>) {
const notIntersectedUsers = usersADFiltered.filter(
(userAd) =>
usersApp.findIndex(
(userApp) => userApp.email === (userAd.mail ?? userAd.userPrincipalName)
) === -1
);

for (const user of notIntersectedUsers) {
try {
const splittedName = user.displayName.split(' ');
await this.createUserService.create({
email: user.mail,
firstName: splittedName[0],
lastName: splittedName.at(-1),
providerAccountCreatedAt: user.createdDateTime
});
} catch (err) {
this.logger.error(
`An error as occurred while creating user with email '${user.mail}' through the syncronize AD Users Cron. Message: ${err.message}`
);
}
}
}
}
34 changes: 33 additions & 1 deletion backend/src/modules/azure/services/auth.azure.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
import { ConfidentialClientApplication } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client';
import { Client, PageCollection } from '@microsoft/microsoft-graph-client';
import { ConfigService } from '@nestjs/config';
import { AZURE_AUTHORITY, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } from 'src/libs/constants/azure';
import { AzureUserDTO } from '../dto/azure-user.dto';
Expand Down Expand Up @@ -70,4 +70,36 @@ export default class AuthAzureService implements AuthAzureServiceInterface {
fetchUserPhoto(userId: string) {
return this.graphClient.api(`/users/${userId}/photo/$value`).get();
}

async getADUsers(): Promise<Array<AzureUserDTO>> {
let response: PageCollection = await this.graphClient
.api('/users')
.header('ConsistencyLevel', 'eventual')
.count(true)
.filter("endswith(userPrincipalName,'xgeeks.com') AND accountEnabled eq true")
.select([
'id',
'mail',
'displayName',
'userPrincipalName',
'createdDateTime',
'accountEnabled',
'deletedDateTime',
'employeeLeaveDateTime'
])
.get();

let users = [];
while (response.value.length > 0) {
users = users.concat(response.value);

if (response['@odata.nextLink']) {
response = await this.graphClient.api(response['@odata.nextLink']).get();
} else {
break;
}
}

return users;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createMock } from '@golevelup/ts-jest';
import { UseCase } from 'src/libs/interfaces/use-case.interface';
import { UserRepositoryInterface } from '../repository/user.repository.interface';
import { Test, TestingModule } from '@nestjs/testing';
import User from '../entities/user.schema';
import { USER_REPOSITORY } from 'src/modules/users/constants';
import GetAllUsersIncludeDeletedUseCase from './get-all-users-include-deleted.use-case';

describe('GetAllUsersIncludeDeletedUseCase', () => {
let getAllUsers: UseCase<void, User[]>;

beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GetAllUsersIncludeDeletedUseCase,
{
provide: USER_REPOSITORY,
useValue: createMock<UserRepositoryInterface>()
}
]
}).compile();

getAllUsers = module.get(GetAllUsersIncludeDeletedUseCase);
});

beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});

it('should be defined', () => {
expect(getAllUsers).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY } from '../constants';
import { UserRepositoryInterface } from '../repository/user.repository.interface';
import { UseCase } from 'src/libs/interfaces/use-case.interface';
import User from '../entities/user.schema';

@Injectable()
export default class GetAllUsersIncludeDeletedUseCase implements UseCase<void, User[]> {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepositoryInterface
) {}

execute() {
return this.userRepository.getAllUsersIncludeDeleted();
}
}
Loading

0 comments on commit 1433045

Please sign in to comment.