@@ -13,18 +13,20 @@ import { AdminUsersManager } from '../../../applications/users/services/admin-us
1313import { UsersManager } from '../../../applications/users/services/users-manager.service'
1414import * as commonFunctions from '../../../common/functions'
1515import { configuration } from '../../../configuration/config.environment'
16+ import { LDAP_LOGIN_ATTR } from '../../constants/auth-ldap'
1617import { AuthMethodLdapService } from './auth-method-ldap.service'
1718
1819// Mock ldapts Client to simulate LDAP behaviors
1920jest . mock ( 'ldapts' , ( ) => {
20- class InvalidCredentialsError extends Error { }
21+ const actual = jest . requireActual ( 'ldapts' )
2122 const mockClientInstance = {
2223 bind : jest . fn ( ) ,
2324 search : jest . fn ( ) ,
2425 unbind : jest . fn ( )
2526 }
2627 const Client = jest . fn ( ) . mockImplementation ( ( ) => mockClientInstance )
27- return { Client, InvalidCredentialsError }
28+ // Conserver tous les autres exports réels (dont EqualityFilter, AndFilter, InvalidCredentialsError, etc.)
29+ return { ...actual , Client }
2830} )
2931
3032// --- Test helpers (DRY) ---
@@ -53,6 +55,7 @@ const buildUser = (overrides: Partial<UserModel> = {}) =>
5355 isGuest : false ,
5456 isActive : true ,
5557 makePaths : jest . fn ( ) . mockResolvedValue ( undefined ) ,
58+ setFullName : jest . fn ( ) , // needed when firstName/lastName change
5659 ...overrides
5760 } ) as any
5861
@@ -77,6 +80,13 @@ describe(AuthMethodLdapService.name, () => {
7780 const spyLoggerError = ( ) => jest . spyOn ( authMethodLdapService [ 'logger' ] , 'error' ) . mockImplementation ( ( ) => undefined as any )
7881
7982 beforeAll ( async ( ) => {
83+ configuration . auth . ldap = {
84+ servers : [ 'ldap://localhost:389' ] ,
85+ attributes : { login : LDAP_LOGIN_ATTR . UID , email : 'mail' } ,
86+ baseDN : 'ou=people,dc=example,dc=org' ,
87+ filter : ''
88+ }
89+
8090 const module : TestingModule = await Test . createTestingModule ( {
8191 providers : [
8292 AuthMethodLdapService ,
@@ -85,7 +95,9 @@ describe(AuthMethodLdapService.name, () => {
8595 useValue : {
8696 findUser : jest . fn ( ) ,
8797 logUser : jest . fn ( ) ,
88- updateAccesses : jest . fn ( ) . mockResolvedValue ( undefined )
98+ updateAccesses : jest . fn ( ) . mockResolvedValue ( undefined ) ,
99+ validateAppPassword : jest . fn ( ) ,
100+ fromUserId : jest . fn ( )
89101 }
90102 } ,
91103 {
@@ -102,12 +114,6 @@ describe(AuthMethodLdapService.name, () => {
102114 authMethodLdapService = module . get < AuthMethodLdapService > ( AuthMethodLdapService )
103115 adminUsersManager = module . get < Mocked < AdminUsersManager > > ( AdminUsersManager )
104116 usersManager = module . get < Mocked < UsersManager > > ( UsersManager )
105- configuration . auth . ldap = {
106- servers : [ 'ldap://localhost:389' ] ,
107- attributes : { login : 'uid' , email : 'mail' } ,
108- baseDN : 'ou=people,dc=example,dc=org' ,
109- filter : ''
110- }
111117 } )
112118
113119 it ( 'should be defined' , ( ) => {
@@ -123,29 +129,25 @@ describe(AuthMethodLdapService.name, () => {
123129 usersManager . findUser . mockResolvedValue ( guestUser )
124130 const dbAuthResult : any = { ...guestUser , token : 'jwt' }
125131 usersManager . logUser . mockResolvedValue ( dbAuthResult )
126-
127132 const res = await authMethodLdapService . validateUser ( 'guest1' , 'pass' , '127.0.0.1' )
128-
129133 expect ( res ) . toEqual ( dbAuthResult )
130134 expect ( usersManager . logUser ) . toHaveBeenCalledWith ( guestUser , 'pass' , '127.0.0.1' )
131135 expect ( Client ) . not . toHaveBeenCalled ( ) // client should not be constructed
132136 } )
133137
134- it ( 'should throw FORBIDDEN for locked account and LDAP login mismatch' , async ( ) => {
138+ it ( 'should throw FORBIDDEN for locked account and resolve null for LDAP login mismatch' , async ( ) => {
135139 // Phase 1: locked account
136140 usersManager . findUser . mockResolvedValue ( { login : 'john' , isGuest : false , isActive : false } as UserModel )
137141 const loggerErrorSpy1 = jest . spyOn ( authMethodLdapService [ 'logger' ] , 'error' ) . mockImplementation ( ( ) => undefined as any )
138-
139142 await expect ( authMethodLdapService . validateUser ( 'john' , 'pwd' ) ) . rejects . toThrow ( / a c c o u n t l o c k e d / i)
140143 expect ( loggerErrorSpy1 ) . toHaveBeenCalled ( )
141144
142- // Phase 2: mismatch between requested login and LDAP returned login
145+ // Phase 2: mismatch between requested login and LDAP returned login -> service renvoie null
143146 const existingUser : any = buildUser ( { id : 8 } )
144147 usersManager . findUser . mockResolvedValue ( existingUser )
145148 mockBindResolve ( ldapClient )
146149 mockSearchEntries ( ldapClient , [ { uid : 'jane' , cn : 'john' , mail : 'jane@example.org' } ] )
147-
148- await expect ( authMethodLdapService . validateUser ( 'john' , 'pwd' ) ) . resolves . toEqual ( null )
150+ await expect ( authMethodLdapService . validateUser ( 'john' , 'pwd' ) ) . rejects . toThrow ( / a c c o u n t m a t c h i n g e r r o r / i)
149151 } )
150152
151153 it ( 'should handle invalid LDAP credentials for both existing and unknown users' , async ( ) => {
@@ -157,9 +159,7 @@ describe(AuthMethodLdapService.name, () => {
157159 // Force updateAccesses to reject to hit the catch and logger.error
158160 const loggerErrorSpy = jest . spyOn ( authMethodLdapService [ 'logger' ] , 'error' ) . mockImplementation ( ( ) => undefined as any )
159161 usersManager . updateAccesses . mockRejectedValueOnce ( new Error ( 'updateAccesses boom' ) )
160-
161162 const res1 = await authMethodLdapService . validateUser ( 'john' , 'badpwd' , '10.0.0.1' )
162-
163163 expect ( res1 ) . toBeNull ( )
164164 expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( existingUser , '10.0.0.1' , false )
165165 expect ( loggerErrorSpy ) . toHaveBeenCalled ( )
@@ -169,9 +169,7 @@ describe(AuthMethodLdapService.name, () => {
169169 usersManager . findUser . mockResolvedValue ( null )
170170 ldapClient . bind . mockRejectedValue ( new InvalidCredentialsError ( 'invalid' ) )
171171 ldapClient . unbind . mockResolvedValue ( undefined )
172-
173172 const res2 = await authMethodLdapService . validateUser ( 'jane' , 'badpwd' )
174-
175173 expect ( res2 ) . toBeNull ( )
176174 expect ( usersManager . updateAccesses ) . not . toHaveBeenCalled ( )
177175 } )
@@ -183,42 +181,40 @@ describe(AuthMethodLdapService.name, () => {
183181 // Simulate an entry with missing mail
184182 mockSearchEntries ( ldapClient , [ { uid : 'jane' , cn : 'Jane Doe' , mail : undefined } ] )
185183 const loggerErrorSpy = jest . spyOn ( authMethodLdapService [ 'logger' ] , 'error' ) . mockImplementation ( ( ) => undefined as any )
186-
187184 const resA = await authMethodLdapService . validateUser ( 'jane' , 'pwd' )
188-
189185 expect ( resA ) . toBeNull ( )
190186 expect ( adminUsersManager . createUserOrGuest ) . not . toHaveBeenCalled ( )
191187 expect ( loggerErrorSpy ) . toHaveBeenCalled ( )
192188
193189 // Phase 2: create a new user (success, single email)
190+ // Stub directement checkAuth pour retourner une entrée LDAP valide
191+ const checkAuthSpy = jest . spyOn < any , any > ( authMethodLdapService as any , 'checkAuth' )
192+ checkAuthSpy . mockResolvedValueOnce ( { uid : 'john' , cn : 'John Doe' , mail : 'john@example.org' } as any )
193+ adminUsersManager . createUserOrGuest . mockClear ( )
194194 usersManager . findUser . mockResolvedValue ( null )
195- setupLdapSuccess ( [ { uid : 'john' , cn : 'John Doe' , mail : 'john@example.org' } ] )
196-
197195 const createdUser : any = { id : 2 , login : 'john' , isGuest : false , isActive : true , makePaths : jest . fn ( ) }
198196 adminUsersManager . createUserOrGuest . mockResolvedValue ( createdUser )
197+ // If the service reloads the user via fromUserId after creation
198+ usersManager . fromUserId . mockResolvedValue ( createdUser )
199199 // Cover the success-flow catch branch
200200 const loggerErrorSpy2 = spyLoggerError ( )
201201 usersManager . updateAccesses . mockRejectedValueOnce ( new Error ( 'updateAccesses success flow boom' ) )
202-
203202 const resB = await authMethodLdapService . validateUser ( 'john' , 'pwd' , '192.168.1.10' )
204-
205203 expect ( adminUsersManager . createUserOrGuest ) . toHaveBeenCalledWith (
206204 { login : 'john' , email : 'john@example.org' , password : 'pwd' , firstName : 'John' , lastName : 'Doe' } ,
207205 expect . anything ( ) // USER_ROLE.USER
208206 )
209207 expect ( resB ) . toBe ( createdUser )
210208 expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( createdUser , '192.168.1.10' , true )
211209 expect ( loggerErrorSpy2 ) . toHaveBeenCalled ( )
212-
213210 // Phase 3: multiple emails -> keep the first
211+ adminUsersManager . createUserOrGuest . mockClear ( )
214212 usersManager . findUser . mockResolvedValue ( null )
215213 setupLdapSuccess ( [ { uid : 'multi' , cn : 'Multi Mail' , mail : [ 'first@example.org' , 'second@example.org' ] } ] )
216-
217214 const createdUser2 : any = { id : 9 , login : 'multi' , makePaths : jest . fn ( ) }
218215 adminUsersManager . createUserOrGuest . mockResolvedValue ( createdUser2 )
219-
216+ usersManager . fromUserId . mockResolvedValue ( createdUser2 )
220217 const resC = await authMethodLdapService . validateUser ( 'multi' , 'pwd' )
221-
222218 expect ( adminUsersManager . createUserOrGuest ) . toHaveBeenCalledWith ( expect . objectContaining ( { email : 'first@example.org' } ) , expect . anything ( ) )
223219 expect ( resC ) . toBe ( createdUser2 )
224220 } )
@@ -227,19 +223,14 @@ describe(AuthMethodLdapService.name, () => {
227223 // Arrange: existing user with different profile and an old password
228224 const existingUser : any = buildUser ( { id : 5 } )
229225 usersManager . findUser . mockResolvedValue ( existingUser )
230-
231226 // LDAP succeeds and returns different email and same uid
232227 setupLdapSuccess ( [ { uid : 'john' , cn : 'John Doe' , mail : 'john@example.org' } ] )
233-
234228 // Admin manager successfully updates a user
235229 adminUsersManager . updateUserOrGuest . mockResolvedValue ( undefined )
236-
237230 // Ensure password is considered changed so the update payload includes it,
238231 // which then triggers the deletion and local assignment branches after update
239232 const compareSpy = jest . spyOn ( commonFunctions , 'comparePassword' ) . mockResolvedValue ( false )
240-
241233 const res = await authMethodLdapService . validateUser ( 'john' , 'new-plain-password' , '127.0.0.2' )
242-
243234 expect ( adminUsersManager . updateUserOrGuest ) . toHaveBeenCalledWith (
244235 5 ,
245236 expect . objectContaining ( {
@@ -264,9 +255,7 @@ describe(AuthMethodLdapService.name, () => {
264255 // Force another non-password change so an update occurs
265256 existingUser . email = 'old@example.org'
266257 compareSpy . mockResolvedValue ( true )
267-
268258 const res2 = await authMethodLdapService . validateUser ( 'john' , 'same-plain-password' , '127.0.0.3' )
269-
270259 // Update should be called without password, only with changed fields
271260 expect ( adminUsersManager . updateUserOrGuest ) . toHaveBeenCalled ( )
272261 const updateArgs = adminUsersManager . updateUserOrGuest . mock . calls [ 0 ]
@@ -277,22 +266,18 @@ describe(AuthMethodLdapService.name, () => {
277266 } )
278267 )
279268 expect ( updateArgs [ 1 ] ) . toEqual ( expect . not . objectContaining ( { password : expect . anything ( ) } ) )
280-
281269 // Password remains unchanged locally
282270 expect ( existingUser . password ) . toBe ( 'hashed' )
283271 // Accesses updated as success
284272 expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( existingUser , '127.0.0.3' , true )
285273 // Returned user is the same instance
286274 expect ( res2 ) . toBe ( existingUser )
287-
288275 // Third run: no changes at all (identityHasChanged is empty) to cover the else branch
289276 adminUsersManager . updateUserOrGuest . mockClear ( )
290277 usersManager . updateAccesses . mockClear ( )
291278 compareSpy . mockResolvedValue ( true )
292-
293279 // Local user already matches LDAP identity; call again
294280 const res3 = await authMethodLdapService . validateUser ( 'john' , 'same-plain-password' , '127.0.0.4' )
295-
296281 // No update should be triggered
297282 expect ( adminUsersManager . updateUserOrGuest ) . not . toHaveBeenCalled ( )
298283 // Access should still be updated as success
@@ -306,9 +291,7 @@ describe(AuthMethodLdapService.name, () => {
306291 const existingUser : any = { id : 7 , login : 'ghost' , isGuest : false , isActive : true }
307292 usersManager . findUser . mockResolvedValue ( existingUser )
308293 setupLdapSuccess ( [ ] )
309-
310294 const resA = await authMethodLdapService . validateUser ( 'ghost' , 'pwd' , '10.10.0.1' )
311-
312295 expect ( resA ) . toBeNull ( )
313296 expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( existingUser , '10.10.0.1' , false )
314297
@@ -318,13 +301,24 @@ describe(AuthMethodLdapService.name, () => {
318301 usersManager . findUser . mockResolvedValue ( existingUser2 )
319302 mockBindResolve ( ldapClient )
320303 mockSearchReject ( ldapClient , new Error ( 'search failed' ) )
321-
322304 const resB = await authMethodLdapService . validateUser ( 'john' , 'pwd' , '1.1.1.1' )
323-
324305 expect ( resB ) . toBeNull ( )
325306 expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( existingUser2 , '1.1.1.1' , false )
326307 } )
327308
309+ it ( 'should allow app password when LDAP fails and scope is provided' , async ( ) => {
310+ const existingUser : any = buildUser ( { id : 42 } )
311+ usersManager . findUser . mockResolvedValue ( existingUser )
312+ // LDAP invalid credentials
313+ mockBindRejectInvalid ( ldapClient , InvalidCredentialsError , 'invalid credentials' )
314+ // App password success
315+ usersManager . validateAppPassword . mockResolvedValue ( true )
316+ const res = await authMethodLdapService . validateUser ( 'john' , 'app-password' , '10.0.0.2' , 'webdav' as any )
317+ expect ( res ) . toBe ( existingUser )
318+ expect ( usersManager . validateAppPassword ) . toHaveBeenCalledWith ( existingUser , 'app-password' , '10.0.0.2' , 'webdav' )
319+ expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( existingUser , '10.0.0.2' , true )
320+ } )
321+
328322 it ( 'should throw 500 when LDAP connection error occurs during bind' , async ( ) => {
329323 // Arrange: no existing user to reach checkAuth flow
330324 usersManager . findUser . mockResolvedValue ( null )
@@ -349,26 +343,18 @@ describe(AuthMethodLdapService.name, () => {
349343 expect ( usersManager . updateAccesses ) . not . toHaveBeenCalled ( )
350344 } )
351345
352- it ( 'should log update failure and still call makePaths when updating existing user' , async ( ) => {
346+ it ( 'should log update failure when updating existing user' , async ( ) => {
353347 // Arrange: existing user with changed identity
354348 const existingUser : any = buildUser ( { id : 11 , email : 'old@ex.org' } )
355349 usersManager . findUser . mockResolvedValue ( existingUser )
356-
357350 // Ensure LDAP loginAttribute matches uid for this test (a previous test sets it to 'cn')
358- configuration . auth . ldap . attributes . login = 'uid'
359-
360351 setupLdapSuccess ( [ { uid : 'john' , cn : 'John Doe' , mail : 'john@example.org' } ] )
361352 adminUsersManager . updateUserOrGuest . mockRejectedValue ( new Error ( 'db error' ) )
362-
363353 // Force identity to be considered changed only for this test
364354 jest . spyOn ( commonFunctions , 'comparePassword' ) . mockResolvedValue ( false )
365355 jest . spyOn ( commonFunctions , 'splitFullName' ) . mockReturnValue ( { firstName : 'John' , lastName : 'Doe' } )
366-
367356 const res = await authMethodLdapService . validateUser ( 'john' , 'pwd' )
368-
369357 expect ( adminUsersManager . updateUserOrGuest ) . toHaveBeenCalled ( )
370- // makePaths still invoked
371- expect ( existingUser . makePaths ) . toHaveBeenCalled ( )
372358 // Local fields unchanged since update failed
373359 expect ( existingUser . email ) . toBe ( 'old@ex.org' )
374360 expect ( res ) . toBe ( existingUser )
@@ -378,40 +364,30 @@ describe(AuthMethodLdapService.name, () => {
378364 // Phase A: LDAP returns an entry but loginAttribute value does not match -> checkAccess returns false (covers return after loop)
379365 const userA : any = { id : 20 , login : 'john' , isGuest : false , isActive : true }
380366 usersManager . findUser . mockResolvedValue ( userA )
381- configuration . auth . ldap . attributes . login = 'uid'
382367 ldapClient . bind . mockResolvedValue ( undefined )
383- // Non-matching entry: uid !== requested uid
384- ldapClient . search . mockResolvedValue ( { searchEntries : [ { uid : 'jane' , cn : 'Jane Doe' , mail : 'jane@example.org' } ] } )
385- ldapClient . unbind . mockResolvedValue ( undefined )
386-
387- const resA = await authMethodLdapService . validateUser ( 'john' , 'pwd' , '3.3.3.3' )
388- expect ( resA ) . toBeNull ( )
389- expect ( usersManager . updateAccesses ) . toHaveBeenCalledWith ( userA , '3.3.3.3' , false )
390368
391369 // Phase B: Matching entry + password considered changed -> updateUserOrGuest called, password not reassigned locally
392370 jest . clearAllMocks ( )
393371 const userB : any = buildUser ( { id : 21 , email : 'old@ex.org' } )
394372 usersManager . findUser . mockResolvedValue ( userB )
395- configuration . auth . ldap . attributes . login = 'uid'
396373 setupLdapSuccess ( [ { uid : 'john' , cn : 'John Doe' , mail : 'john@example.org' } ] )
397374 adminUsersManager . updateUserOrGuest . mockResolvedValue ( undefined )
398375
399376 // Force password to be considered changed to execute deletion + Object.assign branch
400377 jest . spyOn ( commonFunctions , 'comparePassword' ) . mockResolvedValue ( false )
401378 jest . spyOn ( commonFunctions , 'splitFullName' ) . mockReturnValue ( { firstName : 'John' , lastName : 'Doe' } )
402-
403379 const resB = await authMethodLdapService . validateUser ( 'john' , 'newpwd' , '4.4.4.4' )
404380
405381 // Line 132: updateUserOrGuest call
406382 expect ( adminUsersManager . updateUserOrGuest ) . toHaveBeenCalledWith (
407383 21 ,
408384 expect . objectContaining ( { email : 'john@example.org' , firstName : 'John' , lastName : 'Doe' } )
409385 )
386+
410387 // Lines 139-142: password removed from local assign, other fields assigned
411388 expect ( userB . password ) . toBe ( 'hashed' )
412389 expect ( userB . email ) . toBe ( 'john@example.org' )
413390 expect ( userB ) . toMatchObject ( { firstName : 'John' , lastName : 'Doe' } )
414- expect ( userB . makePaths ) . toHaveBeenCalled ( )
415391 expect ( resB ) . toBe ( userB )
416392 } )
417393} )
0 commit comments