Files
crumbs-original/src/lib/utils/password.test.ts
T
Frederic CabassutandClaude Opus 5 51dd844f96 feat(wu-001): add password generator and reset-eligibility utilities
Groundwork for issue #77 (admin cannot reset other users' passwords).
Two pure, colocated-tested modules shared by the API route and the
settings UI that follow.

password.ts — generatePassword() over a charset pinned at exactly 64
characters. 64 is a power of two, so nanoid's customAlphabet takes its
exactly-uniform masking path (256 % 64 === 0) rather than the rejection
branch; 120 bits of entropy at the default length of 20. Visually
ambiguous characters (0 O o 1 l I) are excluded so a password survives
being read aloud or retyped. Class coverage is guaranteed by discarding
and regenerating a whole password that is missing a group, which stays
uniform over exactly the set of valid passwords — rather than forcing
characters into positions and shuffling, which would need its own
unbiased index source.

auth-methods.ts — canResetPassword() and passwordResetBlockedReason(),
the single source of truth for eligibility so the endpoint and the
component cannot drift. Password login is gated on authProvider
(verifyPassword matches only rows where it equals 'password'), so
writing a hash for any other provider stores a credential that can
never be used to sign in.

The reason lookup is a Map, not an object literal: an object inherits
Object.prototype, so a lookup for 'toString' or '__proto__' resolves to
an inherited member and the nullish fallback never fires — returning a
function where the signature promises string | null. A Map has no such
keys, so totality holds by construction instead of by a guard a later
refactor could drop.

DoD items verified:
- [x] 1. generatePassword() returns 20 chars; explicit length honoured
- [x] 2. always >=1 lowercase, uppercase, digit and symbol
- [x] 3. never contains 0 O o 1 l I
- [x] 4. randomness from nanoid customAlphabet; no Math.random
- [x] 5. throws for length < 8 (the server minimum)
- [x] 6. retry bounded at 100 with an unconditional throw (inspection)
- [x] 7. canResetPassword true only for 'password'
- [x] 8. totality: every blocked input yields a non-empty string
- [x] 9. exact reason copy per provider; null when allowed
- [x] 10. colocated tests pass

Verified independently by the orchestrator: pnpm check 0 errors /
14 pre-existing warnings; pnpm test:unit 306 passed / 26 files (from
288 / 24); coverage 45.52 lines, 45.52 statements, 54.01 functions,
84.87 branches — every metric above both its .coverage-thresholds.json
floor and the previous baseline, new files at 100%.

Reviewed-by: adversarial-review (PASS, second reviewer; first found a
prototype-chain totality violation, since fixed and re-reviewed fresh)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbqJcFofZSbhbGwUwZP3wU
2026-08-18 12:31:12 +02:00

85 lines
3.0 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
const DIGIT = /[23456789]/;
const SYMBOL = /[!@#$%^&*]/;
/** Visually ambiguous characters the charset deliberately excludes. */
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
describe('PASSWORD_ALPHABET', () => {
it('is exactly 64 distinct characters', () => {
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
// A charset edit that breaks this silently biases every password.
expect(PASSWORD_ALPHABET).toHaveLength(64);
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
});
it('excludes every visually ambiguous character', () => {
for (const char of AMBIGUOUS) {
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
}
});
});
describe('generatePassword', () => {
it('returns a 20-character password by default', () => {
expect(generatePassword()).toHaveLength(20);
});
it('honours an explicit length', () => {
expect(generatePassword(8)).toHaveLength(8);
expect(generatePassword(32)).toHaveLength(32);
expect(generatePassword(64)).toHaveLength(64);
});
it('always includes at least one character from each of the four groups', () => {
// Not a statistical bet: the function only ever returns a class-complete
// password or throws, so every sample must pass.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
expect(password, `sample ${i}`).toMatch(LOWERCASE);
expect(password, `sample ${i}`).toMatch(UPPERCASE);
expect(password, `sample ${i}`).toMatch(DIGIT);
expect(password, `sample ${i}`).toMatch(SYMBOL);
}
});
it('never emits a visually ambiguous character', () => {
// 0 O o 1 l I survive neither being read aloud nor retyped.
for (let i = 0; i < 20; i++) {
const password = generatePassword();
for (const char of AMBIGUOUS) {
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
}
}
});
it('draws only from the pinned 64-character charset', () => {
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
for (let i = 0; i < 20; i++) {
expect(generatePassword(), `sample ${i}`).toMatch(charset);
}
});
it('produces a different password on each call', () => {
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
expect(passwords.size).toBe(20);
});
it('throws below the 8-character server minimum', () => {
expect(() => generatePassword(7)).toThrow(/8/);
expect(() => generatePassword(0)).toThrow(/8/);
expect(() => generatePassword(-1)).toThrow(/8/);
});
it('does not use Math.random anywhere in the module', () => {
// Randomness must come from nanoid's CSPRNG, not Math.random.
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
expect(source).not.toContain('Math.random');
});
});