adapt-authoring-auth-local 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,11 @@ on: push
3
3
  jobs:
4
4
  default:
5
5
  runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
6
8
  steps:
7
- - uses: actions/checkout@master
8
- - uses: actions/setup-node@master
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
9
11
  with:
10
12
  node-version: 'lts/*'
11
13
  cache: 'npm'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-auth-local",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Module which implements username/password (local) authentication",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-auth-local",
6
6
  "license": "GPL-3.0",
@@ -45,6 +45,7 @@ let updateCalls = []
45
45
  let disavowCalls = []
46
46
  let secureRouteCalls = []
47
47
  let unsecureRouteCalls = []
48
+ let mailerSendCalls = []
48
49
 
49
50
  const mockUsers = {
50
51
  find: async (query) => usersStore.filter(u => {
@@ -63,7 +64,7 @@ const mockRoles = {
63
64
 
64
65
  const mockMailer = {
65
66
  isEnabled: true,
66
- send: async () => {}
67
+ send: async (opts) => { mailerSendCalls.push(opts) }
67
68
  }
68
69
 
69
70
  const mockServer = {
@@ -106,7 +107,7 @@ const mockApp = {
106
107
  return names.map(n => moduleMap[n])
107
108
  },
108
109
  lang: {
109
- translate: (_, key) => `translated:${key}`
110
+ translate: (_, key) => 'translated:' + key
110
111
  }
111
112
  }
112
113
 
@@ -160,6 +161,7 @@ describe('LocalAuthModule', () => {
160
161
  disavowCalls = []
161
162
  secureRouteCalls = []
162
163
  unsecureRouteCalls = []
164
+ mailerSendCalls = []
163
165
  usersStore.length = 0
164
166
  authlocalConfig = {
165
167
  failsUntilTemporaryLock: 5,
@@ -193,6 +195,17 @@ describe('LocalAuthModule', () => {
193
195
  const result = LocalAuthModule.formatRemainingTime(60)
194
196
  assert.ok(result.includes('minute'))
195
197
  })
198
+
199
+ it('should return a string containing "hour" for 3600 seconds', () => {
200
+ const result = LocalAuthModule.formatRemainingTime(3600)
201
+ assert.ok(result.includes('hour'))
202
+ })
203
+
204
+ it('should handle zero seconds', () => {
205
+ const result = LocalAuthModule.formatRemainingTime(0)
206
+ assert.equal(typeof result, 'string')
207
+ assert.ok(result.length > 0)
208
+ })
196
209
  })
197
210
 
198
211
  describe('#setValues()', () => {
@@ -227,6 +240,22 @@ describe('LocalAuthModule', () => {
227
240
  const superRoute = instance.routes.find(r => r.route === '/registersuper')
228
241
  assert.equal(superRoute.internal, true)
229
242
  })
243
+
244
+ it('should define post handlers for all routes', async () => {
245
+ const instance = new LocalAuthModule()
246
+ await instance.setValues()
247
+ for (const route of instance.routes) {
248
+ assert.equal(typeof route.handlers.post, 'function')
249
+ }
250
+ })
251
+
252
+ it('should define meta for all routes', async () => {
253
+ const instance = new LocalAuthModule()
254
+ await instance.setValues()
255
+ for (const route of instance.routes) {
256
+ assert.ok(route.meta)
257
+ }
258
+ })
230
259
  })
231
260
 
232
261
  describe('#init()', () => {
@@ -239,13 +268,26 @@ describe('LocalAuthModule', () => {
239
268
  assert.ok(secureRouteCalls.some(c => c[0] === '/invite' && c[1] === 'post'))
240
269
  })
241
270
 
242
- it('should secure the validatepass route', async () => {
271
+ it('should secure the validatepass route with read:me permission', async () => {
272
+ const instance = new LocalAuthModule()
273
+ instance.app = mockApp
274
+ instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
275
+ await instance.setValues()
276
+ await instance.init()
277
+ const call = secureRouteCalls.find(c => c[0] === '/validatepass')
278
+ assert.ok(call)
279
+ assert.deepEqual(call[2], ['read:me'])
280
+ })
281
+
282
+ it('should secure the invite route with register:users permission', async () => {
243
283
  const instance = new LocalAuthModule()
244
284
  instance.app = mockApp
245
285
  instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
246
286
  await instance.setValues()
247
287
  await instance.init()
248
- assert.ok(secureRouteCalls.some(c => c[0] === '/validatepass' && c[1] === 'post'))
288
+ const call = secureRouteCalls.find(c => c[0] === '/invite')
289
+ assert.ok(call)
290
+ assert.deepEqual(call[2], ['register:users'])
249
291
  })
250
292
 
251
293
  it('should unsecure registersuper, changepass, and forgotpass routes', async () => {
@@ -268,6 +310,16 @@ describe('LocalAuthModule', () => {
268
310
  await instance.init()
269
311
  assert.equal(instance.users, mockUsers)
270
312
  })
313
+
314
+ it('should set meta on root and register routes', async () => {
315
+ const instance = new LocalAuthModule()
316
+ instance.app = mockApp
317
+ instance.router = { routes: [{ route: '/', meta: null }, { route: '/register', meta: null }] }
318
+ await instance.setValues()
319
+ await instance.init()
320
+ assert.ok(instance.router.routes.find(r => r.route === '/').meta)
321
+ assert.ok(instance.router.routes.find(r => r.route === '/register').meta)
322
+ })
271
323
  })
272
324
 
273
325
  describe('#authenticate()', () => {
@@ -313,6 +365,24 @@ describe('LocalAuthModule', () => {
313
365
  assert.equal(lastUpdate.data.failedLoginAttempts, 1)
314
366
  })
315
367
 
368
+ it('should set lastFailedLoginAttempt on wrong password', async () => {
369
+ const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
370
+ const hash = await PasswordUtils.generate('correctpass')
371
+ const user = {
372
+ _id: 'user-1',
373
+ password: hash,
374
+ isPermLocked: false,
375
+ isTempLocked: false,
376
+ failedLoginAttempts: 0,
377
+ lastFailedLoginAttempt: null
378
+ }
379
+ const req = { body: { password: 'wrongpass' } }
380
+ await assert.rejects(() => mod.authenticate(user, req, {}))
381
+ const lastUpdate = updateCalls[updateCalls.length - 1]
382
+ assert.ok(lastUpdate.data.lastFailedLoginAttempt)
383
+ assert.ok(!isNaN(Date.parse(lastUpdate.data.lastFailedLoginAttempt)))
384
+ })
385
+
316
386
  it('should temporarily lock after reaching failsUntilTemporaryLock threshold', async () => {
317
387
  const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
318
388
  const hash = await PasswordUtils.generate('correctpass')
@@ -387,6 +457,40 @@ describe('LocalAuthModule', () => {
387
457
  const lastUpdate = updateCalls[updateCalls.length - 1]
388
458
  assert.equal(lastUpdate.data.failedLoginAttempts, 20)
389
459
  })
460
+
461
+ it('should not increment failed attempts during temp lock timeout', async () => {
462
+ const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
463
+ const hash = await PasswordUtils.generate('correctpass')
464
+ const user = {
465
+ _id: 'user-1',
466
+ password: hash,
467
+ isPermLocked: false,
468
+ isTempLocked: true,
469
+ failedLoginAttempts: 5,
470
+ lastFailedLoginAttempt: new Date().toISOString()
471
+ }
472
+ const req = { body: { password: 'wrongpass' } }
473
+ await assert.rejects(() => mod.authenticate(user, req, {}))
474
+ const lastUpdate = updateCalls[updateCalls.length - 1]
475
+ assert.equal(lastUpdate.data.failedLoginAttempts, 5)
476
+ })
477
+
478
+ it('should clear lastFailedLoginAttempt on successful login', async () => {
479
+ const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
480
+ const hash = await PasswordUtils.generate('correctpass')
481
+ const user = {
482
+ _id: 'user-1',
483
+ password: hash,
484
+ isPermLocked: false,
485
+ isTempLocked: false,
486
+ failedLoginAttempts: 0,
487
+ lastFailedLoginAttempt: null
488
+ }
489
+ const req = { body: { password: 'correctpass' } }
490
+ await mod.authenticate(user, req, {})
491
+ const lastUpdate = updateCalls[updateCalls.length - 1]
492
+ assert.equal(lastUpdate.data.lastFailedLoginAttempt, undefined)
493
+ })
390
494
  })
391
495
 
392
496
  describe('#handleLockStatus()', () => {
@@ -413,6 +517,23 @@ describe('LocalAuthModule', () => {
413
517
  )
414
518
  })
415
519
 
520
+ it('should include remaining time in ACCOUNT_LOCKED_TEMP error data', async () => {
521
+ const user = {
522
+ isPermLocked: false,
523
+ isTempLocked: true,
524
+ lastFailedLoginAttempt: new Date().toISOString()
525
+ }
526
+ await assert.rejects(
527
+ () => mod.handleLockStatus(user),
528
+ (err) => {
529
+ assert.ok(err.data)
530
+ assert.ok(err.data.remaining)
531
+ assert.equal(typeof err.data.remaining, 'string')
532
+ return true
533
+ }
534
+ )
535
+ })
536
+
416
537
  it('should unlock user when temp lock has expired', async () => {
417
538
  const user = {
418
539
  _id: 'user-1',
@@ -435,6 +556,18 @@ describe('LocalAuthModule', () => {
435
556
  await mod.handleLockStatus(user)
436
557
  assert.equal(updateCalls.length, 0)
437
558
  })
559
+
560
+ it('should check permanent lock before temporary lock', async () => {
561
+ const user = {
562
+ isPermLocked: true,
563
+ isTempLocked: true,
564
+ lastFailedLoginAttempt: new Date().toISOString()
565
+ }
566
+ await assert.rejects(
567
+ () => mod.handleLockStatus(user),
568
+ (err) => err.name === 'ACCOUNT_LOCKED_PERM'
569
+ )
570
+ })
438
571
  })
439
572
 
440
573
  describe('#register()', () => {
@@ -450,6 +583,17 @@ describe('LocalAuthModule', () => {
450
583
  assert.ok(result.password)
451
584
  assert.ok(result.password.startsWith('$2a$') || result.password.startsWith('$2b$'))
452
585
  })
586
+
587
+ it('should preserve other data fields in registration', async () => {
588
+ const result = await mod.register({
589
+ email: 'new@example.com',
590
+ password: 'validpassword',
591
+ firstName: 'Test',
592
+ lastName: 'User'
593
+ })
594
+ assert.equal(result.firstName, 'Test')
595
+ assert.equal(result.lastName, 'User')
596
+ })
453
597
  })
454
598
 
455
599
  describe('#registerSuper()', () => {
@@ -460,6 +604,21 @@ describe('LocalAuthModule', () => {
460
604
  (err) => err.name === 'SUPER_USER_EXISTS'
461
605
  )
462
606
  })
607
+
608
+ it('should call register with hardcoded firstName and lastName', async () => {
609
+ const originalRegister = mod.register.bind(mod)
610
+ let registeredData
611
+ mod.register = async (data) => {
612
+ registeredData = data
613
+ return originalRegister(data)
614
+ }
615
+ await mod.registerSuper({ email: 'super@example.com', password: 'validpassword' })
616
+ mod.register = originalRegister
617
+ assert.equal(registeredData.firstName, 'Super')
618
+ assert.equal(registeredData.lastName, 'User')
619
+ assert.equal(registeredData.email, 'super@example.com')
620
+ assert.equal(registeredData.password, 'validpassword')
621
+ })
463
622
  })
464
623
 
465
624
  describe('#setUserEnabled()', () => {
@@ -472,7 +631,7 @@ describe('LocalAuthModule', () => {
472
631
  assert.equal(lastUpdate.data.isTempLocked, false)
473
632
  })
474
633
 
475
- // NOTE: There is a bug in setUserEnabled when disabling (isEnabled=false),
634
+ // NOTE: There is a bug in setUserEnabled -- when disabling (isEnabled=false),
476
635
  // it references user.failedAttempts which doesn't exist on the schema.
477
636
  // The schema field is user.failedLoginAttempts. This means
478
637
  // failedLoginAttempts will always be set to undefined when disabling.
@@ -483,6 +642,21 @@ describe('LocalAuthModule', () => {
483
642
  assert.equal(lastUpdate.data.isPermLocked, true)
484
643
  assert.equal(lastUpdate.data.isTempLocked, true)
485
644
  })
645
+
646
+ it('should use localauthuser schema for user update', async () => {
647
+ const user = { _id: 'user-1', failedLoginAttempts: 0 }
648
+ await mod.setUserEnabled(user, true)
649
+ assert.ok(updateCalls.length > 0)
650
+ })
651
+
652
+ // TODO: Bug - setUserEnabled references user.failedAttempts instead of
653
+ // user.failedLoginAttempts when disabling. See BUGS.md.
654
+ it('should preserve failedLoginAttempts when disabling a user', { todo: 'references user.failedAttempts instead of user.failedLoginAttempts' }, async () => {
655
+ const user = { _id: 'user-1', failedLoginAttempts: 7 }
656
+ await mod.setUserEnabled(user, false)
657
+ const lastUpdate = updateCalls[updateCalls.length - 1]
658
+ assert.equal(lastUpdate.data.failedLoginAttempts, 7)
659
+ })
486
660
  })
487
661
 
488
662
  describe('#updateUser()', () => {
@@ -498,6 +672,13 @@ describe('LocalAuthModule', () => {
498
672
  assert.deepEqual(lastUpdate.query, { email: 'test@example.com' })
499
673
  })
500
674
 
675
+ it('should accept an ObjectId-like object as userIdOrQuery', async () => {
676
+ const fakeObjectId = { constructor: { name: 'ObjectId' }, toString: () => 'abc123' }
677
+ await mod.updateUser(fakeObjectId, { firstName: 'Updated' })
678
+ const lastUpdate = updateCalls[updateCalls.length - 1]
679
+ assert.deepEqual(lastUpdate.query, { _id: fakeObjectId })
680
+ })
681
+
501
682
  it('should hash password when update includes password', async () => {
502
683
  const result = await mod.updateUser('user-id-1', { password: 'newpassword' })
503
684
  assert.ok(result)
@@ -509,6 +690,24 @@ describe('LocalAuthModule', () => {
509
690
  assert.equal(lastUpdate.data.firstName, 'NoPassword')
510
691
  assert.equal(Object.prototype.hasOwnProperty.call(lastUpdate.data, 'password'), false)
511
692
  })
693
+
694
+ it('should call disavowUser after a password update', async () => {
695
+ disavowCalls = []
696
+ await mod.updateUser('user-id-1', { password: 'newpassword' })
697
+ assert.ok(disavowCalls.length > 0)
698
+ assert.equal(disavowCalls[0][0].authType, 'local')
699
+ })
700
+
701
+ it('should send email notification after password update when mailer is enabled', async () => {
702
+ mailerSendCalls = []
703
+ await mod.updateUser('user-id-1', { password: 'newpassword' })
704
+ assert.ok(mailerSendCalls.length > 0)
705
+ assert.equal(mailerSendCalls[0].to, 'test@example.com')
706
+ })
707
+
708
+ it('should pass useDefaults:false and ignoreRequired:true for non-password updates', async () => {
709
+ await assert.doesNotReject(() => mod.updateUser('user-id-1', { firstName: 'Test' }))
710
+ })
512
711
  })
513
712
 
514
713
  describe('#createPasswordReset()', () => {
@@ -525,6 +724,23 @@ describe('LocalAuthModule', () => {
525
724
  (err) => err.name === 'INVALID_PARAMS'
526
725
  )
527
726
  })
727
+
728
+ it('should throw when email is null', async () => {
729
+ await assert.rejects(
730
+ () => mod.createPasswordReset(null),
731
+ (err) => err.name === 'INVALID_PARAMS'
732
+ )
733
+ })
734
+
735
+ it('should include email param in INVALID_PARAMS error data', async () => {
736
+ await assert.rejects(
737
+ () => mod.createPasswordReset(''),
738
+ (err) => {
739
+ assert.deepEqual(err.data.params, ['email'])
740
+ return true
741
+ }
742
+ )
743
+ })
528
744
  })
529
745
 
530
746
  describe('#inviteHandler()', () => {
@@ -532,7 +748,7 @@ describe('LocalAuthModule', () => {
532
748
  let statusSent
533
749
  const req = {
534
750
  body: { email: 'invite@example.com' },
535
- translate: (key) => `translated:${key}`,
751
+ translate: (key) => 'translated:' + key,
536
752
  auth: { user: { _id: { toString: () => 'admin-id' } } }
537
753
  }
538
754
  const res = {
@@ -550,13 +766,30 @@ describe('LocalAuthModule', () => {
550
766
  let nextError
551
767
  const req = {
552
768
  body: { email: '' },
553
- translate: (key) => `translated:${key}`,
769
+ translate: (key) => 'translated:' + key,
554
770
  auth: {}
555
771
  }
556
772
  const res = { sendStatus: () => {} }
557
773
  await mod.inviteHandler(req, res, (err) => { nextError = err })
558
774
  assert.ok(nextError)
559
775
  })
776
+
777
+ it('should pass inviteTokenLifespan to createPasswordReset', async () => {
778
+ let receivedLifespan
779
+ const original = mod.createPasswordReset.bind(mod)
780
+ mod.createPasswordReset = async (email, subject, text, html, lifespan) => {
781
+ receivedLifespan = lifespan
782
+ }
783
+ const req = {
784
+ body: { email: 'invite@example.com' },
785
+ translate: (key) => 'translated:' + key,
786
+ auth: { user: { _id: { toString: () => 'admin-id' } } }
787
+ }
788
+ const res = { sendStatus: () => {} }
789
+ await mod.inviteHandler(req, res, () => {})
790
+ mod.createPasswordReset = original
791
+ assert.equal(receivedLifespan, 604800000)
792
+ })
560
793
  })
561
794
 
562
795
  describe('#registerSuperHandler()', () => {
@@ -588,7 +821,7 @@ describe('LocalAuthModule', () => {
588
821
  let responseJson
589
822
  const req = {
590
823
  body: { email: '' },
591
- translate: (key) => `translated:${key}`,
824
+ translate: (key) => 'translated:' + key,
592
825
  auth: {}
593
826
  }
594
827
  const res = {
@@ -601,6 +834,146 @@ describe('LocalAuthModule', () => {
601
834
  assert.equal(responseStatus, 200)
602
835
  assert.ok(responseJson.message)
603
836
  })
837
+
838
+ it('should include translated message in response', async () => {
839
+ let responseJson
840
+ const req = {
841
+ body: { email: 'test@example.com' },
842
+ translate: (key) => 'translated:' + key,
843
+ auth: {}
844
+ }
845
+ const res = {
846
+ status: () => ({ json: (data) => { responseJson = data } })
847
+ }
848
+ const original = mod.createPasswordReset.bind(mod)
849
+ mod.createPasswordReset = async () => {}
850
+ await mod.forgotPasswordHandler(req, res, () => {})
851
+ mod.createPasswordReset = original
852
+ assert.equal(responseJson.message, 'translated:app.forgotpasswordmessage')
853
+ })
854
+
855
+ it('should not call next with error (swallows errors to avoid info leaks)', async () => {
856
+ let nextCalled = false
857
+ const req = {
858
+ body: { email: '' },
859
+ translate: (key) => 'translated:' + key,
860
+ auth: {}
861
+ }
862
+ const res = {
863
+ status: () => ({ json: () => {} })
864
+ }
865
+ await mod.forgotPasswordHandler(req, res, () => { nextCalled = true })
866
+ assert.equal(nextCalled, false)
867
+ })
868
+ })
869
+
870
+ describe('#changePasswordHandler()', () => {
871
+ it('should respond with 204 on successful authenticated password change', async () => {
872
+ const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
873
+ const oldHash = await PasswordUtils.generate('oldpassword')
874
+ usersStore.push({ email: 'test@example.com', password: oldHash })
875
+
876
+ let endCalled = false
877
+ let statusCode
878
+ const req = {
879
+ body: { password: 'newpassword1', oldPassword: 'oldpassword' },
880
+ auth: {
881
+ token: { type: 'local', signature: 'sig123' },
882
+ user: { email: 'test@example.com', _id: { toString: () => 'uid1' } }
883
+ }
884
+ }
885
+ const res = {
886
+ status: (code) => {
887
+ statusCode = code
888
+ return { end: () => { endCalled = true } }
889
+ }
890
+ }
891
+ await mod.changePasswordHandler(req, res, () => {})
892
+ assert.equal(statusCode, 204)
893
+ assert.equal(endCalled, true)
894
+ })
895
+
896
+ it('should call next with error on invalid old password', async () => {
897
+ const { default: PasswordUtils } = await import('../lib/PasswordUtils.js')
898
+ const oldHash = await PasswordUtils.generate('oldpassword')
899
+ usersStore.push({ email: 'test@example.com', password: oldHash })
900
+
901
+ let nextError
902
+ const req = {
903
+ body: { password: 'newpassword1', oldPassword: 'wrongpassword' },
904
+ auth: {
905
+ token: { type: 'local', signature: 'sig123' },
906
+ user: { email: 'test@example.com', _id: { toString: () => 'uid1' } }
907
+ }
908
+ }
909
+ const res = { status: () => ({ end: () => {} }) }
910
+ await mod.changePasswordHandler(req, res, (err) => { nextError = err })
911
+ assert.ok(nextError)
912
+ })
913
+
914
+ it('should call next with error when auth token type is not local', async () => {
915
+ let nextError
916
+ const req = {
917
+ body: { password: 'newpassword1' },
918
+ auth: {
919
+ token: { type: 'sso', signature: 'sig123' },
920
+ user: { email: 'test@example.com', _id: { toString: () => 'uid1' } }
921
+ }
922
+ }
923
+ const res = { status: () => ({ end: () => {} }) }
924
+ await mod.changePasswordHandler(req, res, (err) => { nextError = err })
925
+ assert.ok(nextError)
926
+ })
927
+
928
+ it('should use reset token path when no auth token present', async () => {
929
+ let statusCode
930
+ let endCalled = false
931
+ const PasswordUtils = (await import('../lib/PasswordUtils.js')).default
932
+
933
+ const originalUpdate = mod.updateUser.bind(mod)
934
+ mod.updateUser = async () => ({ _id: 'uid1' })
935
+
936
+ const origDeleteReset = PasswordUtils.deleteReset
937
+ PasswordUtils.deleteReset = async () => {}
938
+
939
+ const origValidate = PasswordUtils.validateReset
940
+ PasswordUtils.validateReset = async () => ({ email: 'test@example.com' })
941
+
942
+ const req = {
943
+ body: { password: 'newpassword1', token: 'reset-token-123' },
944
+ auth: {}
945
+ }
946
+ const res = {
947
+ status: (code) => {
948
+ statusCode = code
949
+ return { end: () => { endCalled = true } }
950
+ }
951
+ }
952
+ await mod.changePasswordHandler(req, res, () => {})
953
+ assert.equal(statusCode, 204)
954
+ assert.equal(endCalled, true)
955
+
956
+ // Restore
957
+ PasswordUtils.validateReset = origValidate
958
+ PasswordUtils.deleteReset = origDeleteReset
959
+ mod.updateUser = originalUpdate
960
+ })
961
+
962
+ it('should call next with error when email is missing', async () => {
963
+ let nextError
964
+ const PasswordUtils = (await import('../lib/PasswordUtils.js')).default
965
+ const origValidate = PasswordUtils.validateReset
966
+ PasswordUtils.validateReset = async () => ({ email: '' })
967
+
968
+ const req = {
969
+ body: { password: 'newpassword1', token: 'reset-token-123' },
970
+ auth: {}
971
+ }
972
+ const res = { status: () => ({ end: () => {} }) }
973
+ await mod.changePasswordHandler(req, res, (err) => { nextError = err })
974
+ assert.ok(nextError)
975
+ PasswordUtils.validateReset = origValidate
976
+ })
604
977
  })
605
978
 
606
979
  describe('#validatePasswordHandler()', () => {
@@ -608,7 +981,7 @@ describe('LocalAuthModule', () => {
608
981
  let responseJson
609
982
  const req = {
610
983
  body: { password: 'validpassword' },
611
- translate: (key) => `translated:${key}`
984
+ translate: (key) => 'translated:' + key
612
985
  }
613
986
  const res = {
614
987
  json: (data) => { responseJson = data }
@@ -617,12 +990,25 @@ describe('LocalAuthModule', () => {
617
990
  assert.ok(responseJson.message)
618
991
  })
619
992
 
993
+ it('should return translated success message', async () => {
994
+ let responseJson
995
+ const req = {
996
+ body: { password: 'validpassword' },
997
+ translate: (key) => 'translated:' + key
998
+ }
999
+ const res = {
1000
+ json: (data) => { responseJson = data }
1001
+ }
1002
+ await mod.validatePasswordHandler(req, res, () => {})
1003
+ assert.equal(responseJson.message, 'translated:app.passwordindicatorstrong')
1004
+ })
1005
+
620
1006
  it('should call sendError for an invalid password', async () => {
621
1007
  let sentError
622
1008
  authlocalConfig.minPasswordLength = 20
623
1009
  const req = {
624
1010
  body: { password: 'short' },
625
- translate: (key) => `translated:${key}`
1011
+ translate: (key) => 'translated:' + key
626
1012
  }
627
1013
  const res = {
628
1014
  sendError: (err) => { sentError = err }
@@ -631,5 +1017,20 @@ describe('LocalAuthModule', () => {
631
1017
  assert.ok(sentError)
632
1018
  assert.equal(sentError.name, 'INVALID_PASSWORD')
633
1019
  })
1020
+
1021
+ it('should translate error messages and join them', async () => {
1022
+ let sentError
1023
+ authlocalConfig.minPasswordLength = 20
1024
+ const req = {
1025
+ body: { password: 'short' },
1026
+ translate: (key) => 'translated:' + key
1027
+ }
1028
+ const res = {
1029
+ sendError: (err) => { sentError = err }
1030
+ }
1031
+ await mod.validatePasswordHandler(req, res, () => {})
1032
+ assert.equal(typeof sentError.data.errors, 'string')
1033
+ assert.ok(sentError.data.errors.includes('translated:'))
1034
+ })
634
1035
  })
635
1036
  })
@@ -1,4 +1,4 @@
1
- import { describe, it, before, beforeEach, after, mock } from 'node:test'
1
+ import { describe, it, before, beforeEach, mock } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import bcrypt from 'bcryptjs'
4
4
  import { promisify } from 'util'
@@ -108,6 +108,16 @@ describe('PasswordUtils', () => {
108
108
  const result = await PasswordUtils.getConfig('saltRounds', 'minPasswordLength')
109
109
  assert.deepEqual(result, { saltRounds: 10, minPasswordLength: 8 })
110
110
  })
111
+
112
+ it('should return undefined for an unknown config key', async () => {
113
+ const result = await PasswordUtils.getConfig('nonExistentKey')
114
+ assert.equal(result, undefined)
115
+ })
116
+
117
+ it('should return an object with undefined values for unknown keys in multi-key mode', async () => {
118
+ const result = await PasswordUtils.getConfig('saltRounds', 'unknownKey')
119
+ assert.deepEqual(result, { saltRounds: 10, unknownKey: undefined })
120
+ })
111
121
  })
112
122
 
113
123
  describe('#compare()', () => {
@@ -149,6 +159,41 @@ describe('PasswordUtils', () => {
149
159
  (err) => err.name === 'INVALID_LOGIN_DETAILS'
150
160
  )
151
161
  })
162
+
163
+ it('should throw when plainPassword is null', async () => {
164
+ await assert.rejects(
165
+ () => PasswordUtils.compare(null, hash),
166
+ (err) => err.name === 'INVALID_LOGIN_DETAILS'
167
+ )
168
+ })
169
+
170
+ it('should throw when hash is null', async () => {
171
+ await assert.rejects(
172
+ () => PasswordUtils.compare('password', null),
173
+ (err) => err.name === 'INVALID_LOGIN_DETAILS'
174
+ )
175
+ })
176
+
177
+ it('should set INCORRECT_PASSWORD as nested error data on mismatch', async () => {
178
+ await assert.rejects(
179
+ () => PasswordUtils.compare('wrongpassword', hash),
180
+ (err) => {
181
+ assert.equal(err.data.error.name, 'INCORRECT_PASSWORD')
182
+ return true
183
+ }
184
+ )
185
+ })
186
+
187
+ it('should set INVALID_PARAMS as nested error data when params missing', async () => {
188
+ await assert.rejects(
189
+ () => PasswordUtils.compare('', ''),
190
+ (err) => {
191
+ assert.equal(err.data.error.name, 'INVALID_PARAMS')
192
+ assert.deepEqual(err.data.error.data.params, ['plainPassword', 'hash'])
193
+ return true
194
+ }
195
+ )
196
+ })
152
197
  })
153
198
 
154
199
  describe('#validate()', () => {
@@ -175,6 +220,27 @@ describe('PasswordUtils', () => {
175
220
  )
176
221
  })
177
222
 
223
+ it('should throw when password is null', async () => {
224
+ await assert.rejects(
225
+ () => PasswordUtils.validate(null),
226
+ (err) => err.name === 'INVALID_PARAMS'
227
+ )
228
+ })
229
+
230
+ it('should throw when password is undefined', async () => {
231
+ await assert.rejects(
232
+ () => PasswordUtils.validate(undefined),
233
+ (err) => err.name === 'INVALID_PARAMS'
234
+ )
235
+ })
236
+
237
+ it('should throw when password is a boolean', async () => {
238
+ await assert.rejects(
239
+ () => PasswordUtils.validate(true),
240
+ (err) => err.name === 'INVALID_PARAMS'
241
+ )
242
+ })
243
+
178
244
  it('should throw when password is too short', async () => {
179
245
  await assert.rejects(
180
246
  () => PasswordUtils.validate('short'),
@@ -182,6 +248,23 @@ describe('PasswordUtils', () => {
182
248
  )
183
249
  })
184
250
 
251
+ it('should include minimum length in INVALID_PASSWORD_LENGTH error data', async () => {
252
+ await assert.rejects(
253
+ () => PasswordUtils.validate('short'),
254
+ (err) => {
255
+ assert.equal(err.name, 'INVALID_PASSWORD')
256
+ const lengthErr = err.data.errors.find(e => e.name === 'INVALID_PASSWORD_LENGTH')
257
+ assert.ok(lengthErr)
258
+ assert.equal(lengthErr.data.length, 8)
259
+ return true
260
+ }
261
+ )
262
+ })
263
+
264
+ it('should accept a password of exactly minimum length', async () => {
265
+ await assert.doesNotReject(() => PasswordUtils.validate('12345678'))
266
+ })
267
+
185
268
  it('should throw when number is required but missing', async () => {
186
269
  authlocalConfig.passwordMustHaveNumber = true
187
270
  await assert.rejects(
@@ -232,11 +315,11 @@ describe('PasswordUtils', () => {
232
315
  )
233
316
  })
234
317
 
235
- it('should accept a password with special character when required', async () => {
318
+ it('should accept each recognized special character', async () => {
236
319
  authlocalConfig.passwordMustHaveSpecial = true
237
320
  const specials = ['#', '?', '!', '@', '$', '%', '^', '&', '*', '-']
238
321
  for (const ch of specials) {
239
- await assert.doesNotReject(() => PasswordUtils.validate(`abcdefg${ch}`))
322
+ await assert.doesNotReject(() => PasswordUtils.validate('abcdefg' + ch))
240
323
  }
241
324
  })
242
325
 
@@ -262,7 +345,12 @@ describe('PasswordUtils', () => {
262
345
  await assert.doesNotReject(() => PasswordUtils.validate('Abcdef1!'))
263
346
  })
264
347
 
265
- // NOTE: The blacklist check in the source has a bug — it uses .some() where
348
+ it('should accept an empty string when minPasswordLength is zero', async () => {
349
+ authlocalConfig.minPasswordLength = 0
350
+ await assert.doesNotReject(() => PasswordUtils.validate(''))
351
+ })
352
+
353
+ // NOTE: The blacklist check in the source has a bug -- it uses .some() where
266
354
  // it should use .every(). This means a password containing a blacklisted
267
355
  // value can still pass if there are other blacklisted values it doesn't
268
356
  // contain. The test below documents the expected (correct) behaviour.
@@ -281,10 +369,15 @@ describe('PasswordUtils', () => {
281
369
  await assert.doesNotReject(() => PasswordUtils.validate('securevalue'))
282
370
  })
283
371
 
284
- // BUG: The blacklist check uses .some(p => !(password.includes(p))) where
285
- // it should use .every(). With multiple blacklisted values, a password
286
- // containing one blacklisted value passes if another blacklisted value is
287
- // absent. See PasswordUtils.js line 67.
372
+ it('should handle an empty blacklist array', async () => {
373
+ authlocalConfig.blacklistedPasswordValues = []
374
+ await assert.doesNotReject(() => PasswordUtils.validate('anything1'))
375
+ })
376
+
377
+ // TODO: Bug - blacklist check uses .some() instead of .every()
378
+ // With multiple blacklisted values, a password containing one blacklisted
379
+ // value passes if another blacklisted value is absent.
380
+ // See BUGS.md and PasswordUtils.js line 67.
288
381
  it('should throw when password contains any blacklisted value (multiple entries)', { todo: 'blacklist check uses .some() instead of .every()' }, async () => {
289
382
  authlocalConfig.blacklistedPasswordValues = ['password', 'qwerty']
290
383
  await assert.rejects(
@@ -315,11 +408,25 @@ describe('PasswordUtils', () => {
315
408
  )
316
409
  })
317
410
 
411
+ it('should throw when plainPassword is null', async () => {
412
+ await assert.rejects(
413
+ () => PasswordUtils.generate(null),
414
+ (err) => err.name === 'INVALID_PARAMS'
415
+ )
416
+ })
417
+
318
418
  it('should generate different hashes for the same password (salted)', async () => {
319
419
  const hash1 = await PasswordUtils.generate('samepassword')
320
420
  const hash2 = await PasswordUtils.generate('samepassword')
321
421
  assert.notEqual(hash1, hash2)
322
422
  })
423
+
424
+ it('should generate a hash verifiable with bcrypt compare', async () => {
425
+ const password = 'verifyMe123'
426
+ const hash = await PasswordUtils.generate(password)
427
+ const isValid = await promisify(bcrypt.compare)(password, hash)
428
+ assert.equal(isValid, true)
429
+ })
323
430
  })
324
431
 
325
432
  describe('#getRandomHex()', () => {
@@ -340,6 +447,12 @@ describe('PasswordUtils', () => {
340
447
  const hex2 = await PasswordUtils.getRandomHex()
341
448
  assert.notEqual(hex1, hex2)
342
449
  })
450
+
451
+ it('should handle a size of 1', async () => {
452
+ const hex = await PasswordUtils.getRandomHex(1)
453
+ assert.equal(hex.length, 2)
454
+ assert.ok(/^[0-9a-f]+$/.test(hex))
455
+ })
343
456
  })
344
457
 
345
458
  describe('#createReset()', () => {
@@ -364,6 +477,15 @@ describe('PasswordUtils', () => {
364
477
  assert.ok(mockPasswordResetsStore[0].token)
365
478
  })
366
479
 
480
+ it('should set expiresAt to a future date based on lifespan', async () => {
481
+ mockUsersStore.push({ email: 'test@example.com', authType: 'local' })
482
+ const beforeTime = Date.now()
483
+ await PasswordUtils.createReset('test@example.com', 86400000)
484
+ const expiresAt = new Date(mockPasswordResetsStore[0].expiresAt).getTime()
485
+ assert.ok(expiresAt >= beforeTime + 86400000)
486
+ assert.ok(expiresAt <= Date.now() + 86400000)
487
+ })
488
+
367
489
  it('should throw when user is not found', async () => {
368
490
  await assert.rejects(
369
491
  () => PasswordUtils.createReset('noone@example.com', 86400000),
@@ -371,6 +493,17 @@ describe('PasswordUtils', () => {
371
493
  )
372
494
  })
373
495
 
496
+ it('should include user email as id in NOT_FOUND error data', async () => {
497
+ await assert.rejects(
498
+ () => PasswordUtils.createReset('noone@example.com', 86400000),
499
+ (err) => {
500
+ assert.equal(err.data.id, 'noone@example.com')
501
+ assert.equal(err.data.type, 'user')
502
+ return true
503
+ }
504
+ )
505
+ })
506
+
374
507
  it('should throw when user is not authenticated with local auth', async () => {
375
508
  mockUsersStore.push({ email: 'sso@example.com', authType: 'sso' })
376
509
  await assert.rejects(
@@ -396,6 +529,10 @@ describe('PasswordUtils', () => {
396
529
  await PasswordUtils.deleteReset('abc123')
397
530
  assert.equal(mockPasswordResetsStore.length, 0)
398
531
  })
532
+
533
+ it('should not error when deleting a non-existent token', async () => {
534
+ await assert.doesNotReject(() => PasswordUtils.deleteReset('nonexistent'))
535
+ })
399
536
  })
400
537
 
401
538
  describe('#validateReset()', () => {
@@ -418,6 +555,13 @@ describe('PasswordUtils', () => {
418
555
  )
419
556
  })
420
557
 
558
+ it('should throw when token is null', async () => {
559
+ await assert.rejects(
560
+ () => PasswordUtils.validateReset(null),
561
+ (err) => err.name === 'INVALID_PARAMS'
562
+ )
563
+ })
564
+
421
565
  it('should throw when token is not found', async () => {
422
566
  await assert.rejects(
423
567
  () => PasswordUtils.validateReset('nonexistent'),
@@ -449,6 +593,24 @@ describe('PasswordUtils', () => {
449
593
  )
450
594
  })
451
595
 
596
+ // TODO: Bug - validateReset uses token.email instead of tokenData.email
597
+ // in the NOT_FOUND error data. Since token is a string, token.email is
598
+ // undefined. See PasswordUtils.js line 167.
599
+ it('should include correct email in NOT_FOUND error when user is missing', { todo: 'uses token.email (string) instead of tokenData.email' }, async () => {
600
+ mockPasswordResetsStore.push({
601
+ token: 'orphan-token',
602
+ email: 'orphan@example.com',
603
+ expiresAt: new Date(Date.now() + 86400000).toISOString()
604
+ })
605
+ await assert.rejects(
606
+ () => PasswordUtils.validateReset('orphan-token'),
607
+ (err) => {
608
+ assert.equal(err.data.id, 'orphan@example.com')
609
+ return true
610
+ }
611
+ )
612
+ })
613
+
452
614
  it('should return token data for a valid, non-expired token', async () => {
453
615
  const tokenData = {
454
616
  token: 'valid-token',
@@ -461,5 +623,16 @@ describe('PasswordUtils', () => {
461
623
  assert.equal(result.token, 'valid-token')
462
624
  assert.equal(result.email, 'test@example.com')
463
625
  })
626
+
627
+ it('should accept a token that has not yet expired', async () => {
628
+ mockPasswordResetsStore.push({
629
+ token: 'edge-token',
630
+ email: 'test@example.com',
631
+ expiresAt: new Date(Date.now() + 1000).toISOString()
632
+ })
633
+ mockUsersStore.push({ email: 'test@example.com' })
634
+ const result = await PasswordUtils.validateReset('edge-token')
635
+ assert.equal(result.token, 'edge-token')
636
+ })
464
637
  })
465
638
  })