0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-23 20:33:16 -05:00
logto/packages/toolkit/core-kit/src/password-policy.test.ts
2024-03-28 12:07:54 +08:00

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);
});
});