Files
PiCloud/dashboard/src/lib/password-gen.test.ts
MechaCat02 70b66451d6 fix(dashboard): rejection-sample password-gen to remove modulo bias
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>
2026-05-28 19:37:18 +02:00

55 lines
1.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
});
});