/** * 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$$ (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); }); });