mirror of
https://github.com/logto-io/logto.git
synced 2024-12-23 20:33:16 -05:00
254 lines
9.4 KiB
TypeScript
254 lines
9.4 KiB
TypeScript
import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest';
|
|
import { ZodError } from 'zod';
|
|
|
|
import { PasswordPolicyChecker } from './password-policy.js';
|
|
|
|
const mockPwnResponse = () => {
|
|
const originalFetch = global.fetch;
|
|
beforeAll(() => {
|
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
// Return hash suffixes for '123456'.
|
|
text: async () =>
|
|
'D032E84B0AEB4E773555C73D6B13BEA7A44:1\nD09CA3762AF61E59520943DC26494F8941B:37615252',
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
|
global.fetch = originalFetch;
|
|
});
|
|
};
|
|
|
|
describe('PasswordPolicyChecker', () => {
|
|
it('should reject malformed policy', () => {
|
|
expect(() => {
|
|
// @ts-expect-error
|
|
return new PasswordPolicyChecker({ length: { min: 1, max: '2' } });
|
|
}).toThrowError(ZodError);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> check()', () => {
|
|
mockPwnResponse();
|
|
|
|
const checker = new PasswordPolicyChecker({
|
|
length: { min: 7, max: 15 },
|
|
characterTypes: { min: 3 },
|
|
rejects: {
|
|
pwned: true,
|
|
repetitionAndSequence: true,
|
|
userInfo: true,
|
|
words: ['aaaaaate', 'aaaaaaaa', 'silverhand'],
|
|
},
|
|
});
|
|
|
|
it('should accept valid password', async () => {
|
|
expect(await checker.check('aL1!aL1!', {})).toEqual([]);
|
|
expect(await checker.check('silverHAnd213', {})).toEqual([]);
|
|
expect(await checker.check('lo9KI8mJu112', {})).toEqual([]);
|
|
});
|
|
|
|
it('should recognize rejection combinations', async () => {
|
|
expect(await checker.check('aL1!aL1!', { name: 'aL1!' })).toEqual([
|
|
{ code: 'password_rejected.restricted.user_info' },
|
|
]);
|
|
expect(await checker.check('lo9KI8mju78911', {})).toEqual([
|
|
{ code: 'password_rejected.restricted.sequence' },
|
|
]);
|
|
expect(await checker.check('lo9KI8MJU789111', {})).toEqual([
|
|
{ code: 'password_rejected.restricted.sequence' },
|
|
{ code: 'password_rejected.restricted.repetition' },
|
|
]);
|
|
});
|
|
|
|
it('should reject with all failed checks', async () => {
|
|
expect(await checker.check('aaa😀', {})).toEqual([
|
|
{ code: 'password_rejected.too_short', interpolation: { min: 7 } },
|
|
{ code: 'password_rejected.unsupported_characters' },
|
|
{ code: 'password_rejected.restricted.repetition' },
|
|
]);
|
|
|
|
expect(await checker.check('123456', { phoneNumber: '12345' })).toEqual([
|
|
{ code: 'password_rejected.too_short', interpolation: { min: 7 } },
|
|
{ code: 'password_rejected.character_types', interpolation: { min: 3 } },
|
|
{ code: 'password_rejected.pwned' },
|
|
{ code: 'password_rejected.restricted.sequence' },
|
|
{ code: 'password_rejected.restricted.user_info' },
|
|
]);
|
|
|
|
expect(await checker.check('aaaaaaaaAAAAAAAAbcdCOK', { name: 'CO' })).toEqual([
|
|
{ code: 'password_rejected.too_long', interpolation: { max: 15 } },
|
|
{ code: 'password_rejected.character_types', interpolation: { min: 3 } },
|
|
{ code: 'password_rejected.restricted.repetition' },
|
|
{ code: 'password_rejected.restricted.words' },
|
|
{ code: 'password_rejected.restricted.sequence' },
|
|
{ code: 'password_rejected.restricted.user_info' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> checkCharTypes()', () => {
|
|
const checker1 = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 2 },
|
|
rejects: { pwned: false, repetitionAndSequence: false, userInfo: false, words: [] },
|
|
});
|
|
const checker2 = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 4 },
|
|
rejects: { pwned: false, repetitionAndSequence: false, userInfo: false, words: [] },
|
|
});
|
|
|
|
it('should reject unsupported characters', () => {
|
|
expect(checker1.checkCharTypes('😀')).toBe('unsupported');
|
|
expect(checker2.checkCharTypes('aA1!😀')).toBe('unsupported');
|
|
});
|
|
|
|
it('should reject password with too few character types', () => {
|
|
expect(checker1.checkCharTypes('a')).toBe(false);
|
|
expect(checker2.checkCharTypes('aA')).toBe(false);
|
|
});
|
|
|
|
it('should accept password with enough character types', () => {
|
|
expect(checker1.checkCharTypes('aA')).toBe(true);
|
|
expect(checker1.checkCharTypes('aA1!0')).toBe(true);
|
|
expect(checker2.checkCharTypes('aA1!0')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> hasBeenPwned()', () => {
|
|
const checker = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 2 },
|
|
rejects: { pwned: true, repetitionAndSequence: false, userInfo: false, words: [] },
|
|
});
|
|
|
|
mockPwnResponse();
|
|
|
|
it('should reject pwned password', async () => {
|
|
expect(await checker.hasBeenPwned('123456')).toBe(true);
|
|
});
|
|
|
|
it('should accept non-pwned password', async () => {
|
|
expect(await checker.hasBeenPwned('1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> repetitionLength()', () => {
|
|
const checker = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 2 },
|
|
rejects: { pwned: false, repetitionAndSequence: true, words: [] },
|
|
});
|
|
|
|
it('should recognize repeated characters that start at the beginning', () => {
|
|
expect(checker.repetitionLength('aaaa')).toBe(4);
|
|
expect(checker.repetitionLength('aaa12')).toBe(3);
|
|
expect(checker.repetitionLength('AAAAAA😀')).toBe(6);
|
|
});
|
|
|
|
it('should ignore repeated characters that do not start at the beginning or are too short', () => {
|
|
expect(checker.repetitionLength('a')).toBe(0);
|
|
expect(checker.repetitionLength('aa')).toBe(0);
|
|
expect(checker.repetitionLength('aaAaaAaa')).toBe(0);
|
|
expect(checker.repetitionLength('aL!bbbbb')).toBe(0);
|
|
expect(checker.repetitionLength('aL1!')).toBe(0);
|
|
expect(checker.repetitionLength('aL1!bbbbbbbbbbbb')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> userInfoLength()', () => {
|
|
const checker = new PasswordPolicyChecker({
|
|
rejects: { pwned: false, repetitionAndSequence: false, userInfo: true, words: [] },
|
|
});
|
|
|
|
it('should recognize name', () => {
|
|
expect(checker.userInfoLength('test', { name: 'test' })).toBe(4);
|
|
expect(checker.userInfoLength('test', { name: 'test2' })).toBe(0);
|
|
expect(checker.userInfoLength('FOO', { name: 'Foo bar' })).toBe(3);
|
|
expect(checker.userInfoLength('Foo', { name: 'bar fOo' })).toBe(3);
|
|
expect(checker.userInfoLength('barFooBaz12', { name: 'bar fOo baz' })).toBe(9);
|
|
expect(checker.userInfoLength('bar fOo baz12', { name: 'bar fOo baz' })).toBe(15);
|
|
expect(checker.userInfoLength('bar fOo baz12', { name: 'bar fOo baz' })).toBe(3);
|
|
expect(checker.userInfoLength('barfOo baz12', { name: 'bar fOo baz' })).toBe(3);
|
|
});
|
|
|
|
it('should recognize username', () => {
|
|
expect(checker.userInfoLength('test1!', { username: 'teST' })).toBe(4);
|
|
expect(checker.userInfoLength('test123', { username: 'test2' })).toBe(0);
|
|
});
|
|
|
|
it('should recognize email', () => {
|
|
expect(checker.userInfoLength('teST1', { email: 'test@foo.com' })).toBe(4);
|
|
expect(checker.userInfoLength('TEST2', { email: 'test1@foo.com' })).toBe(0);
|
|
expect(checker.userInfoLength('FOO', { email: 'test@foo.com' })).toBe(0);
|
|
expect(checker.userInfoLength('Foo!', { email: 'fOO@foo.com' })).toBe(3);
|
|
});
|
|
|
|
it('should recognize phone number', () => {
|
|
expect(checker.userInfoLength('123456789ST', { phoneNumber: '123456789' })).toBe(9);
|
|
expect(checker.userInfoLength('123456789ST', { phoneNumber: '12' })).toBe(2);
|
|
expect(checker.userInfoLength('teST1234567890.', { phoneNumber: '123456789' })).toBe(0);
|
|
expect(checker.userInfoLength('TEST12345678', { phoneNumber: '123456789' })).toBe(0);
|
|
});
|
|
|
|
it('should return the longest match', () => {
|
|
expect(
|
|
checker.userInfoLength('123456789ST', {
|
|
name: '1234 56789',
|
|
username: '12345',
|
|
email: '1234567',
|
|
phoneNumber: '1234',
|
|
})
|
|
).toBe(9);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> wordLength()', () => {
|
|
const checker = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 2 },
|
|
rejects: {
|
|
pwned: false,
|
|
repetitionAndSequence: false,
|
|
userInfo: false,
|
|
words: ['test', 'teSt2', 'TesT3'],
|
|
},
|
|
});
|
|
|
|
it('should recognize blacklisted words (case insensitive)', () => {
|
|
expect(checker.wordLength('test')).toEqual(4);
|
|
expect(checker.wordLength('tEst2')).toEqual(5);
|
|
});
|
|
|
|
it('should ignore other words', () => {
|
|
expect(checker.wordLength('tes4')).toEqual(0);
|
|
expect(checker.wordLength('tes4 est5 tes t')).toEqual(0);
|
|
expect(checker.wordLength('tES4 TEst2 teSt3')).toEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('PasswordPolicyChecker -> sequenceLength()', () => {
|
|
const checker = new PasswordPolicyChecker({
|
|
length: { min: 1, max: 256 },
|
|
characterTypes: { min: 2 },
|
|
rejects: { pwned: false, repetitionAndSequence: true, userInfo: false, words: [] },
|
|
});
|
|
|
|
it('should recognize string starts with too many sequential characters', () => {
|
|
expect(checker.sequenceLength('1234')).toBe(4);
|
|
expect(checker.sequenceLength('edcba')).toBe(5);
|
|
expect(checker.sequenceLength('BCDEDC')).toBe(4);
|
|
expect(checker.sequenceLength('YUIOP##')).toBe(5);
|
|
expect(checker.sequenceLength('2wsx3edc1')).toBe(4);
|
|
expect(checker.sequenceLength('lo9KI8mJu7890')).toBe(3);
|
|
});
|
|
|
|
it('should ignore string starts with too few sequential characters', () => {
|
|
expect(checker.sequenceLength('z')).toBe(0);
|
|
expect(checker.sequenceLength('FE')).toBe(0);
|
|
expect(checker.sequenceLength('aL1!')).toBe(0);
|
|
expect(checker.sequenceLength('aL1!BCDEFA')).toBe(0);
|
|
});
|
|
});
|