Skip to content

Commit 1433045

Browse files
feat: issue 1594 synchronize users ad app (#1596)
1 parent e238d2e commit 1433045

16 files changed

+360
-8
lines changed

backend/src/libs/test-utils/mocks/factories/azure-user-factory.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { buildTestFactory } from './generic-factory.mock';
33
import { AzureUserDTO } from 'src/modules/azure/dto/azure-user.dto';
44

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

811
return {
912
id: faker.datatype.uuid(),
10-
displayName: faker.name.firstName() + faker.name.lastName(),
13+
displayName: firstName + ' ' + lastName,
1114
mail: mail,
1215
userPrincipalName: mail,
1316
createdDateTime: faker.date.past(5),

backend/src/modules/azure/azure.module.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ import AuthModule from '../auth/auth.module';
33
import { CommunicationModule } from '../communication/communication.module';
44
import { StorageModule } from '../storage/storage.module';
55
import UsersModule from '../users/users.module';
6-
import { authAzureService, checkUserUseCase, registerOrLoginUseCase } from './azure.providers';
6+
import {
7+
authAzureService,
8+
checkUserUseCase,
9+
registerOrLoginUseCase,
10+
synchronizeADUsersCronUseCase
11+
} from './azure.providers';
712
import AzureController from './controller/azure.controller';
813
import { JwtRegister } from 'src/infrastructure/config/jwt.register';
914

1015
@Module({
1116
imports: [UsersModule, AuthModule, CommunicationModule, StorageModule, JwtRegister],
1217
controllers: [AzureController],
13-
providers: [authAzureService, checkUserUseCase, registerOrLoginUseCase]
18+
providers: [
19+
authAzureService,
20+
checkUserUseCase,
21+
registerOrLoginUseCase,
22+
synchronizeADUsersCronUseCase
23+
]
1424
})
1525
export default class AzureModule {}

backend/src/modules/azure/azure.providers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { CheckUserAzureUseCase } from './applications/check-user.azure.use-case';
22
import { RegisterOrLoginAzureUseCase } from './applications/register-or-login.azure.use-case';
3-
import { AUTH_AZURE_SERVICE, CHECK_USER_USE_CASE, REGISTER_OR_LOGIN_USE_CASE } from './constants';
3+
import {
4+
AUTH_AZURE_SERVICE,
5+
CHECK_USER_USE_CASE,
6+
REGISTER_OR_LOGIN_USE_CASE,
7+
SYNCHRONIZE_AD_USERS_CRON_USE_CASE
8+
} from './constants';
9+
import { SynchronizeADUsersCronUseCase } from './schedules/synchronize-ad-users.cron.azure.use-case';
410
import AuthAzureService from './services/auth.azure.service';
511

612
/* SERVICE */
@@ -21,3 +27,8 @@ export const registerOrLoginUseCase = {
2127
provide: REGISTER_OR_LOGIN_USE_CASE,
2228
useClass: RegisterOrLoginAzureUseCase
2329
};
30+
31+
export const synchronizeADUsersCronUseCase = {
32+
provide: SYNCHRONIZE_AD_USERS_CRON_USE_CASE,
33+
useClass: SynchronizeADUsersCronUseCase
34+
};

backend/src/modules/azure/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ export const AUTH_AZURE_SERVICE = 'AuthAzureService';
77
export const REGISTER_OR_LOGIN_USE_CASE = 'RegisterOrLoginUseCase';
88

99
export const CHECK_USER_USE_CASE = 'CheckUserUseCase';
10+
11+
export const SYNCHRONIZE_AD_USERS_CRON_USE_CASE = 'SynchronizeADUsersCronUseCase';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { UseCase } from 'src/libs/interfaces/use-case.interface';
2+
3+
export interface SynchronizeADUsersCronUseCaseInterface extends UseCase<void, void> {
4+
execute(): Promise<void>;
5+
}

backend/src/modules/azure/interfaces/services/auth.azure.service.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ import { AzureUserDTO } from '../../dto/azure-user.dto';
33
export interface AuthAzureServiceInterface {
44
getUserFromAzure(email: string): Promise<AzureUserDTO | undefined>;
55
fetchUserPhoto(userId: string): Promise<any>;
6+
getADUsers(): Promise<Array<AzureUserDTO>>;
67
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AUTH_AZURE_SERVICE } from '../constants';
3+
import { DeepMocked, createMock } from '@golevelup/ts-jest';
4+
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
5+
import { DeleteUserUseCase } from 'src/modules/users/applications/delete-user.use-case';
6+
import {
7+
CREATE_USER_SERVICE,
8+
DELETE_USER_USE_CASE,
9+
GET_ALL_USERS_INCLUDE_DELETED_USE_CASE
10+
} from 'src/modules/users/constants';
11+
import { UserFactory } from 'src/libs/test-utils/mocks/factories/user-factory';
12+
import { UseCase } from 'src/libs/interfaces/use-case.interface';
13+
import { AzureUserFactory } from 'src/libs/test-utils/mocks/factories/azure-user-factory';
14+
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';
15+
import GetAllUsersIncludeDeletedUseCase from 'src/modules/users/applications/get-all-users-include-deleted.use-case';
16+
import { SynchronizeADUsersCronUseCase } from './synchronize-ad-users.cron.azure.use-case';
17+
18+
const usersAD = AzureUserFactory.createMany(4, () => ({
19+
deletedDateTime: null,
20+
employeeLeaveDateTime: null
21+
}));
22+
const users = UserFactory.createMany(
23+
4,
24+
usersAD.map((u) => ({
25+
email: u.mail,
26+
firstName: u.displayName.split(' ')[0],
27+
lastName: u.displayName.split(' ')[1]
28+
})) as never
29+
);
30+
31+
describe('SynchronizeAdUsersCronUseCase', () => {
32+
let synchronizeADUsers: UseCase<void, void>;
33+
let authAzureServiceMock: DeepMocked<AuthAzureServiceInterface>;
34+
let getAllUsersMock: DeepMocked<GetAllUsersIncludeDeletedUseCase>;
35+
let deleteUserMock: DeepMocked<DeleteUserUseCase>;
36+
let createUserServiceMock: DeepMocked<CreateUserServiceInterface>;
37+
beforeAll(async () => {
38+
const module: TestingModule = await Test.createTestingModule({
39+
providers: [
40+
SynchronizeADUsersCronUseCase,
41+
{
42+
provide: AUTH_AZURE_SERVICE,
43+
useValue: createMock<AuthAzureServiceInterface>()
44+
},
45+
{
46+
provide: GET_ALL_USERS_INCLUDE_DELETED_USE_CASE,
47+
useValue: createMock<GetAllUsersIncludeDeletedUseCase>()
48+
},
49+
{
50+
provide: DELETE_USER_USE_CASE,
51+
useValue: createMock<DeleteUserUseCase>()
52+
},
53+
{
54+
provide: CREATE_USER_SERVICE,
55+
useValue: createMock<CreateUserServiceInterface>()
56+
}
57+
]
58+
}).compile();
59+
60+
synchronizeADUsers = module.get(SynchronizeADUsersCronUseCase);
61+
authAzureServiceMock = module.get(AUTH_AZURE_SERVICE);
62+
getAllUsersMock = module.get(GET_ALL_USERS_INCLUDE_DELETED_USE_CASE);
63+
deleteUserMock = module.get(DELETE_USER_USE_CASE);
64+
createUserServiceMock = module.get(CREATE_USER_SERVICE);
65+
});
66+
beforeEach(() => {
67+
jest.clearAllMocks();
68+
jest.resetAllMocks();
69+
});
70+
71+
it('should be defined', () => {
72+
expect(synchronizeADUsers).toBeDefined();
73+
});
74+
it('execute', async () => {
75+
const userNotInApp = AzureUserFactory.create({
76+
employeeLeaveDateTime: null,
77+
deletedDateTime: null
78+
});
79+
const finalADUsers = [userNotInApp, ...usersAD];
80+
authAzureServiceMock.getADUsers.mockResolvedValueOnce(finalADUsers);
81+
const userNotInAD = UserFactory.create();
82+
const finalAppUsers = [userNotInAD, ...users];
83+
getAllUsersMock.execute.mockResolvedValueOnce(finalAppUsers);
84+
await synchronizeADUsers.execute();
85+
expect(deleteUserMock.execute).toBeCalledWith(userNotInAD._id);
86+
expect(deleteUserMock.execute.mock.calls).toEqual([[userNotInAD._id]]);
87+
expect(createUserServiceMock.create).toHaveBeenCalledWith({
88+
email: userNotInApp.mail,
89+
firstName: userNotInApp.displayName.split(' ')[0],
90+
lastName: userNotInApp.displayName.split(' ')[1],
91+
providerAccountCreatedAt: userNotInApp.createdDateTime
92+
});
93+
});
94+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
2+
import { Cron } from '@nestjs/schedule';
3+
import { SynchronizeADUsersCronUseCaseInterface } from '../interfaces/schedules/synchronize-ad-users.cron.azure.use-case.interface';
4+
import { AUTH_AZURE_SERVICE } from '../constants';
5+
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
6+
import {
7+
CREATE_USER_SERVICE,
8+
DELETE_USER_USE_CASE,
9+
GET_ALL_USERS_INCLUDE_DELETED_USE_CASE
10+
} from 'src/modules/users/constants';
11+
import { UseCase } from 'src/libs/interfaces/use-case.interface';
12+
import User from 'src/modules/users/entities/user.schema';
13+
import { AzureUserDTO } from '../dto/azure-user.dto';
14+
import { CreateUserServiceInterface } from 'src/modules/users/interfaces/services/create.user.service.interface';
15+
16+
@Injectable()
17+
export class SynchronizeADUsersCronUseCase implements SynchronizeADUsersCronUseCaseInterface {
18+
private readonly logger: Logger = new Logger(SynchronizeADUsersCronUseCase.name);
19+
constructor(
20+
@Inject(AUTH_AZURE_SERVICE)
21+
private readonly authAzureService: AuthAzureServiceInterface,
22+
@Inject(GET_ALL_USERS_INCLUDE_DELETED_USE_CASE)
23+
private readonly getAllUsersIncludeDeletedUseCase: UseCase<void, Array<User>>,
24+
@Inject(DELETE_USER_USE_CASE)
25+
private readonly deleteUserUseCase: UseCase<string, boolean>,
26+
@Inject(CREATE_USER_SERVICE)
27+
private readonly createUserService: CreateUserServiceInterface
28+
) {}
29+
30+
//Runs every saturday at mid-night
31+
//@Cron('0 0 * * 6')
32+
@Cron('0 14 * * *')
33+
async execute() {
34+
try {
35+
const usersADAll = await this.authAzureService.getADUsers();
36+
37+
if (!usersADAll.length) {
38+
throw new Error('Azure AD users list is empty.');
39+
}
40+
41+
const usersApp = await this.getAllUsersIncludeDeletedUseCase.execute();
42+
43+
if (!usersApp.length) {
44+
throw new Error('Split app users list is empty.');
45+
}
46+
47+
const today = new Date();
48+
//Filter out users that don't have a '.' in the beggining of the email
49+
let usersADFiltered = usersADAll.filter((u) =>
50+
/[a-z]+\.[a-zA-Z0-9]+@/.test(u.userPrincipalName)
51+
);
52+
53+
//Filter out users that have a deletedDateTime bigger than 'today'
54+
usersADFiltered = usersADFiltered.filter((u) =>
55+
'deletedDateTime' in u ? u.deletedDateTime === null || u.deletedDateTime >= today : true
56+
);
57+
58+
//Filter out users that have a employeeLeaveDateTime bigger than 'today'
59+
usersADFiltered = usersADFiltered.filter((u) =>
60+
'employeeLeaveDateTime' in u
61+
? u.employeeLeaveDateTime === null || u.employeeLeaveDateTime >= today
62+
: true
63+
);
64+
65+
await this.removeUsersFromApp(usersADFiltered, usersApp);
66+
await this.addUsersToApp(usersADFiltered, usersApp);
67+
} catch (err) {
68+
this.logger.error(
69+
`An error occurred while synchronizing users between AD and Aplit Application. Message: ${err.message}`
70+
);
71+
}
72+
}
73+
74+
private async removeUsersFromApp(usersADFiltered: Array<AzureUserDTO>, usersApp: Array<User>) {
75+
const notIntersectedUsers = usersApp.filter(
76+
(userApp) =>
77+
userApp.isDeleted === false &&
78+
usersADFiltered.findIndex(
79+
(userAd) => (userAd.mail ?? userAd.userPrincipalName) === userApp.email
80+
) === -1
81+
);
82+
83+
for (const user of notIntersectedUsers) {
84+
try {
85+
await this.deleteUserUseCase.execute(user._id);
86+
} catch (err) {
87+
this.logger.error(
88+
`An error occurred while deleting user with id '${user._id}' through the syncronize AD Users Cron. Message: ${err.message}`
89+
);
90+
}
91+
}
92+
}
93+
private async addUsersToApp(usersADFiltered: Array<AzureUserDTO>, usersApp: Array<User>) {
94+
const notIntersectedUsers = usersADFiltered.filter(
95+
(userAd) =>
96+
usersApp.findIndex(
97+
(userApp) => userApp.email === (userAd.mail ?? userAd.userPrincipalName)
98+
) === -1
99+
);
100+
101+
for (const user of notIntersectedUsers) {
102+
try {
103+
const splittedName = user.displayName.split(' ');
104+
await this.createUserService.create({
105+
email: user.mail,
106+
firstName: splittedName[0],
107+
lastName: splittedName.at(-1),
108+
providerAccountCreatedAt: user.createdDateTime
109+
});
110+
} catch (err) {
111+
this.logger.error(
112+
`An error as occurred while creating user with email '${user.mail}' through the syncronize AD Users Cron. Message: ${err.message}`
113+
);
114+
}
115+
}
116+
}
117+
}

backend/src/modules/azure/services/auth.azure.service.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { AuthAzureServiceInterface } from '../interfaces/services/auth.azure.service.interface';
33
import { ConfidentialClientApplication } from '@azure/msal-node';
4-
import { Client } from '@microsoft/microsoft-graph-client';
4+
import { Client, PageCollection } from '@microsoft/microsoft-graph-client';
55
import { ConfigService } from '@nestjs/config';
66
import { AZURE_AUTHORITY, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } from 'src/libs/constants/azure';
77
import { AzureUserDTO } from '../dto/azure-user.dto';
@@ -70,4 +70,36 @@ export default class AuthAzureService implements AuthAzureServiceInterface {
7070
fetchUserPhoto(userId: string) {
7171
return this.graphClient.api(`/users/${userId}/photo/$value`).get();
7272
}
73+
74+
async getADUsers(): Promise<Array<AzureUserDTO>> {
75+
let response: PageCollection = await this.graphClient
76+
.api('/users')
77+
.header('ConsistencyLevel', 'eventual')
78+
.count(true)
79+
.filter("endswith(userPrincipalName,'xgeeks.com') AND accountEnabled eq true")
80+
.select([
81+
'id',
82+
'mail',
83+
'displayName',
84+
'userPrincipalName',
85+
'createdDateTime',
86+
'accountEnabled',
87+
'deletedDateTime',
88+
'employeeLeaveDateTime'
89+
])
90+
.get();
91+
92+
let users = [];
93+
while (response.value.length > 0) {
94+
users = users.concat(response.value);
95+
96+
if (response['@odata.nextLink']) {
97+
response = await this.graphClient.api(response['@odata.nextLink']).get();
98+
} else {
99+
break;
100+
}
101+
}
102+
103+
return users;
104+
}
73105
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createMock } from '@golevelup/ts-jest';
2+
import { UseCase } from 'src/libs/interfaces/use-case.interface';
3+
import { UserRepositoryInterface } from '../repository/user.repository.interface';
4+
import { Test, TestingModule } from '@nestjs/testing';
5+
import User from '../entities/user.schema';
6+
import { USER_REPOSITORY } from 'src/modules/users/constants';
7+
import GetAllUsersIncludeDeletedUseCase from './get-all-users-include-deleted.use-case';
8+
9+
describe('GetAllUsersIncludeDeletedUseCase', () => {
10+
let getAllUsers: UseCase<void, User[]>;
11+
12+
beforeAll(async () => {
13+
const module: TestingModule = await Test.createTestingModule({
14+
providers: [
15+
GetAllUsersIncludeDeletedUseCase,
16+
{
17+
provide: USER_REPOSITORY,
18+
useValue: createMock<UserRepositoryInterface>()
19+
}
20+
]
21+
}).compile();
22+
23+
getAllUsers = module.get(GetAllUsersIncludeDeletedUseCase);
24+
});
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
jest.resetAllMocks();
29+
});
30+
31+
it('should be defined', () => {
32+
expect(getAllUsers).toBeDefined();
33+
});
34+
});

0 commit comments

Comments
 (0)