import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_SYMBOL_CHARS, DIGIT_CHARS, LOWER_CHARS, MAX_CUSTOM_SYMBOLS, UPPER_CHARS, entropyForMemorizable, entropyForRandom, entropyIsApproximate, generateMemorizable, generateRandom, } from '../src/generator.js'; import { getWordList, listIds, listSize } from '../src/words.js'; const LOWER = /[a-z]/; const UPPER = /[A-Z]/; const DIGIT = /[0-9]/; const SYMBOL = /[^a-zA-Z0-9]/; /** Matches an unpaired UTF-16 surrogate, i.e. half of an astral character. */ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { assert.deepEqual(listIds().sort(), ['eff-large', 'eff-short']); assert.equal(listSize('eff-large'), 7776); assert.equal(listSize('eff-short'), 1296); assert.equal(getWordList('eff-large').length, 7776); assert.throws(() => getWordList('nope'), /unknown word list/); }); test('random mode respects the requested length and only uses enabled classes', () => { const value = generateRandom({ length: 24, useLower: true, useUpper: false, useDigits: false, useSymbols: false, }); assert.equal(value.length, 24); assert.match(value, /^[a-z]+$/); }); test('random mode honours each enabled class', () => { const value = generateRandom({ length: 32, useLower: true, useUpper: true, useDigits: true, useSymbols: true, }); assert.equal(value.length, 32); assert.match(value, LOWER); assert.match(value, UPPER); assert.match(value, DIGIT); assert.match(value, SYMBOL); }); test('random mode with requireEach contains at least one char from each enabled class', () => { for (let attempt = 0; attempt < 25; attempt += 1) { const value = generateRandom({ length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: true, requireEach: true, }); assert.equal(value.length, 16); assert.match(value, LOWER); assert.match(value, UPPER); assert.match(value, DIGIT); assert.match(value, SYMBOL); } }); test('random mode uses the custom symbol set instead of the default one', () => { const value = generateRandom({ length: 20, useLower: true, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '@#', requireEach: true, }); assert.match(value, /^[a-z@#]+$/); assert.match(value, /[@#]/); assert.ok(!value.includes('!')); assert.equal(DEFAULT_SYMBOL_CHARS.includes('!'), true); }); test('random mode excludes ambiguous characters on request', () => { const value = generateRandom({ length: 60, useLower: true, useUpper: true, useDigits: true, useSymbols: true, excludeAmbiguous: true, }); assert.equal(value.length, 60); for (const ch of ['0', 'O', '1', 'l', 'I', '|']) { assert.ok(!value.includes(ch), `expected ${ch} to be excluded`); } }); test('random mode throws when every set is disabled', () => { assert.throws( () => generateRandom({ length: 16, useLower: false, useUpper: false, useDigits: false, useSymbols: false, }), /no characters available/, ); }); test('memorizable output has the requested groups, digits and words', () => { const words = getWordList('eff-large'); const value = generateMemorizable({ words: 3, digits: 2, digitSeparator: '', separator: '_', capitalization: 'none', list: 'eff-large', }); assert.match(value, /^[0-9a-zA-Z]*([\-_. ]?[0-9a-zA-Z]+)*$/); const groups = value.split('_'); assert.equal(groups.length, 3); for (const group of groups) { assert.match(group, /^\d{2}[a-z]+(-[a-z]+)*$/); const word = group.slice(2); assert.ok(words.includes(word), `expected "${word}" to come from the word list`); } }); test('memorizable output supports every group separator', () => { for (const separator of ['_', '.', ' ']) { const value = generateMemorizable({ words: 2, digits: 0, separator, capitalization: 'none', list: 'eff-short', }); assert.equal(value.split(separator).length, 2); } }); test('memorizable output applies the digit separator', () => { const value = generateMemorizable({ words: 2, digits: 3, digitSeparator: '-', separator: '_', capitalization: 'none', list: 'eff-short', }); const groups = value.split('_'); assert.equal(groups.length, 2); for (const group of groups) { assert.match(group, /^\d{3}-[a-z]+(-[a-z]+)*$/); } }); test('memorizable output applies capitalization', () => { const none = generateMemorizable({ words: 2, digits: 0, capitalization: 'none', list: 'eff-short' }).split('-')[0]; const title = generateMemorizable({ words: 2, digits: 0, capitalization: 'title', list: 'eff-short' }).split('-')[0]; const upper = generateMemorizable({ words: 2, digits: 0, capitalization: 'upper', list: 'eff-short' }).split('-')[0]; assert.match(none, /^[a-z]+(-[a-z]+)*$/); assert.match(title, /^[A-Z][a-z]*(-[a-z]+)*$/); assert.match(upper, /^[A-Z]+(-[a-z]+)*$/); }); test('memorizable with digits=0 contains no digits', () => { for (let attempt = 0; attempt < 10; attempt += 1) { const value = generateMemorizable({ words: 5, digits: 0, separator: '-', capitalization: 'none', list: 'eff-large', }); assert.ok(!/[0-9]/.test(value), 'expected no digits'); assert.equal(value.split('-').length, 5); } }); test('memorizable rejects unknown word lists and out-of-range options', () => { assert.throws( () => generateMemorizable({ words: 2, digits: 2, list: 'nope' }), /unknown word list/, ); assert.throws( () => generateMemorizable({ words: 99, digits: 2 }), /words must be between 2 and 10/, ); assert.throws( () => generateMemorizable({ words: 2, digits: 9 }), /digits must be between 0 and 4/, ); }); test('two consecutive calls differ', () => { const a = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true }); const b = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true }); assert.notEqual(a, b); const m1 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' }); const m2 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' }); assert.notEqual(m1, m2); }); test('entropy helpers return sane numbers', () => { const short = entropyForRandom({ length: 8, useLower: true }); const long = entropyForRandom({ length: 64, useLower: true }); assert.ok(short > 0, 'expected positive entropy'); assert.ok(long > short, 'expected entropy to grow with length'); assert.equal(short, Math.round(short * 10) / 10); assert.equal(long, Math.round(long * 10) / 10); const symbols = entropyForRandom({ length: 8, useLower: true, useUpper: true, useDigits: true, useSymbols: true, }); assert.ok(symbols > short); const memorable = entropyForMemorizable({ words: 4, digits: 2, list: 'eff-large' }); const expected = 4 * Math.log2(7776) + 4 * 2 * Math.log2(10); assert.equal(memorable, Math.round(expected * 10) / 10); assert.ok(memorable > 0); const moreWords = entropyForMemorizable({ words: 5, digits: 2, list: 'eff-large' }); assert.ok(moreWords > memorable); const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' }); assert.ok(shorter < memorable); }); // --- bug 1: unicode symbols were split into UTF-16 code units --- test('random mode never splits astral characters into lone surrogates', () => { for (let attempt = 0; attempt < 50; attempt += 1) { const value = generateRandom({ length: 12, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '😀', requireEach: true, }); assert.ok(!LONE_SURROGATE.test(value), `expected no lone surrogate in ${JSON.stringify(value)}`); const codePoints = Array.from(value); assert.equal(codePoints.length, 12, 'expected 12 code points'); for (const codePoint of codePoints) { assert.equal(codePoint, '😀'); } } }); test('random mode handles a mixed custom symbol set with astral characters', () => { const value = generateRandom({ length: 16, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '😀🎉a', requireEach: true, }); assert.ok(!LONE_SURROGATE.test(value), `expected no lone surrogate in ${JSON.stringify(value)}`); assert.equal(Array.from(value).length, 16); for (const codePoint of Array.from(value)) { assert.ok(['😀', '🎉', 'a'].includes(codePoint), `unexpected code point ${codePoint}`); } }); test('random mode entropy counts code points, not UTF-16 code units', () => { const one = entropyForRandom({ length: 8, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '😀', }); const two = entropyForRandom({ length: 8, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '😀🎉', }); assert.equal(one, 8 * Math.log2(1)); assert.equal(two, 8 * Math.log2(2)); }); // --- bug 2: boolean options treated garbage as true --- test('boolean options accept the documented truthy and falsy spellings', () => { for (const value of [true, 1, 'true', 'TRUE', '1', 'yes', 'Yes', 'on']) { const generated = generateRandom({ length: 12, useLower: value, useUpper: false, useDigits: false, useSymbols: false, }); assert.match(generated, LOWER, `expected ${JSON.stringify(value)} to enable lowercase`); } for (const value of [false, 0, 'false', 'FALSE', '0', 'no', 'No', 'off']) { const generated = generateRandom({ length: 12, useLower: true, useUpper: value, useDigits: false, useSymbols: false, }); assert.ok(!UPPER.test(generated), `expected ${JSON.stringify(value)} to disable uppercase`); } }); test('boolean options reject unparseable values and name the option', () => { assert.throws( () => generateRandom({ length: 12, useLower: 'maybe' }), /useLower must be a boolean/, ); assert.throws( () => generateRandom({ length: 12, useUpper: 'nope' }), /useUpper must be a boolean/, ); assert.throws( () => generateRandom({ length: 12, useDigits: 'off-ish' }), /useDigits must be a boolean/, ); assert.throws( () => generateRandom({ length: 12, requireEach: 'sure' }), /requireEach must be a boolean/, ); assert.throws( () => generateRandom({ length: 12, excludeAmbiguous: 2 }), /excludeAmbiguous must be a boolean/, ); }); test('a falsy string really disables the set', () => { const value = generateRandom({ length: 24, useLower: 'no', useUpper: false, useDigits: true, useSymbols: false, }); assert.equal(value.length, 24); assert.ok(!LOWER.test(value), 'expected no lowercase characters'); assert.match(value, DIGIT); }); // --- bug 3: custom symbol sets were not validated --- test('custom symbol sets reject control characters and whitespace', () => { for (const symbols of ['a\tb', 'a\nb', 'a b', 'a\rb', 'a\u0000b', 'a\u200bb']) { assert.throws( () => generateRandom({ length: 12, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: symbols, }), /customSymbols must not contain control characters or whitespace/, `expected ${JSON.stringify(symbols)} to be rejected`, ); } }); test('custom symbol sets are limited to 64 code points', () => { const tooMany = 'xy'.repeat(MAX_CUSTOM_SYMBOLS); assert.throws( () => generateRandom({ length: 12, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: tooMany, }), /customSymbols must contain at most 64 characters/, ); const exactly = 'x'.repeat(MAX_CUSTOM_SYMBOLS); const value = generateRandom({ length: 12, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: exactly, requireEach: true, }); assert.equal(value.length, 12); }); test('custom symbol sets are de-duplicated', () => { const value = generateRandom({ length: 40, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '@#@#@@', requireEach: true, }); assert.equal(Array.from(value).length, 40); for (const codePoint of Array.from(value)) { assert.ok(['@', '#'].includes(codePoint), `unexpected code point ${codePoint}`); } }); // --- bug 4: random mode entropy ignored requireEach --- test('random entropy with requireEach counts only passwords with every class', () => { const options = { length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: true, requireEach: true, }; const constrained = entropyForRandom(options); const unconstrained = entropyForRandom({ ...options, requireEach: false }); assert.ok( constrained < unconstrained, `expected ${constrained} to be below the unconstrained ${unconstrained}`, ); // Closed form for "at least one character of each enabled class", spelled // out here independently of the implementation. The pool holds 87 // characters (26 lowercase, 26 uppercase, 10 digits, 25 symbols), so the // number of strings of length L that touch every class is // 87^L // - (2*61^L + 77^L + 62^L) missing one class // + (35^L + 2*51^L + 2*36^L + 52^L) missing two classes // - (25^L + 2*10^L + 2*26^L) missing three classes const L = 16; const count = 87 ** L - (2 * 61 ** L + 77 ** L + 62 ** L) + (35 ** L + 2 * 51 ** L + 2 * 36 ** L + 52 ** L) - (25 ** L + 2 * 10 ** L + 2 * 26 ** L); const expected = Math.log2(count); assert.ok(Math.abs(constrained - expected) < 0.2, `expected ~${expected.toFixed(1)}, got ${constrained}`); // The pool really is that big, and the unconstrained value is larger. assert.equal(LOWER_CHARS.length, 26); assert.equal(UPPER_CHARS.length, 26); assert.equal(DIGIT_CHARS.length, 10); assert.equal(DEFAULT_SYMBOL_CHARS.length, 25); }); test('random entropy with requireEach matches the closed form for two and three classes', () => { const two = entropyForRandom({ length: 16, useLower: true, useUpper: true, useDigits: false, useSymbols: false, requireEach: true, }); // N = 52, classes of 26: 52^L - 2*26^L (the empty intersection contributes 0). const expectedTwo = Math.log2(52 ** 16 - 2 * 26 ** 16); assert.ok(Math.abs(two - expectedTwo) < 0.2, `expected ~${expectedTwo.toFixed(1)}, got ${two}`); const three = entropyForRandom({ length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: false, requireEach: true, }); // N = 62, classes 26/26/10. const expectedThree = Math.log2( 62 ** 16 - (2 * 36 ** 16 + 52 ** 16) + (10 ** 16 + 2 * 26 ** 16), ); assert.ok(Math.abs(three - expectedThree) < 0.2, `expected ~${expectedThree.toFixed(1)}, got ${three}`); }); test('entropy is flagged as approximate only when requireEach can bite', () => { const many = { length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: true, }; assert.equal(entropyIsApproximate('random', { ...many, requireEach: true }), true); assert.equal(entropyIsApproximate('random', { ...many, requireEach: false }), false); assert.equal( entropyIsApproximate('random', { length: 16, useLower: true, useUpper: false, useDigits: false, useSymbols: false, requireEach: true }), false, 'a single class makes requireEach a no-op', ); assert.equal(entropyIsApproximate('memorable', { words: 4, digits: 2 }), false); });