diff --git a/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts b/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts new file mode 100644 index 0000000000..209899cf5c --- /dev/null +++ b/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts @@ -0,0 +1,1376 @@ +import { QueryFailedError } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; +import { Client } from 'ldapts'; +import type { Cipher } from 'n8n-core'; +import { randomString } from 'n8n-workflow'; + +import config from '@/config'; +import type { Settings } from '@/databases/entities/settings'; +import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; +import type { EventService } from '@/events/event.service'; +import { mockInstance, mockLogger } from '@test/mocking'; + +import { + BINARY_AD_ATTRIBUTES, + LDAP_LOGIN_ENABLED, + LDAP_LOGIN_LABEL, + LDAP_FEATURE_NAME, +} from '../constants'; +import { + getLdapIds, + createFilter, + escapeFilter, + resolveBinaryAttributes, + processUsers, + mapLdapUserToDbUser, + saveLdapSynchronization, + resolveEntryBinaryAttributes, +} from '../helpers.ee'; +import { LdapService } from '../ldap.service.ee'; +import type { LdapConfig } from '../types'; + +// Mock ldapts client +jest.mock('ldapts', () => { + const ClientMock = jest.fn(); + + ClientMock.prototype.bind = jest.fn(); + ClientMock.prototype.unbind = jest.fn(); + ClientMock.prototype.startTLS = jest.fn(); + ClientMock.prototype.search = jest.fn(); + + return { Client: ClientMock }; +}); + +jest.mock('../helpers.ee', () => ({ + ...jest.requireActual('../helpers.ee'), + getLdapIds: jest.fn(), + saveLdapSynchronization: jest.fn(), + resolveBinaryAttributes: jest.fn(), + processUsers: jest.fn(), + resolveEntryBinaryAttributes: jest.fn(), +})); + +jest.mock('n8n-workflow', () => ({ + ...jest.requireActual('n8n-workflow'), + randomString: jest.fn(), +})); + +describe('LdapService', () => { + const ldapConfig: LdapConfig = { + loginEnabled: true, + loginLabel: 'fakeLoginLabel', + connectionUrl: 'connection.url', + allowUnauthorizedCerts: true, + connectionSecurity: 'none', + connectionPort: 1234, + baseDn: 'dc=example,dc=com', + bindingAdminDn: 'uid=jdoe,ou=users,dc=example,dc=com', + bindingAdminPassword: 'fakePassword', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + loginIdAttribute: 'uid', + ldapIdAttribute: 'uid', + userFilter: '(uid=jdoe)', + synchronizationEnabled: true, + synchronizationInterval: 60, + searchPageSize: 1, + searchTimeout: 6, + }; + + let settingsRepository = mockInstance(SettingsRepository); + + beforeAll(() => { + // Need fake timers to avoid setInterval + // problems with the scheduled sync + jest.useFakeTimers(); + }); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + const mockSettingsRespositoryFindOneByOrFail = (config: LdapConfig) => { + settingsRepository.findOneByOrFail.mockResolvedValueOnce({ + value: JSON.stringify(config), + } as Settings); + }; + + const createDefaultLdapService = (config: LdapConfig) => { + mockSettingsRespositoryFindOneByOrFail(config); + + return new LdapService(mockLogger(), settingsRepository, mock(), mock()); + }; + + describe('init()', () => { + it('should load the LDAP configuration', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const loadConfigSpy = jest.spyOn(ldapService, 'loadConfig'); + + await ldapService.init(); + + expect(loadConfigSpy).toHaveBeenCalledTimes(1); + }); + + it('should set expected configuration variables from LDAP config if LDAP is enabled', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const configSetSpy = jest.spyOn(config, 'set'); + + await ldapService.init(); + + expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, ldapConfig.loginEnabled); + expect(configSetSpy).toHaveBeenNthCalledWith( + 2, + 'userManagement.authenticationMethod', + 'ldap', + ); + expect(configSetSpy).toHaveBeenNthCalledWith(3, LDAP_LOGIN_LABEL, ldapConfig.loginLabel); + }); + + it('should set expected configuration variables from LDAP config if LDAP is disabled', async () => { + const givenConfig = { ...ldapConfig, loginEnabled: false }; + + const ldapService = createDefaultLdapService(givenConfig); + + const configSetSpy = jest.spyOn(config, 'set'); + + await ldapService.init(); + + expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, givenConfig.loginEnabled); + expect(configSetSpy).toHaveBeenNthCalledWith( + 2, + 'userManagement.authenticationMethod', + 'email', + ); + expect(configSetSpy).toHaveBeenNthCalledWith(3, LDAP_LOGIN_LABEL, givenConfig.loginLabel); + }); + + it('should show logger warning if authentication method is not ldap or email', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const logger = mockLogger(); + + const ldapService = new LdapService(logger, settingsRepository, mock(), mock()); + + config.set('userManagement.authenticationMethod', 'invalid'); + + await ldapService.init(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: invalid)', + expect.any(Error), + ); + }); + + it('should schedule syncing if config has enabled synchronization', async () => { + const givenConfig = { + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 10, + }; + + const ldapService = createDefaultLdapService(givenConfig); + + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + await ldapService.init(); + + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + givenConfig.synchronizationInterval * 60_000, + ); + }); + + it('should throw an error if config has enabled synchronization but no synchronizationInterval is set', async () => { + const ldapService = createDefaultLdapService({ + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 0, + }); + + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + await expect(ldapService.init()).rejects.toThrowError('Interval variable has to be defined'); + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); + }); + + describe('loadConfig()', () => { + it('should retrieve the LDAP configuration from the settings repository', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + await ldapService.loadConfig(); + + expect(settingsRepository.findOneByOrFail).toHaveBeenCalledTimes(1); + }); + + it('should throw an expected error if the LDAP configuration is not found', async () => { + settingsRepository.findOneByOrFail.mockRejectedValue( + new Error('LDAP configuration not found'), + ); + + const ldapService = new LdapService(mockLogger(), settingsRepository, mock(), mock()); + + await expect(ldapService.loadConfig()).rejects.toThrowError('LDAP configuration not found'); + }); + + it('should decipher the LDAP configuration admin password', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn(), + }); + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.loadConfig(); + + expect(cipherMock.decrypt).toHaveBeenCalledTimes(1); + expect(cipherMock.decrypt).toHaveBeenCalledWith(ldapConfig.bindingAdminPassword); + }); + + it('should return the expected LDAP configuration', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const config = await ldapService.loadConfig(); + + expect(config).toEqual({ ...ldapConfig, bindingAdminPassword: 'decryptedPassword' }); + }); + }); + + describe('updateConfig()', () => { + it('should throw expected error if the LDAP configuration is invalid', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const invalidLdapConfig = { ...ldapConfig, loginEnabled: 'notABoolean' }; + + await expect( + ldapService.updateConfig(invalidLdapConfig as unknown as LdapConfig), + ).rejects.toThrowError('request.body.loginEnabled is not of a type(s) boolean'); + }); + + it('should throw expected error if login is enabled and the current authentication method is "saml"', async () => { + config.set('userManagement.authenticationMethod', 'saml'); + + const ldapService = createDefaultLdapService(ldapConfig); + + await expect(ldapService.updateConfig(ldapConfig)).rejects.toThrowError( + 'LDAP cannot be enabled if SSO in enabled', + ); + }); + + it('should encrypt the binding admin password', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + encrypt: jest.fn().mockReturnValue('encryptedPassword'), + }); + + config.set('userManagement.authenticationMethod', 'email'); + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const newConfig = { ...ldapConfig }; + await ldapService.updateConfig(newConfig); + + expect(cipherMock.encrypt).toHaveBeenCalledTimes(1); + expect(cipherMock.encrypt).toHaveBeenCalledWith(ldapConfig.bindingAdminPassword); + expect(newConfig.bindingAdminPassword).toEqual('encryptedPassword'); + }); + + it('should delete all ldap identities if login is disabled and ldap users exist', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const authIdentityRepository = mockInstance(AuthIdentityRepository, { + find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]), + delete: jest.fn(), + }); + + const cipherMock = mock({ + encrypt: jest.fn().mockReturnValue('encryptedPassword'), + }); + + config.set('userManagement.authenticationMethod', 'email'); + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const newConfig = { ...ldapConfig, loginEnabled: false, synchronizationEnabled: true }; + await ldapService.updateConfig(newConfig); + + expect(newConfig.synchronizationEnabled).toBeFalsy(); + expect(authIdentityRepository.delete).toHaveBeenCalledTimes(1); + expect(authIdentityRepository.delete).toHaveBeenCalledWith({ providerType: 'ldap' }); + }); + + it('should not delete ldap identities if login is disabled and there are no ldap identities', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const authIdentityRepository = mockInstance(AuthIdentityRepository, { + find: jest.fn().mockResolvedValue([]), + delete: jest.fn(), + }); + + const cipherMock = mock({ + encrypt: jest.fn().mockReturnValue('encryptedPassword'), + }); + + config.set('userManagement.authenticationMethod', 'email'); + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const newConfig = { ...ldapConfig, loginEnabled: false, synchronizationEnabled: true }; + await ldapService.updateConfig(newConfig); + + expect(newConfig.synchronizationEnabled).toBeFalsy(); + expect(authIdentityRepository.delete).not.toHaveBeenCalled(); + expect(authIdentityRepository.delete).not.toHaveBeenCalled(); + }); + + it('should update the LDAP configuration in the settings repository', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + mockInstance(AuthIdentityRepository, { + find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]), + delete: jest.fn(), + }); + + const cipherMock = mock({ + encrypt: jest.fn().mockReturnValue('encryptedPassword'), + }); + + config.set('userManagement.authenticationMethod', 'email'); + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const newConfig = { ...ldapConfig, loginEnabled: false, synchronizationEnabled: true }; + await ldapService.updateConfig(newConfig); + + expect(settingsRepository.update).toHaveBeenCalledTimes(1); + expect(settingsRepository.update).toHaveBeenCalledWith( + { key: LDAP_FEATURE_NAME }, + { value: JSON.stringify(newConfig), loadOnStartup: true }, + ); + }); + + it('should update the LDAP login label in the config', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + mockInstance(AuthIdentityRepository, { + find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]), + delete: jest.fn(), + }); + + const cipherMock = mock({ + encrypt: jest.fn().mockReturnValue('encryptedPassword'), + }); + const configSetSpy = jest.spyOn(config, 'set'); + + config.set('userManagement.authenticationMethod', 'email'); + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + const newConfig = { + ...ldapConfig, + loginEnabled: false, + synchronizationEnabled: true, + loginLabel: 'newLoginLabel', + }; + await ldapService.updateConfig(newConfig); + + expect(configSetSpy).toHaveBeenNthCalledWith(4, LDAP_LOGIN_LABEL, newConfig.loginLabel); + }); + }); + + describe('setConfig()', () => { + it('should stop synchronization if the timer is running and the config is disabled', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const updatedLdapConfig = { ...ldapConfig, synchronizationEnabled: false }; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + await ldapService.init(); + ldapService.setConfig(updatedLdapConfig); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should schedule synchronization if the timer is not running and the config is enabled', () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const updatedLdapConfig = { + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 999, + }; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + ldapService.setConfig(updatedLdapConfig); + + expect(clearIntervalSpy).not.toHaveBeenCalled(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + updatedLdapConfig.synchronizationInterval * 60_000, + ); + }); + + it('should throw an error if the timer is not running and the config is enabled but the synchronizationInterval is not set', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const updatedLdapConfig = { + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 0, + }; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + const thrownSetConfig = () => ldapService.setConfig(updatedLdapConfig); + + expect(thrownSetConfig).toThrowError('Interval variable has to be defined'); + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect(clearIntervalSpy).not.toHaveBeenCalled(); + }); + + it('should restart synchronization if the timer is running and the config is enabled', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const updatedLdapConfig = { + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 1234, + }; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + await ldapService.init(); + ldapService.setConfig(updatedLdapConfig); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledTimes(2); + expect(setIntervalSpy).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + ldapConfig.synchronizationInterval * 60_000, + ); + expect(setIntervalSpy).toHaveBeenNthCalledWith( + 2, + expect.any(Function), + updatedLdapConfig.synchronizationInterval * 60_000, + ); + }); + }); + + describe('searchWithAdminBinding()', () => { + it('should bind admin client', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const filter = ''; + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.init(); + await ldapService.searchWithAdminBinding(filter); + + expect(Client.prototype.bind).toHaveBeenCalledTimes(1); + expect(Client.prototype.bind).toHaveBeenCalledWith( + ldapConfig.bindingAdminDn, + 'decryptedPassword', + ); + }); + + it('should call client search with expected parameters', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + const filter = ''; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.init(); + await ldapService.searchWithAdminBinding(filter); + + expect(Client.prototype.search).toHaveBeenCalledTimes(1); + expect(Client.prototype.search).toHaveBeenLastCalledWith(ldapConfig.baseDn, { + attributes: [ + ldapConfig.emailAttribute, + ldapConfig.ldapIdAttribute, + ldapConfig.firstNameAttribute, + ldapConfig.lastNameAttribute, + ldapConfig.emailAttribute, + ], + explicitBufferAttributes: BINARY_AD_ATTRIBUTES, + filter, + timeLimit: ldapConfig.searchTimeout, + paged: { pageSize: ldapConfig.searchPageSize }, + }); + }); + + it('should call client search with expected parameters when searchPageSize is 0', async () => { + mockSettingsRespositoryFindOneByOrFail({ ...ldapConfig, searchPageSize: 0 }); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + const filter = ''; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.init(); + await ldapService.searchWithAdminBinding(filter); + + expect(Client.prototype.search).toHaveBeenCalledTimes(1); + expect(Client.prototype.search).toHaveBeenLastCalledWith(ldapConfig.baseDn, { + attributes: [ + ldapConfig.emailAttribute, + ldapConfig.ldapIdAttribute, + ldapConfig.firstNameAttribute, + ldapConfig.lastNameAttribute, + ldapConfig.emailAttribute, + ], + explicitBufferAttributes: BINARY_AD_ATTRIBUTES, + filter, + timeLimit: ldapConfig.searchTimeout, + paged: true, + }); + }); + + it('should unbind client after search', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const filter = ''; + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.init(); + await ldapService.searchWithAdminBinding(filter); + + expect(Client.prototype.unbind).toHaveBeenCalledTimes(1); + }); + + it('should return expected search entries', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const cipherMock = mock({ + decrypt: jest.fn().mockReturnValue('decryptedPassword'), + }); + + const userList = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: 'Jo Doe', + sn: 'Doe', + mail: 'jdoe@example.com', + memberOf: 'cn=admins,ou=groups,dc=example,dc=com', + }, + { + dn: 'uid=ghopper,ou=users,dc=example,dc=com', + cn: 'Grace Hopper', + sn: 'Hopper', + mail: 'ghopper@nasa.com', + memberOf: 'cn=admins,ou=groups,dc=example,dc=com', + }, + ]; + Client.prototype.search = jest.fn().mockResolvedValue({ + searchEntries: userList, + }); + + const filter = ''; + + const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); + + await ldapService.init(); + const results = await ldapService.searchWithAdminBinding(filter); + + expect(results).toEqual(userList); + }); + }); + + describe('validUser()', () => { + it('should throw expected error if no configuration has been set', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + await expect(ldapService.validUser('dn', 'password')).rejects.toThrowError( + 'Service cannot be used without setting the property config', + ); + }); + + it('should bind the ldap client with the expected distinguished name and password', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const distinguishedName = 'uid=jdoe,ou=users,dc=example,dc=com'; + const password = 'password'; + + await ldapService.init(); + await ldapService.validUser(distinguishedName, password); + + expect(Client.prototype.bind).toHaveBeenCalledTimes(1); + expect(Client.prototype.bind).toHaveBeenCalledWith(distinguishedName, password); + }); + + it('should throw expected error if binding fails', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const distinguishedName = 'uid=jdoe,ou=users,dc=example,dc=com'; + const password = 'password'; + + Client.prototype.bind = jest + .fn() + .mockRejectedValue(new Error('Error validating user against LDAP server')); + + await ldapService.init(); + + await expect(ldapService.validUser(distinguishedName, password)).rejects.toThrowError( + 'Error validating user against LDAP server', + ); + }); + + it('should unbind the client binding', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const distinguishedName = 'uid=jdoe,ou=users,dc=example,dc=com'; + const password = 'password'; + + await ldapService.init(); + await ldapService.validUser(distinguishedName, password); + + expect(Client.prototype.unbind).toHaveBeenCalledTimes(1); + }); + }); + + describe('findAndAuthenticateLdapUser()', () => { + it('should search for expected admin login ID', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const searchWithAdminBindingSpy = jest.spyOn(ldapService, 'searchWithAdminBinding'); + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + const expectedFilter = createFilter( + `(${ldapConfig.loginIdAttribute}=${escapeFilter('jdoe')})`, + ldapConfig.userFilter, + ); + + await ldapService.init(); + await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(searchWithAdminBindingSpy).toHaveBeenCalledTimes(1); + expect(searchWithAdminBindingSpy).toHaveBeenCalledWith(expectedFilter); + }); + + it('should emit expected error if admin search fails', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const eventServiceMock = mock({ + emit: jest.fn(), + }); + + const ldapService = new LdapService( + mockLogger(), + settingsRepository, + mock(), + eventServiceMock, + ); + Client.prototype.search = jest.fn().mockRejectedValue(new Error('Failed to find admin user')); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + const result = await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(eventServiceMock.emit).toBeCalledTimes(1); + expect(eventServiceMock.emit).toHaveBeenCalledWith('ldap-login-sync-failed', { + error: 'Failed to find admin user', + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined if no user is found', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + const result = await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(result).toBeUndefined(); + }); + + it('should validate found user', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + }, + ]; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [...foundUsers] }); + + const validUserSpy = jest.spyOn(ldapService, 'validUser'); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(validUserSpy).toBeCalledTimes(1); + expect(validUserSpy).toHaveBeenCalledWith(foundUsers[0].dn, 'fakePassword'); + }); + + it('should validate last user if more than one is found', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + }, + { + dn: 'uid=janedoe,ou=users,dc=example,dc=com', + cn: ['Jane Doe'], + mail: ['jane.doe@example.com'], + uid: ['janedoe'], + }, + ]; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [...foundUsers] }); + + const validUserSpy = jest.spyOn(ldapService, 'validUser'); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(validUserSpy).toBeCalledTimes(1); + expect(validUserSpy).toHaveBeenCalledWith(foundUsers[1].dn, 'fakePassword'); + }); + + it('should return undefined if invalid user is found', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + }, + ]; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [...foundUsers] }); + + const validUserSpy = jest + .spyOn(ldapService, 'validUser') + .mockRejectedValue(new Error('Failed to validate user')); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + const result = await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(validUserSpy).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('should resolve binary attributes for found user', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + }, + ]; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [...foundUsers] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(resolveEntryBinaryAttributes).toHaveBeenCalledTimes(1); + expect(resolveEntryBinaryAttributes).toHaveBeenCalledWith(foundUsers[0]); + }); + + it('should return found user', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + }, + ]; + + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [...foundUsers] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + const result = await ldapService.findAndAuthenticateLdapUser( + 'jdoe', + 'fakePassword', + ldapConfig.loginIdAttribute, + ldapConfig.userFilter, + ); + + expect(result).toEqual(foundUsers[0]); + }); + }); + + describe('testConnection()', () => { + it('should throw expected error if init() is not called first', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + await expect(ldapService.testConnection()).rejects.toThrowError( + 'Service cannot be used without setting the property config', + ); + }); + + it('should create a new client without TLS if connectionSecurity is set to "none"', async () => { + const ldapService = createDefaultLdapService({ ...ldapConfig, connectionSecurity: 'none' }); + + await ldapService.init(); + await ldapService.testConnection(); + + expect(Client).toHaveBeenCalledTimes(1); + expect(Client).toHaveBeenCalledWith({ + url: `ldap://${ldapConfig.connectionUrl}:${ldapConfig.connectionPort}`, + }); + }); + + it('should create a new client with TLS enabled if connectionSecurity is set to "tls" and allowing unauthorized certificates', async () => { + const ldapService = createDefaultLdapService({ + ...ldapConfig, + connectionSecurity: 'tls', + allowUnauthorizedCerts: true, + }); + + await ldapService.init(); + await ldapService.testConnection(); + + expect(Client).toHaveBeenCalledTimes(1); + expect(Client).toHaveBeenCalledWith({ + url: `ldaps://${ldapConfig.connectionUrl}:${ldapConfig.connectionPort}`, + tlsOptions: { + rejectUnauthorized: false, + }, + }); + }); + + it('should create a new client with TLS enabled if connectionSecurity is set to "tls" and not allowing unauthorized certificates', async () => { + const ldapService = createDefaultLdapService({ + ...ldapConfig, + connectionSecurity: 'tls', + allowUnauthorizedCerts: false, + }); + + await ldapService.init(); + await ldapService.testConnection(); + + expect(Client).toHaveBeenCalledTimes(1); + expect(Client).toHaveBeenCalledWith({ + url: `ldaps://${ldapConfig.connectionUrl}:${ldapConfig.connectionPort}`, + tlsOptions: { + rejectUnauthorized: true, + }, + }); + }); + + it('should create a new client and start TLS if connectionSecurity is set to "startTls"', async () => { + const ldapService = createDefaultLdapService({ + ...ldapConfig, + connectionSecurity: 'startTls', + allowUnauthorizedCerts: true, + }); + + await ldapService.init(); + await ldapService.testConnection(); + + expect(Client).toHaveBeenCalledTimes(1); + expect(Client.prototype.startTLS).toHaveBeenCalledTimes(1); + expect(Client.prototype.startTLS).toHaveBeenCalledWith({ + rejectUnauthorized: false, + }); + }); + + it('should not create a new client if one has already been created', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + await ldapService.init(); + await ldapService.testConnection(); + + expect(Client).toHaveBeenCalledTimes(1); + + await ldapService.testConnection(); + expect(Client).toHaveBeenCalledTimes(1); + }); + }); + + describe('runSync()', () => { + beforeEach(() => { + const mockedRandomString = randomString as jest.Mock; + mockedRandomString.mockReturnValue('nonRandomPassword'); + }); + + it('should search for users with expected parameters', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const searchWithAdminBindingSpy = jest.spyOn(ldapService, 'searchWithAdminBinding'); + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + const expectedFilter = createFilter( + `(${ldapConfig.loginIdAttribute}=*)`, + ldapConfig.userFilter, + ); + + await ldapService.init(); + await ldapService.runSync('dry'); + + expect(searchWithAdminBindingSpy).toHaveBeenCalledTimes(1); + expect(searchWithAdminBindingSpy).toHaveBeenCalledWith(expectedFilter); + }); + + it('should resolve binary attributes for users', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + const foundUsers = [ + { + dn: 'uid=jdoe,ou=users,dc=example,dc=com', + cn: ['John Doe'], + mail: ['jdoe@example.com'], + uid: ['jdoe'], + jpegPhoto: [Buffer.from('89504E470D0A1A0A', 'hex')], + }, + ]; + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: foundUsers }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await ldapService.runSync('dry'); + + expect(resolveBinaryAttributes).toHaveBeenCalledTimes(1); + expect(resolveBinaryAttributes).toHaveBeenCalledWith(foundUsers); + }); + + it('should throw expected error if search fails', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + Client.prototype.search = jest.fn().mockRejectedValue(new Error('Error finding users')); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await expect(ldapService.runSync('dry')).rejects.toThrowError('Error finding users'); + }); + + it('should process expected users if mode is "live"', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + // Users that don't exist in memory + const newUsers = [ + { + dn: 'uid=johndoe,ou=users,dc=example,dc=com', + cn: 'John Doe', + givenName: 'John', + sn: 'Doe', + mail: 'john.doe@example.com', + uid: 'johndoe', + }, + { + dn: 'uid=janedoe,ou=users,dc=example,dc=com', + cn: 'Jane Doe', + givenName: 'Jane', + sn: 'Doe', + mail: 'jane.doe@example.com', + uid: 'janedoe', + }, + ]; + + // Users that exist in memory and in LDAP response + const updateUsers = [ + { + dn: 'uid=emilyclark,ou=users,dc=example,dc=com', + cn: 'Emily Clark', + givenName: 'Emily', + sn: 'Clark', + mail: 'emily.clark@example.com', + uid: 'emilyclark', + }, + ]; + + // Users that only exist in memory + const deleteUsers = ['santaclaus', 'jackfrost']; + + const foundUsers = [...newUsers, ...updateUsers]; + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: foundUsers }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + + // Delete users that exist in memory but not in the LDAP response + mockedGetLdapIds.mockResolvedValue(['emilyclark', ...deleteUsers]); + + const newDbUsers = newUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig, true)); + const updateDbUsers = updateUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig)); + + await ldapService.init(); + await ldapService.runSync('live'); + + expect(processUsers).toHaveBeenCalledTimes(1); + expect(processUsers).toHaveBeenCalledWith(newDbUsers, updateDbUsers, deleteUsers); + }); + + it('should sync expected LDAP data when no errors', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + // Users that don't exist in memory + const newUsers = [ + { + dn: 'uid=johndoe,ou=users,dc=example,dc=com', + cn: 'John Doe', + givenName: 'John', + sn: 'Doe', + mail: 'john.doe@example.com', + uid: 'johndoe', + }, + { + dn: 'uid=janedoe,ou=users,dc=example,dc=com', + cn: 'Jane Doe', + givenName: 'Jane', + sn: 'Doe', + mail: 'jane.doe@example.com', + uid: 'janedoe', + }, + ]; + + // Users that exist in memory and in LDAP response + const updateUsers = [ + { + dn: 'uid=emilyclark,ou=users,dc=example,dc=com', + cn: 'Emily Clark', + givenName: 'Emily', + sn: 'Clark', + mail: 'emily.clark@example.com', + uid: 'emilyclark', + }, + ]; + + // Users that only exist in memory + const deleteUsers = ['santaclaus', 'jackfrost']; + + const foundUsers = [...newUsers, ...updateUsers]; + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: foundUsers }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + + // Delete users that exist in memory but not in the LDAP response + mockedGetLdapIds.mockResolvedValue(['emilyclark', ...deleteUsers]); + + const newDbUsers = newUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig, true)); + const updateDbUsers = updateUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig)); + + jest.setSystemTime(new Date('2024-12-25')); + const expectedDate = new Date(); + + await ldapService.init(); + await ldapService.runSync('live'); + + expect(saveLdapSynchronization).toHaveBeenCalledTimes(1); + expect(saveLdapSynchronization).toHaveBeenCalledWith({ + startedAt: expectedDate, + endedAt: expectedDate, + created: newDbUsers.length, + updated: updateDbUsers.length, + disabled: deleteUsers.length, + scanned: foundUsers.length, + runMode: 'live', + status: 'success', + error: '', + }); + }); + + it('should sync expected LDAP data when users fail to process', async () => { + const ldapService = createDefaultLdapService(ldapConfig); + + // Users that don't exist in memory + const newUsers = [ + { + dn: 'uid=johndoe,ou=users,dc=example,dc=com', + cn: 'John Doe', + givenName: 'John', + sn: 'Doe', + mail: 'john.doe@example.com', + uid: 'johndoe', + }, + { + dn: 'uid=janedoe,ou=users,dc=example,dc=com', + cn: 'Jane Doe', + givenName: 'Jane', + sn: 'Doe', + mail: 'jane.doe@example.com', + uid: 'janedoe', + }, + ]; + + // Users that exist in memory and in LDAP response + const updateUsers = [ + { + dn: 'uid=emilyclark,ou=users,dc=example,dc=com', + cn: 'Emily Clark', + givenName: 'Emily', + sn: 'Clark', + mail: 'emily.clark@example.com', + uid: 'emilyclark', + }, + ]; + + // Users that only exist in memory + const deleteUsers = ['santaclaus', 'jackfrost']; + + const foundUsers = [...newUsers, ...updateUsers]; + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: foundUsers }); + + const mockedProcessUsers = processUsers as jest.Mock; + mockedProcessUsers.mockRejectedValue( + new QueryFailedError('Query', [], new Error('Error processing users')), + ); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + + // Delete users that exist in memory but not in the LDAP response + mockedGetLdapIds.mockResolvedValue(['emilyclark', ...deleteUsers]); + + const newDbUsers = newUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig, true)); + const updateDbUsers = updateUsers.map((user) => mapLdapUserToDbUser(user, ldapConfig)); + + jest.setSystemTime(new Date('2024-12-25')); + const expectedDate = new Date(); + + await ldapService.init(); + await ldapService.runSync('live'); + + expect(saveLdapSynchronization).toHaveBeenCalledTimes(1); + expect(saveLdapSynchronization).toHaveBeenCalledWith({ + startedAt: expectedDate, + endedAt: expectedDate, + created: newDbUsers.length, + updated: updateDbUsers.length, + disabled: deleteUsers.length, + scanned: foundUsers.length, + runMode: 'live', + status: 'error', + error: 'Error processing users', + }); + }); + + it('should emit expected event if synchronization is enabled', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const eventServiceMock = mock({ + emit: jest.fn(), + }); + + const ldapService = new LdapService( + mockLogger(), + settingsRepository, + mock(), + eventServiceMock, + ); + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + await ldapService.runSync('dry'); + + expect(eventServiceMock.emit).toHaveBeenCalledTimes(1); + expect(eventServiceMock.emit).toHaveBeenCalledWith('ldap-general-sync-finished', { + error: '', + succeeded: true, + type: 'manual_dry', + usersSynced: 0, + }); + }); + + it('should emit expected event if synchronization is disabled', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const eventServiceMock = mock({ + emit: jest.fn(), + }); + + const ldapService = new LdapService( + mockLogger(), + settingsRepository, + mock(), + eventServiceMock, + ); + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + await ldapService.init(); + ldapService.stopSync(); + await ldapService.runSync('dry'); + + expect(eventServiceMock.emit).toHaveBeenCalledTimes(1); + expect(eventServiceMock.emit).toHaveBeenCalledWith('ldap-general-sync-finished', { + error: '', + succeeded: true, + type: 'scheduled', + usersSynced: 0, + }); + }); + + it('should emit expected event with error message if processUsers fails', async () => { + mockSettingsRespositoryFindOneByOrFail(ldapConfig); + + const eventServiceMock = mock({ + emit: jest.fn(), + }); + + const ldapService = new LdapService( + mockLogger(), + settingsRepository, + mock(), + eventServiceMock, + ); + Client.prototype.search = jest.fn().mockResolvedValue({ searchEntries: [] }); + + const mockedGetLdapIds = getLdapIds as jest.Mock; + mockedGetLdapIds.mockResolvedValue([]); + + const mockedProcessUsers = processUsers as jest.Mock; + mockedProcessUsers.mockRejectedValue( + new QueryFailedError('Query', [], new Error('Error processing users')), + ); + + await ldapService.init(); + ldapService.stopSync(); + await ldapService.runSync('live'); + + expect(mockedProcessUsers).toHaveBeenCalledTimes(1); + expect(eventServiceMock.emit).toHaveBeenCalledTimes(1); + expect(eventServiceMock.emit).toHaveBeenCalledWith('ldap-general-sync-finished', { + error: 'Error processing users', + succeeded: true, + type: 'scheduled', + usersSynced: 0, + }); + }); + }); + + describe('stopSync()', () => { + it('should clear the scheduled timer', async () => { + const ldapService = createDefaultLdapService({ + ...ldapConfig, + synchronizationEnabled: true, + synchronizationInterval: 10, + }); + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + await ldapService.init(); + ldapService.stopSync(); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); + }); +});