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.
- package/.github/workflows/tests.yml +4 -2
- package/package.json +1 -1
- package/tests/LocalAuthModule.spec.js +411 -10
- package/tests/PasswordUtils.spec.js +181 -8
|
@@ -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@
|
|
8
|
-
- uses: actions/setup-node@
|
|
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.
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
})
|