Switches to Uint8 rejection sampling against the largest multiple of the charset length that fits in a byte. Eliminates the ~16 ppm overweight the previous `% N` over Uint32 would otherwise leave on the first 38 chars. Adds a vitest distribution check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
55 lines
1.9 KiB
TypeScript
55 lines
1.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { generatePassword } from './password-gen';
|
||
|
||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||
|
||
describe('generatePassword', () => {
|
||
it('rejects lengths under 8', () => {
|
||
expect(() => generatePassword(7)).toThrowError(/at least 8/);
|
||
});
|
||
|
||
it('respects the requested length', () => {
|
||
for (const len of [8, 16, 32, 64]) {
|
||
expect(generatePassword(len)).toHaveLength(len);
|
||
}
|
||
});
|
||
|
||
it('uses only characters from the documented charset', () => {
|
||
const set = new Set(CHARSET);
|
||
for (let i = 0; i < 1000; i++) {
|
||
for (const c of generatePassword(32)) {
|
||
expect(set.has(c)).toBe(true);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Rejection-sampling sanity. With N = 71 the expected count per
|
||
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
|
||
// any byte-level bias (biased modulo would push the first 38
|
||
// chars by ~16 ppm — too small for this band to flag on its
|
||
// own, but a regression to `% N` over Uint16/Uint32 with a
|
||
// non-power-of-two charset would still produce visible drift in
|
||
// pathological codepaths). Mostly this guards against
|
||
// fundamental mistakes (off-by-one in the loop, returning the
|
||
// same byte stream every time, etc.).
|
||
it('distribution stays within a wide tolerance band', () => {
|
||
const samples = 100_000;
|
||
const counts = new Map<string, number>();
|
||
for (let i = 0; i < samples; i++) {
|
||
const c = generatePassword(8)[0];
|
||
counts.set(c, (counts.get(c) ?? 0) + 1);
|
||
}
|
||
const expected = samples / CHARSET.length;
|
||
const sigma = Math.sqrt(expected);
|
||
const band = 6 * sigma;
|
||
for (const c of CHARSET) {
|
||
const observed = counts.get(c) ?? 0;
|
||
const drift = Math.abs(observed - expected);
|
||
expect(
|
||
drift,
|
||
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
|
||
).toBeLessThan(band);
|
||
}
|
||
});
|
||
});
|