From 9a989940a2243301eeb2a9dc12adc527b88b0e87 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:23:32 -0700 Subject: [PATCH] fix(isStrongPassword): count repeated supplementary characters correctly Count repeated code-point occurrences from the existing character map instead of mixing UTF-16 length with unique code-point counts. Preserve boolean validation and minimum-length behavior. AI assistance: OpenAI Codex. --- src/lib/isStrongPassword.js | 4 ++- test/sanitizers.test.js | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/lib/isStrongPassword.js b/src/lib/isStrongPassword.js index 8fe9223b7..7f506d687 100644 --- a/src/lib/isStrongPassword.js +++ b/src/lib/isStrongPassword.js @@ -43,12 +43,14 @@ function analyzePassword(password) { let analysis = { length: password.length, uniqueChars: Object.keys(charMap).length, + repeatedChars: 0, uppercaseCount: 0, lowercaseCount: 0, numberCount: 0, symbolCount: 0, }; Object.keys(charMap).forEach((char) => { + analysis.repeatedChars += charMap[char] - 1; /* istanbul ignore else */ if (upperCaseRegex.test(char)) { analysis.uppercaseCount += charMap[char]; @@ -66,7 +68,7 @@ function analyzePassword(password) { function scorePassword(analysis, scoringOptions) { let points = 0; points += analysis.uniqueChars * scoringOptions.pointsPerUnique; - points += (analysis.length - analysis.uniqueChars) * scoringOptions.pointsPerRepeat; + points += analysis.repeatedChars * scoringOptions.pointsPerRepeat; if (analysis.lowercaseCount > 0) { points += scoringOptions.pointsForContainingLower; } diff --git a/test/sanitizers.test.js b/test/sanitizers.test.js index 5f6454afb..6c2176e96 100644 --- a/test/sanitizers.test.js +++ b/test/sanitizers.test.js @@ -283,6 +283,66 @@ describe('Sanitizers', () => { }); }); + it('should score passwords with unique supplementary characters', () => { + test({ + sanitizer: 'isStrongPassword', + args: [{ returnScore: true }], + expect: { + '😀': 1, + '😀😁': 2, + 'Aa1!😀': 45, + }, + }); + }); + + it('should score passwords with repeated supplementary characters', () => { + test({ + sanitizer: 'isStrongPassword', + args: [{ returnScore: true }], + expect: { + '😀😀': 1.5, + '😀😁😀': 2.5, + 'Aa1!😀😀': 45.5, + }, + }); + }); + + it('should score passwords with custom supplementary character points', () => { + test({ + sanitizer: 'isStrongPassword', + args: [{ + returnScore: true, + pointsPerUnique: 3, + pointsPerRepeat: 2, + pointsForContainingLower: 7, + pointsForContainingUpper: 11, + pointsForContainingNumber: 13, + pointsForContainingSymbol: 17, + }], + expect: { + '😀': 3, + '😀😀': 5, + '😀😁😀': 8, + 'Aa1!😀😀': 65, + }, + }); + }); + + it('should preserve password scoring for BMP characters and lone surrogates', () => { + test({ + sanitizer: 'isStrongPassword', + args: [{ returnScore: true }], + expect: { + '': 0, + é: 1, + éé: 1.5, + '\uD83D': 1, + '\uDE00': 1, + '\uD83D\uD83D': 1.5, + }, + }); + }); + it('should score passwords with default options', () => { test({ sanitizer: 'isStrongPassword',