97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
/**
|
|
* localCredentials.ts — unit tests for hashPassword / verifyPassword.
|
|
*
|
|
* Uses node:crypto scrypt under the hood; no external dependencies.
|
|
* All tests run without MariaDB or any external service.
|
|
*
|
|
* WR-03: hashPassword / verifyPassword are now async (promisify(scrypt), threadpool) —
|
|
* all assertions await them.
|
|
*
|
|
* Test suite (TDD RED → GREEN — Plan 19-01 Task 1):
|
|
* Test 1: correct password verifies true
|
|
* Test 2: wrong password verifies false
|
|
* Test 3: two hashes of the same input produce different encoded strings (unique salt)
|
|
* Test 4: verifyPassword never throws on a malformed hash (returns false)
|
|
* Test 5: encoded string has the PHC shape: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (6 segments)
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { scryptSync, randomBytes } from 'node:crypto';
|
|
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
|
|
|
|
describe('hashPassword / verifyPassword', () => {
|
|
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', async () => {
|
|
const encoded = await hashPassword('hunter2');
|
|
const result = await verifyPassword(encoded, 'hunter2');
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', async () => {
|
|
const encoded = await hashPassword('hunter2');
|
|
const result = await verifyPassword(encoded, 'wrong-password');
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', async () => {
|
|
const encoded1 = await hashPassword('x');
|
|
const encoded2 = await hashPassword('x');
|
|
expect(encoded1).not.toBe(encoded2);
|
|
});
|
|
|
|
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', async () => {
|
|
await expect(verifyPassword('not-a-valid-hash', 'x')).resolves.toBe(false);
|
|
await expect(verifyPassword('', 'x')).resolves.toBe(false);
|
|
await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false);
|
|
});
|
|
|
|
it('Test 6 (IN-02): a hash produced by the INLINED scrypt parameters round-trips against the canonical verifyPassword', async () => {
|
|
// IN-02: the PHC scrypt hash is copy-pasted in three places — the canonical module
|
|
// (src/auth/localCredentials.ts), the break-glass CLI (scripts/reset-admin.ts), and the
|
|
// CI seed step (.gitea/workflows/ci.yml). If the parameters ever drift, those inlined
|
|
// copies would produce hashes the canonical verifyPassword cannot validate, silently
|
|
// breaking login for seeded/reset accounts. This test pins the inlined parameter set:
|
|
// if anyone changes N/r/p/KEY_LEN in the canonical module without updating the inlined
|
|
// copies (or vice versa), this round-trip fails loudly in CI.
|
|
//
|
|
// These constants MUST match scripts/reset-admin.ts and .gitea/workflows/ci.yml exactly.
|
|
const INLINE_N = 16384;
|
|
const INLINE_R = 8;
|
|
const INLINE_P = 1;
|
|
const INLINE_KEY_LEN = 32;
|
|
|
|
const password = 'inline-roundtrip-pw';
|
|
const salt = randomBytes(16);
|
|
const hash = scryptSync(password, salt, INLINE_KEY_LEN, {
|
|
N: INLINE_N,
|
|
r: INLINE_R,
|
|
p: INLINE_P,
|
|
});
|
|
const inlineEncoded = [
|
|
'scrypt',
|
|
INLINE_N,
|
|
INLINE_R,
|
|
INLINE_P,
|
|
salt.toString('base64url'),
|
|
hash.toString('base64url'),
|
|
].join('$');
|
|
|
|
// The canonical verifyPassword must validate a hash produced by the inlined params.
|
|
expect(await verifyPassword(inlineEncoded, password)).toBe(true);
|
|
expect(await verifyPassword(inlineEncoded, 'wrong')).toBe(false);
|
|
});
|
|
|
|
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => {
|
|
const encoded = await hashPassword('testpassword');
|
|
const segments = encoded.split('$');
|
|
expect(segments).toHaveLength(6);
|
|
expect(segments[0]).toBe('scrypt');
|
|
// N, r, p are numeric
|
|
expect(Number(segments[1])).toBeGreaterThan(0); // N
|
|
expect(Number(segments[2])).toBeGreaterThan(0); // r
|
|
expect(Number(segments[3])).toBeGreaterThan(0); // p
|
|
// salt and hash are non-empty base64url strings
|
|
expect(segments[4].length).toBeGreaterThan(0);
|
|
expect(segments[5].length).toBeGreaterThan(0);
|
|
});
|
|
});
|