import { isNumber, isLowercaseChar, isUppercaseChar, isSpecialChar, minLength } from '@/utils/password-validation'; import { strengthColor, strengthIndicator } from '@/utils/password-strength'; /** * Password rules for the login and change-password forms. * * These gate what the console will accept as a credential, so the boundaries * matter more than the happy path: a rule that is one character loose is a * weaker password policy than the UI claims to enforce. */ describe('password-validation', () => { describe('character-class rules', () => { it.each([ ['isNumber', isNumber, 'abc1', 'abcd'], ['isLowercaseChar', isLowercaseChar, 'ABCd', 'ABCD'], ['isUppercaseChar', isUppercaseChar, 'abcD', 'abcd'], ['isSpecialChar', isSpecialChar, 'abc!', 'abcd'] ])('%s should accept a qualifying value and reject one without it', (_name, rule, pass, fail) => { expect(rule(pass)).toBe(true); expect(rule(fail)).toBe(false); }); it('should accept a digit anywhere in the value, not only at the end', () => { expect(isNumber('1abc')).toBe(true); expect(isNumber('ab1c')).toBe(true); }); it.each(['-', '+', '_', '!', '@', '#', '$', '%', '^', '&', '*', '.', ',', '?'])( 'should treat %s as a special character', (char) => { expect(isSpecialChar(`abc${char}`)).toBe(true); } ); it.each(['(', ')', '=', '/', '\\', '~', '|', '<', '>', '[', ']', '{', '}', ';', ':', "'", '"'])( 'should NOT treat %s as a special character under the current rule set', (char) => { // Documents the accepted set precisely: a user typing a symbol outside // it is told their password has no special character. expect(isSpecialChar(`abc${char}`)).toBe(false); } ); it.each([isNumber, isLowercaseChar, isUppercaseChar, isSpecialChar])( 'should reject an empty value', (rule) => { expect(rule('')).toBe(false); } ); }); describe('minLength', () => { it('should reject a value of exactly eight characters minus one', () => { expect(minLength('1234567')).toBe(false); }); it('should accept a value of eight characters', () => { // The boundary the UI advertises as "at least 8 characters". expect(minLength('12345678')).toBe(true); }); it('should accept anything longer', () => { expect(minLength('123456789')).toBe(true); }); it('should reject an empty value', () => { expect(minLength('')).toBe(false); }); }); }); describe('password-strength', () => { describe('strengthIndicator', () => { it('should score an empty password at zero', () => { expect(strengthIndicator('')).toBe(0); }); it('should score a short lowercase-only password at zero', () => { expect(strengthIndicator('abcde')).toBe(0); }); it('should award a point once the password passes five characters', () => { expect(strengthIndicator('abcdef')).toBe(1); }); it('should award a second point once it passes seven characters', () => { expect(strengthIndicator('abcdefgh')).toBe(2); }); it('should award a point for a digit', () => { expect(strengthIndicator('abcde1')).toBe(2); }); it('should award a point for a special character', () => { expect(strengthIndicator('abcde!')).toBe(2); }); it('should award a point only when BOTH cases are present', () => { expect(strengthIndicator('abcdeF')).toBe(2); expect(strengthIndicator('ABCDEF')).toBe(1); }); it('should award every point for a long mixed password', () => { expect(strengthIndicator('Abcdef1!')).toBe(5); }); it('should never exceed five, which is the maximum the rules can award', () => { // Worth pinning: strengthColor branches on `count < 6`, which is only // reachable if this invariant is ever broken. const samples = ['', 'a', 'Abcdef1!', 'A1!aaaaaaaaaaaaaaaaaaaaaaaaaa', '!!!!!!!!!!AAAAaaaa1111']; samples.forEach((password) => { expect(strengthIndicator(password)).toBeLessThanOrEqual(5); expect(strengthIndicator(password)).toBeGreaterThanOrEqual(0); }); }); }); describe('strengthColor', () => { it.each([ [0, 'Poor'], [1, 'Poor'], [2, 'Weak'], [3, 'Normal'], [4, 'Good'], [5, 'Strong'] ])('should label a score of %d as %s', (count, label) => { expect(strengthColor(count).label).toBe(label); }); it('should return a colour token alongside every label', () => { [0, 1, 2, 3, 4, 5].forEach((count) => { expect(strengthColor(count).color).toEqual(expect.any(String)); expect(strengthColor(count).color).not.toBe(''); }); }); it('should escalate the label monotonically as the score rises', () => { const ranked = ['Poor', 'Weak', 'Normal', 'Good', 'Strong']; const labels = [0, 1, 2, 3, 4, 5].map((count) => strengthColor(count).label); const indices = labels.map((label) => ranked.indexOf(label)); indices.slice(1).forEach((index, i) => expect(index).toBeGreaterThanOrEqual(indices[i])); }); }); describe('the two modules together', () => { it('should rate a password that satisfies every validation rule as Strong', () => { const password = 'Abcdef1!'; expect(minLength(password)).toBe(true); expect(isNumber(password)).toBe(true); expect(isLowercaseChar(password)).toBe(true); expect(isUppercaseChar(password)).toBe(true); expect(isSpecialChar(password)).toBe(true); expect(strengthColor(strengthIndicator(password)).label).toBe('Strong'); }); it('should rate a password that satisfies none of them as Poor', () => { expect(strengthColor(strengthIndicator('abc')).label).toBe('Poor'); }); }); });