UNPKG

@sync-in/server

Version:

The secure, open-source platform for file storage, sharing, collaboration, and sync

534 lines (533 loc) 24.1 kB
/* * Copyright (C) 2012-2025 Johan Legrand <johan.legrand@sync-in.com> * This file is part of Sync-in | The open source file sync and share solution * See the LICENSE file for licensing details */ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const _testing = require("@nestjs/testing"); const _ldapts = require("ldapts"); const _appconstants = require("../../../app.constants"); const _adminusersmanagerservice = require("../../../applications/users/services/admin-users-manager.service"); const _usersmanagerservice = require("../../../applications/users/services/users-manager.service"); const _functions = /*#__PURE__*/ _interop_require_wildcard(require("../../../common/functions")); const _configenvironment = require("../../../configuration/config.environment"); const _authldap = require("../../constants/auth-ldap"); const _authmethodldapservice = require("./auth-method-ldap.service"); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interop_require_wildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = { __proto__: null }; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for(var key in obj){ if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } // Mock ldapts Client to simulate LDAP behaviors jest.mock('ldapts', ()=>{ const actual = jest.requireActual('ldapts'); const mockClientInstance = { bind: jest.fn(), search: jest.fn(), unbind: jest.fn() }; const Client = jest.fn().mockImplementation(()=>mockClientInstance); // Conserver tous les autres exports réels (dont EqualityFilter, AndFilter, InvalidCredentialsError, etc.) return { ...actual, Client }; }); // --- Test helpers (DRY) --- // Reusable LDAP mocks const mockBindResolve = (ldapClient)=>{ ldapClient.bind.mockResolvedValue(undefined); ldapClient.unbind.mockResolvedValue(undefined); }; const mockBindRejectInvalid = (ldapClient, InvalidCredentialsErrorCtor, message = 'invalid')=>{ ldapClient.bind.mockRejectedValue(new InvalidCredentialsErrorCtor(message)); ldapClient.unbind.mockResolvedValue(undefined); }; const mockSearchEntries = (ldapClient, entries)=>{ ldapClient.search.mockResolvedValue({ searchEntries: entries }); }; const mockSearchReject = (ldapClient, err)=>{ ldapClient.search.mockRejectedValue(err); }; // User factory const buildUser = (overrides = {})=>({ id: 0, login: 'john', email: 'old@example.org', password: 'hashed', isGuest: false, isActive: true, makePaths: jest.fn().mockResolvedValue(undefined), setFullName: jest.fn(), ...overrides }); // -------------------------- describe(_authmethodldapservice.AuthMethodLdapService.name, ()=>{ let authMethodLdapService; let usersManager; let adminUsersManager; const ldapClient = { bind: jest.fn(), search: jest.fn(), unbind: jest.fn() }; _ldapts.Client.mockImplementation(()=>ldapClient); // Local helpers (need access to authMethodLdapService and ldapClient in this scope) const setupLdapSuccess = (entries)=>{ mockBindResolve(ldapClient); mockSearchEntries(ldapClient, entries); }; const spyLoggerError = ()=>jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(()=>undefined); beforeAll(async ()=>{ _configenvironment.configuration.auth.ldap = { servers: [ 'ldap://localhost:389' ], attributes: { login: _authldap.LDAP_LOGIN_ATTR.UID, email: 'mail' }, baseDN: 'ou=people,dc=example,dc=org', filter: '' }; const module = await _testing.Test.createTestingModule({ providers: [ _authmethodldapservice.AuthMethodLdapService, { provide: _usersmanagerservice.UsersManager, useValue: { findUser: jest.fn(), logUser: jest.fn(), updateAccesses: jest.fn().mockResolvedValue(undefined), validateAppPassword: jest.fn(), fromUserId: jest.fn() } }, { provide: _adminusersmanagerservice.AdminUsersManager, useValue: { createUserOrGuest: jest.fn(), updateUserOrGuest: jest.fn() } } ] }).compile(); module.useLogger([ 'fatal' ]); authMethodLdapService = module.get(_authmethodldapservice.AuthMethodLdapService); adminUsersManager = module.get(_adminusersmanagerservice.AdminUsersManager); usersManager = module.get(_usersmanagerservice.UsersManager); }); it('should be defined', ()=>{ expect(authMethodLdapService).toBeDefined(); expect(usersManager).toBeDefined(); expect(adminUsersManager).toBeDefined(); expect(ldapClient).toBeDefined(); }); it('should authenticate a guest user via database and bypass LDAP', async ()=>{ // Arrange const guestUser = { id: 1, login: 'guest1', isGuest: true, isActive: true }; usersManager.findUser.mockResolvedValue(guestUser); const dbAuthResult = { ...guestUser, token: 'jwt' }; usersManager.logUser.mockResolvedValue(dbAuthResult); const res = await authMethodLdapService.validateUser('guest1', 'pass', '127.0.0.1'); expect(res).toEqual(dbAuthResult); expect(usersManager.logUser).toHaveBeenCalledWith(guestUser, 'pass', '127.0.0.1'); expect(_ldapts.Client).not.toHaveBeenCalled(); // client should not be constructed }); it('should throw FORBIDDEN for locked account and resolve null for LDAP login mismatch', async ()=>{ // Phase 1: locked account usersManager.findUser.mockResolvedValue({ login: 'john', isGuest: false, isActive: false }); const loggerErrorSpy1 = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(()=>undefined); await expect(authMethodLdapService.validateUser('john', 'pwd')).rejects.toThrow(/account locked/i); expect(loggerErrorSpy1).toHaveBeenCalled(); // Phase 2: mismatch between requested login and LDAP returned login -> service renvoie null const existingUser = buildUser({ id: 8 }); usersManager.findUser.mockResolvedValue(existingUser); mockBindResolve(ldapClient); mockSearchEntries(ldapClient, [ { uid: 'jane', cn: 'john', mail: 'jane@example.org' } ]); await expect(authMethodLdapService.validateUser('john', 'pwd')).rejects.toThrow(/account matching error/i); }); it('should handle invalid LDAP credentials for both existing and unknown users', async ()=>{ // Phase 1: existing user -> updateAccesses invoked with success=false and logger.error intercepted const existingUser = buildUser({ id: 1 }); usersManager.findUser.mockResolvedValue(existingUser); // Make LDAP bind throw InvalidCredentialsError mockBindRejectInvalid(ldapClient, _ldapts.InvalidCredentialsError, 'invalid credentials'); // Force updateAccesses to reject to hit the catch and logger.error const loggerErrorSpy = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(()=>undefined); usersManager.updateAccesses.mockRejectedValueOnce(new Error('updateAccesses boom')); const res1 = await authMethodLdapService.validateUser('john', 'badpwd', '10.0.0.1'); expect(res1).toBeNull(); expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.0.0.1', false); expect(loggerErrorSpy).toHaveBeenCalled(); // Phase 2: unknown user → no access update usersManager.updateAccesses.mockClear(); usersManager.findUser.mockResolvedValue(null); ldapClient.bind.mockRejectedValue(new _ldapts.InvalidCredentialsError('invalid')); ldapClient.unbind.mockResolvedValue(undefined); const res2 = await authMethodLdapService.validateUser('jane', 'badpwd'); expect(res2).toBeNull(); expect(usersManager.updateAccesses).not.toHaveBeenCalled(); }); it('should handle LDAP new-user flow: missing fields, creation success, and multi-email selection', async ()=>{ // Phase 1: incomplete LDAP entry -> null + error log, no creation usersManager.findUser.mockResolvedValue(null); mockBindResolve(ldapClient); // Simulate an entry with missing mail mockSearchEntries(ldapClient, [ { uid: 'jane', cn: 'Jane Doe', mail: undefined } ]); const loggerErrorSpy = jest.spyOn(authMethodLdapService['logger'], 'error').mockImplementation(()=>undefined); const resA = await authMethodLdapService.validateUser('jane', 'pwd'); expect(resA).toBeNull(); expect(adminUsersManager.createUserOrGuest).not.toHaveBeenCalled(); expect(loggerErrorSpy).toHaveBeenCalled(); // Phase 2: create a new user (success, single email) // Stub directement checkAuth pour retourner une entrée LDAP valide const checkAuthSpy = jest.spyOn(authMethodLdapService, 'checkAuth'); checkAuthSpy.mockResolvedValueOnce({ uid: 'john', cn: 'John Doe', mail: 'john@example.org' }); adminUsersManager.createUserOrGuest.mockClear(); usersManager.findUser.mockResolvedValue(null); const createdUser = { id: 2, login: 'john', isGuest: false, isActive: true, makePaths: jest.fn() }; adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser); // If the service reloads the user via fromUserId after creation usersManager.fromUserId.mockResolvedValue(createdUser); // Cover the success-flow catch branch const loggerErrorSpy2 = spyLoggerError(); usersManager.updateAccesses.mockRejectedValueOnce(new Error('updateAccesses success flow boom')); const resB = await authMethodLdapService.validateUser('john', 'pwd', '192.168.1.10'); expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith({ login: 'john', email: 'john@example.org', password: 'pwd', firstName: 'John', lastName: 'Doe', role: 1 }, expect.anything() // USER_ROLE.USER ); expect(resB).toBe(createdUser); expect(usersManager.updateAccesses).toHaveBeenCalledWith(createdUser, '192.168.1.10', true); expect(loggerErrorSpy2).toHaveBeenCalled(); // Phase 3: multiple emails -> keep the first adminUsersManager.createUserOrGuest.mockClear(); usersManager.findUser.mockResolvedValue(null); setupLdapSuccess([ { uid: 'multi', cn: 'Multi Mail', mail: [ 'first@example.org', 'second@example.org' ] } ]); const createdUser2 = { id: 9, login: 'multi', makePaths: jest.fn() }; adminUsersManager.createUserOrGuest.mockResolvedValue(createdUser2); usersManager.fromUserId.mockResolvedValue(createdUser2); const resC = await authMethodLdapService.validateUser('multi', 'pwd'); expect(adminUsersManager.createUserOrGuest).toHaveBeenCalledWith(expect.objectContaining({ email: 'first@example.org' }), expect.anything()); expect(resC).toBe(createdUser2); }); it('should update existing user profile when LDAP identity changed (except password assigned back)', async ()=>{ // Arrange: existing user with different profile and an old password const existingUser = buildUser({ id: 5 }); usersManager.findUser.mockResolvedValue(existingUser); // LDAP succeeds and returns different email and same uid setupLdapSuccess([ { uid: 'john', cn: 'John Doe', mail: 'john@example.org' } ]); // Admin manager successfully updates a user adminUsersManager.updateUserOrGuest.mockResolvedValue(undefined); // Ensure password is considered changed so the update payload includes it, // which then triggers the deletion and local assignment branches after update const compareSpy = jest.spyOn(_functions, 'comparePassword').mockResolvedValue(false); const res = await authMethodLdapService.validateUser('john', 'new-plain-password', '127.0.0.2'); expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(5, expect.objectContaining({ email: 'john@example.org', firstName: 'John', lastName: 'Doe' })); // Password should not be assigned back onto the user object (it is deleted before Object.assign) expect(existingUser.password).toBe('hashed'); // Other fields should be updated locally expect(existingUser.email).toBe('john@example.org'); expect(existingUser).toMatchObject({ firstName: 'John', lastName: 'Doe' }); // Accesses updated as success expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '127.0.0.2', true); // Returned user is the same instance expect(res).toBe(existingUser); // Second run: password unchanged (comparePassword => true) to cover the null branch for password adminUsersManager.updateUserOrGuest.mockClear(); usersManager.updateAccesses.mockClear(); // Force another non-password change so an update occurs existingUser.email = 'old@example.org'; compareSpy.mockResolvedValue(true); const res2 = await authMethodLdapService.validateUser('john', 'same-plain-password', '127.0.0.3'); // Update should be called without password, only with changed fields expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalled(); const updateArgs = adminUsersManager.updateUserOrGuest.mock.calls[0]; expect(updateArgs[0]).toBe(5); expect(updateArgs[1]).toEqual(expect.objectContaining({ email: 'john@example.org' })); expect(updateArgs[1]).toEqual(expect.not.objectContaining({ password: expect.anything() })); // Password remains unchanged locally expect(existingUser.password).toBe('hashed'); // Accesses updated as success expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '127.0.0.3', true); // Returned user is the same instance expect(res2).toBe(existingUser); // Third run: no changes at all (identityHasChanged is empty) to cover the else branch adminUsersManager.updateUserOrGuest.mockClear(); usersManager.updateAccesses.mockClear(); compareSpy.mockResolvedValue(true); // Local user already matches LDAP identity; call again const res3 = await authMethodLdapService.validateUser('john', 'same-plain-password', '127.0.0.4'); // No update should be triggered expect(adminUsersManager.updateUserOrGuest).not.toHaveBeenCalled(); // Access should still be updated as success expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '127.0.0.4', true); // Returned user is the same instance expect(res3).toBe(existingUser); }); it('should log failed access when LDAP search returns no entry or throws after bind', async ()=>{ // Phase 1: no entry found after a successful bind -> failed access const existingUser = { id: 7, login: 'ghost', isGuest: false, isActive: true }; usersManager.findUser.mockResolvedValue(existingUser); setupLdapSuccess([]); const resA = await authMethodLdapService.validateUser('ghost', 'pwd', '10.10.0.1'); expect(resA).toBeNull(); expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.10.0.1', false); // Phase 2: exception during search after a bind -> failed access jest.clearAllMocks(); const existingUser2 = { id: 10, login: 'john', isGuest: false, isActive: true }; usersManager.findUser.mockResolvedValue(existingUser2); mockBindResolve(ldapClient); mockSearchReject(ldapClient, new Error('search failed')); const resB = await authMethodLdapService.validateUser('john', 'pwd', '1.1.1.1'); expect(resB).toBeNull(); expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser2, '1.1.1.1', false); }); it('should allow app password when LDAP fails and scope is provided', async ()=>{ const existingUser = buildUser({ id: 42 }); usersManager.findUser.mockResolvedValue(existingUser); // LDAP invalid credentials mockBindRejectInvalid(ldapClient, _ldapts.InvalidCredentialsError, 'invalid credentials'); // App password success usersManager.validateAppPassword.mockResolvedValue(true); const res = await authMethodLdapService.validateUser('john', 'app-password', '10.0.0.2', 'webdav'); expect(res).toBe(existingUser); expect(usersManager.validateAppPassword).toHaveBeenCalledWith(existingUser, 'app-password', '10.0.0.2', 'webdav'); expect(usersManager.updateAccesses).toHaveBeenCalledWith(existingUser, '10.0.0.2', true); }); it('should throw 500 when LDAP connection error occurs during bind', async ()=>{ // Arrange: no existing user to reach checkAuth flow usersManager.findUser.mockResolvedValue(null); const err1 = new Error('socket hang up'); const err2 = Object.assign(new Error('connect ECONNREFUSED'), { code: Array.from(_appconstants.CONNECT_ERROR_CODE)[0] }); ldapClient.bind.mockRejectedValue({ errors: [ err1, err2 ] }); ldapClient.unbind.mockResolvedValue(undefined); // First scenario: recognized connection error -> throws 500 await expect(authMethodLdapService.validateUser('john', 'pwd')).rejects.toThrow(/authentication service/i); // Second scenario: generic error (no code, not InvalidCredentialsError) -> resolves to null and no access update ldapClient.bind.mockReset(); ldapClient.unbind.mockReset(); usersManager.updateAccesses.mockClear(); usersManager.findUser.mockResolvedValue(null); ldapClient.bind.mockRejectedValue(new Error('unexpected failure')); ldapClient.unbind.mockResolvedValue(undefined); const res = await authMethodLdapService.validateUser('john', 'pwd'); expect(res).toBeNull(); expect(usersManager.updateAccesses).not.toHaveBeenCalled(); }); it('should log update failure when updating existing user', async ()=>{ // Arrange: existing user with changed identity const existingUser = buildUser({ id: 11, email: 'old@ex.org' }); usersManager.findUser.mockResolvedValue(existingUser); // Ensure LDAP loginAttribute matches uid for this test (a previous test sets it to 'cn') setupLdapSuccess([ { uid: 'john', cn: 'John Doe', mail: 'john@example.org' } ]); adminUsersManager.updateUserOrGuest.mockRejectedValue(new Error('db error')); // Force identity to be considered changed only for this test jest.spyOn(_functions, 'comparePassword').mockResolvedValue(false); jest.spyOn(_functions, 'splitFullName').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); const res = await authMethodLdapService.validateUser('john', 'pwd'); expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalled(); // Local fields unchanged since update failed expect(existingUser.email).toBe('old@ex.org'); expect(res).toBe(existingUser); }); it('should skip non-matching LDAP entries then update user with changed password without reassigning it', async ()=>{ // Phase A: LDAP returns an entry but loginAttribute value does not match -> checkAccess returns false (covers return after loop) const userA = { id: 20, login: 'john', isGuest: false, isActive: true }; usersManager.findUser.mockResolvedValue(userA); ldapClient.bind.mockResolvedValue(undefined); // Phase B: Matching entry + password considered changed -> updateUserOrGuest called, password not reassigned locally jest.clearAllMocks(); const userB = buildUser({ id: 21, email: 'old@ex.org' }); usersManager.findUser.mockResolvedValue(userB); setupLdapSuccess([ { uid: 'john', cn: 'John Doe', mail: 'john@example.org' } ]); adminUsersManager.updateUserOrGuest.mockResolvedValue(undefined); // Force password to be considered changed to execute deletion + Object.assign branch jest.spyOn(_functions, 'comparePassword').mockResolvedValue(false); jest.spyOn(_functions, 'splitFullName').mockReturnValue({ firstName: 'John', lastName: 'Doe' }); const resB = await authMethodLdapService.validateUser('john', 'newpwd', '4.4.4.4'); // Line 132: updateUserOrGuest call expect(adminUsersManager.updateUserOrGuest).toHaveBeenCalledWith(21, expect.objectContaining({ email: 'john@example.org', firstName: 'John', lastName: 'Doe' })); // Lines 139-142: password removed from local assign, other fields assigned expect(userB.password).toBe('hashed'); expect(userB.email).toBe('john@example.org'); expect(userB).toMatchObject({ firstName: 'John', lastName: 'Doe' }); expect(resB).toBe(userB); }); }); //# sourceMappingURL=auth-method-ldap.service.spec.js.map