diff --git a/src/core/ldap/domain/ldap-client.service.spec.ts b/src/core/ldap/domain/ldap-client.service.spec.ts index de02b8202..2d100f5e6 100644 --- a/src/core/ldap/domain/ldap-client.service.spec.ts +++ b/src/core/ldap/domain/ldap-client.service.spec.ts @@ -230,6 +230,32 @@ describe('LDAP Client Service', () => { }); }); + describe('executeWithRetry', () => { + it('when writing operation fails it should automatically retry the operation', async () => { + ldapClientMock.getClient.mockImplementation(() => { + clientMock.bind.mockResolvedValue(); + clientMock.add.mockRejectedValue(new Error()); + clientMock.search.mockResolvedValueOnce(createMock()); //mock existsLehrer + + return clientMock; + }); + const testLehrer: PersonData = { + id: faker.string.uuid(), + vorname: faker.person.firstName(), + familienname: faker.person.lastName(), + referrer: faker.lorem.word(), + ldapEntryUUID: faker.string.uuid(), + }; + const result: Result = await ldapClientService.createLehrer(testLehrer, 'schule-sh.de'); + + expect(result.ok).toBeFalsy(); + expect(clientMock.bind).toHaveBeenCalledTimes(3); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 1 failed')); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 2 failed')); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 3 failed')); + }); + }); + describe('creation', () => { const fakeEmailDomain: string = 'schule-sh.de'; @@ -295,7 +321,7 @@ describe('LDAP Client Service', () => { const result: Result = await ldapClientService.createLehrer(testLehrer, fakeEmailDomain); if (result.ok) throw Error(); - expect(loggerMock.error).toHaveBeenLastCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( `LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:{}`, ); expect(result.error).toEqual(new LdapCreateLehrerError()); @@ -696,8 +722,8 @@ describe('LDAP Client Service', () => { it('should set mailAlternativeAddress as current mailPrimaryAddress and throw LdapPersonEntryChangedEvent', async () => { ldapClientMock.getClient.mockImplementation(() => { - clientMock.bind.mockResolvedValueOnce(); - clientMock.search.mockResolvedValueOnce( + clientMock.bind.mockResolvedValue(); + clientMock.search.mockResolvedValue( createMock({ searchEntries: [ createMock({ @@ -707,7 +733,7 @@ describe('LDAP Client Service', () => { ], }), ); - clientMock.modify.mockRejectedValueOnce(new Error()); + clientMock.modify.mockRejectedValue(new Error()); return clientMock; }); @@ -720,7 +746,7 @@ describe('LDAP Client Service', () => { if (result.ok) throw Error(); expect(result.error).toStrictEqual(new LdapModifyEmailError()); - expect(loggerMock.error).toHaveBeenLastCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( `LDAP: Modifying mailPrimaryAddress and mailAlternativeAddress FAILED, errMsg:{}`, ); expect(eventServiceMock.publish).toHaveBeenCalledTimes(0); diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 02a97318c..bc2984ead 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -27,6 +27,8 @@ export type PersonData = { @Injectable() export class LdapClientService { + public static readonly DEFAULT_RETRIES: number = 3; // e.g. DEFAULT_RETRIES = 3 will produce retry sequence: 1sek, 8sek, 27sek (1000ms * retrycounter^3) + public static readonly OEFFENTLICHE_SCHULEN_DOMAIN_DEFAULT: string = 'schule-sh.de'; public static readonly ERSATZ_SCHULEN_DOMAIN_DEFAULT: string = 'ersatzschule-sh.de'; @@ -62,6 +64,68 @@ export class LdapClientService { this.mutex = new Mutex(); } + //** BELOW ONLY PUBLIC FUNCTIONS - MUST USE THE 'executeWithRetry' WRAPPER TO HAVE STRONG FAULT TOLERANCE*/ + + public async createLehrer(person: PersonData, domain: string, mail?: string): Promise> { + return this.executeWithRetry( + () => this.createLehrerInternal(person, domain, mail), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async changeEmailAddressByPersonId( + personId: PersonID, + referrer: PersonReferrer, + newEmailAddress: string, + ): Promise> { + return this.executeWithRetry( + () => this.changeEmailAddressByPersonIdInternal(personId, referrer, newEmailAddress), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async modifyPersonAttributes( + oldReferrer: string, + newGivenName?: string, + newSn?: string, + newUid?: string, + ): Promise> { + return this.executeWithRetry( + () => this.modifyPersonAttributesInternal(oldReferrer, newGivenName, newSn, newUid), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async deleteLehrerByReferrer(referrer: string): Promise> { + return this.executeWithRetry( + () => this.deleteLehrerByReferrerInternal(referrer), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async deleteLehrer(person: PersonData, domain: string): Promise> { + return this.executeWithRetry( + () => this.deleteLehrerInternal(person, domain), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async changeUserPasswordByPersonId(personId: PersonID, referrer: PersonReferrer): Promise> { + return this.executeWithRetry( + () => this.changeUserPasswordByPersonIdInternal(personId, referrer), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async isLehrerExisting(referrer: string, domain: string): Promise> { + return this.executeWithRetry( + () => this.isLehrerExistingInternal(referrer, domain), + LdapClientService.DEFAULT_RETRIES, + ); + } + + //** BELOW ONLY PRIVATE FUNCTIONS */ + private async bind(): Promise> { this.logger.info('LDAP: bind'); try { @@ -116,26 +180,12 @@ export class LdapClientService { return rootName; } - public async createLehrer( - person: PersonData, - domain: string, - mail?: string, //Wird hier erstmal seperat mit reingegeben bis die Umstellung auf primary/alternative erfolgt - ): Promise> { - const referrer: string | undefined = person.referrer; - if (!referrer) { - return { - ok: false, - error: new UsernameRequiredError( - `Lehrer ${person.vorname} ${person.familienname} does not have a username`, - ), - }; - } + private async isLehrerExistingInternal(referrer: string, domain: string): Promise> { const rootName: Result = this.getRootNameOrError(domain); if (!rootName.ok) return rootName; - const lehrerUid: string = this.getLehrerUid(referrer, rootName.value); return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: createLehrer'); + this.logger.info('LDAP: isLehrerExisting'); const client: Client = this.ldapClient.getClient(); const bindResult: Result = await this.bind(); if (!bindResult.ok) return bindResult; @@ -143,71 +193,66 @@ export class LdapClientService { const searchResultLehrer: SearchResult = await client.search( `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, { - filter: `(uid=${person.referrer})`, + filter: `(uid=${referrer})`, }, ); if (searchResultLehrer.searchEntries.length > 0) { - this.logger.info(`LDAP: Lehrer ${lehrerUid} exists, nothing to create`); - - return { ok: true, value: person }; - } - const entry: LdapPersonEntry = { - uid: referrer, - uidNumber: LdapClientService.UID_NUMBER, - gidNumber: LdapClientService.GID_NUMBER, - homeDirectory: LdapClientService.HOME_DIRECTORY, - cn: referrer, - givenName: person.vorname, - sn: person.familienname, - objectclass: ['inetOrgPerson', 'univentionMail', 'posixAccount'], - mailPrimaryAddress: mail ?? ``, - mailAlternativeAddress: mail ?? ``, - }; - - const controls: Control[] = []; - if (person.ldapEntryUUID) { - entry.entryUUID = person.ldapEntryUUID; - controls.push(new Control(LdapClientService.RELAX_OID)); + return { ok: true, value: true }; } + return { ok: true, value: false }; + }); + } - try { - await client.add(lehrerUid, entry, controls); - this.logger.info(`LDAP: Successfully created lehrer ${lehrerUid}`); - - return { ok: true, value: person }; - } catch (err) { - const errMsg: string = JSON.stringify(err); - this.logger.error(`LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:${errMsg}`); + private async deleteLehrerByReferrerInternal(referrer: string): Promise> { + return this.mutex.runExclusive(async () => { + this.logger.info('LDAP: deleteLehrer'); + const client: Client = this.ldapClient.getClient(); + const bindResult: Result = await this.bind(); + if (!bindResult.ok) return bindResult; - return { ok: false, error: new LdapCreateLehrerError() }; + const searchResultLehrer: SearchResult = await client.search(`${LdapClientService.DC_SCHULE_SH_DC_DE}`, { + scope: 'sub', + filter: `(uid=${referrer})`, + }); + if (!searchResultLehrer.searchEntries[0]) { + return { + ok: false, + error: new LdapSearchError(LdapEntityType.LEHRER), + }; } + await client.del(searchResultLehrer.searchEntries[0].dn); + this.logger.info(`LDAP: Successfully deleted lehrer by person:${referrer}`); + + return { ok: true, value: referrer }; }); } - public async isLehrerExisting(referrer: string, domain: string): Promise> { + private async deleteLehrerInternal(person: PersonData, domain: string): Promise> { const rootName: Result = this.getRootNameOrError(domain); if (!rootName.ok) return rootName; return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: isLehrerExisting'); + this.logger.info('LDAP: deleteLehrer'); const client: Client = this.ldapClient.getClient(); const bindResult: Result = await this.bind(); if (!bindResult.ok) return bindResult; - - const searchResultLehrer: SearchResult = await client.search( - `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, - { - filter: `(uid=${referrer})`, - }, - ); - if (searchResultLehrer.searchEntries.length > 0) { - return { ok: true, value: true }; + if (!person.referrer) { + return { + ok: false, + error: new UsernameRequiredError( + `Lehrer ${person.vorname} ${person.familienname} does not have a username`, + ), + }; } - return { ok: true, value: false }; + const lehrerUid: string = this.getLehrerUid(person.referrer, rootName.value); + await client.del(lehrerUid); + this.logger.info(`LDAP: Successfully deleted lehrer ${lehrerUid}`); + + return { ok: true, value: person }; }); } - public async modifyPersonAttributes( + private async modifyPersonAttributesInternal( oldReferrer: string, newGivenName?: string, newSn?: string, @@ -286,62 +331,14 @@ export class LdapClientService { }); } - public async deleteLehrerByReferrer(referrer: string): Promise> { - return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: deleteLehrer'); - const client: Client = this.ldapClient.getClient(); - const bindResult: Result = await this.bind(); - if (!bindResult.ok) return bindResult; - - const searchResultLehrer: SearchResult = await client.search(`${LdapClientService.DC_SCHULE_SH_DC_DE}`, { - scope: 'sub', - filter: `(uid=${referrer})`, - }); - if (!searchResultLehrer.searchEntries[0]) { - return { - ok: false, - error: new LdapSearchError(LdapEntityType.LEHRER), - }; - } - await client.del(searchResultLehrer.searchEntries[0].dn); - this.logger.info(`LDAP: Successfully deleted lehrer by person:${referrer}`); - - return { ok: true, value: referrer }; - }); - } - - public async deleteLehrer(person: PersonData, domain: string): Promise> { - const rootName: Result = this.getRootNameOrError(domain); - if (!rootName.ok) return rootName; - - return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: deleteLehrer'); - const client: Client = this.ldapClient.getClient(); - const bindResult: Result = await this.bind(); - if (!bindResult.ok) return bindResult; - if (!person.referrer) { - return { - ok: false, - error: new UsernameRequiredError( - `Lehrer ${person.vorname} ${person.familienname} does not have a username`, - ), - }; - } - const lehrerUid: string = this.getLehrerUid(person.referrer, rootName.value); - await client.del(lehrerUid); - this.logger.info(`LDAP: Successfully deleted lehrer ${lehrerUid}`); - - return { ok: true, value: person }; - }); - } - - public async changeEmailAddressByPersonId( + public async changeEmailAddressByPersonIdInternal( personId: PersonID, referrer: PersonReferrer, newEmailAddress: string, ): Promise> { // Converted to avoid PersonRepository-ref, UEM-password-generation //const referrer: string | undefined = await this.getPersonReferrerOrUndefined(personId); + return this.mutex.runExclusive(async () => { this.logger.info('LDAP: changeEmailAddress'); const splitted: string[] = newEmailAddress.split('@'); @@ -425,17 +422,86 @@ export class LdapClientService { }); } - public async changeUserPasswordByPersonId(personId: PersonID, referrer: PersonReferrer): Promise> { + private async createLehrerInternal( + person: PersonData, + domain: string, + mail?: string, //Wird hier erstmal seperat mit reingegeben bis die Umstellung auf primary/alternative erfolgt + ): Promise> { + const referrer: string | undefined = person.referrer; + if (!referrer) { + return { + ok: false, + error: new UsernameRequiredError( + `Lehrer ${person.vorname} ${person.familienname} does not have a username`, + ), + }; + } + const rootName: Result = this.getRootNameOrError(domain); + if (!rootName.ok) return rootName; + + const lehrerUid: string = this.getLehrerUid(referrer, rootName.value); + return this.mutex.runExclusive(async () => { + this.logger.info('LDAP: createLehrer'); + const client: Client = this.ldapClient.getClient(); + const bindResult: Result = await this.bind(); + if (!bindResult.ok) return bindResult; + + const searchResultLehrer: SearchResult = await client.search( + `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, + { + filter: `(uid=${person.referrer})`, + }, + ); + if (searchResultLehrer.searchEntries.length > 0) { + this.logger.info(`LDAP: Lehrer ${lehrerUid} exists, nothing to create`); + + return { ok: true, value: person }; + } + const entry: LdapPersonEntry = { + uid: referrer, + uidNumber: LdapClientService.UID_NUMBER, + gidNumber: LdapClientService.GID_NUMBER, + homeDirectory: LdapClientService.HOME_DIRECTORY, + cn: referrer, + givenName: person.vorname, + sn: person.familienname, + objectclass: ['inetOrgPerson', 'univentionMail', 'posixAccount'], + mailPrimaryAddress: mail ?? ``, + mailAlternativeAddress: mail ?? ``, + }; + + const controls: Control[] = []; + if (person.ldapEntryUUID) { + entry.entryUUID = person.ldapEntryUUID; + controls.push(new Control(LdapClientService.RELAX_OID)); + } + + try { + await client.add(lehrerUid, entry, controls); + this.logger.info(`LDAP: Successfully created lehrer ${lehrerUid}`); + + return { ok: true, value: person }; + } catch (err) { + const errMsg: string = JSON.stringify(err); + this.logger.error(`LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:${errMsg}`); + + return { ok: false, error: new LdapCreateLehrerError() }; + } + }); + } + + private async changeUserPasswordByPersonIdInternal( + personId: PersonID, + referrer: PersonReferrer, + ): Promise> { // Converted to avoid PersonRepository-ref, UEM-password-generation //const referrer: string | undefined = await this.getPersonReferrerOrUndefined(personId); const userPassword: string = generatePassword(); - return this.mutex.runExclusive(async () => { this.logger.info('LDAP: changeUserPassword'); const client: Client = this.ldapClient.getClient(); const bindResult: Result = await this.bind(); if (!bindResult.ok) return bindResult; - const searchResult: SearchResult = await client.search(`${LdapClientService.DC_SCHULE_SH_DC_DE}`, { scope: 'sub', filter: `(uid=${referrer})`, @@ -449,7 +515,6 @@ export class LdapClientService { error: new LdapSearchError(LdapEntityType.LEHRER), }; } - try { await client.modify(searchResult.searchEntries[0].dn, [ new Change({ @@ -462,14 +527,51 @@ export class LdapClientService { ]); this.logger.info(`LDAP: Successfully modified userPassword (UEM) for personId:${personId}`); this.eventService.publish(new LdapPersonEntryChangedEvent(personId, undefined, undefined, true)); - return { ok: true, value: userPassword }; } catch (err) { const errMsg: string = JSON.stringify(err); this.logger.error(`LDAP: Modifying userPassword (UEM) FAILED, errMsg:${errMsg}`); - return { ok: false, error: new LdapModifyUserPasswordError() }; } }); } + + private async executeWithRetry( + func: () => Promise>, + retries: number, + delay: number = 1000, + ): Promise> { + let currentAttempt: number = 1; + let result: Result = { + ok: false, + error: new Error('executeWithRetry default fallback'), + }; + + while (currentAttempt <= retries) { + try { + // eslint-disable-next-line no-await-in-loop + result = await func(); + if (result.ok) { + return result; + } else { + throw new Error(`Function returned error: ${result.error.message}`); + } + } catch (error) { + const currentDelay: number = delay * Math.pow(currentAttempt, 3); + this.logger.warning( + `Attempt ${currentAttempt} failed. Retrying in ${currentDelay}ms... Remaining retries: ${retries - currentAttempt}`, + ); + + // eslint-disable-next-line no-await-in-loop + await this.sleep(currentDelay); + } + currentAttempt++; + } + this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); + return result; + } + + private async sleep(ms: number): Promise { + return new Promise((resolve: () => void) => setTimeout(resolve, ms)); + } }