// @vitest-environment node import argon2 from "argon2"; import bcryptjs from "bcryptjs"; import { describe, expect, test, vi } from "vitest"; import { hashPassword, isKnownHashFormat, verifyPassword } from "./passwordHasher.server.js"; const PLAINTEXT = "correct-horse-battery-staple"; const WRONG = "incorrect-zebra-cell-paperclip"; describe("passwordHasher.server", () => { test("hashPassword produces an argon2id hash that round-trips", async () => { const hash = await hashPassword(PLAINTEXT); expect(hash.startsWith("$argon2id$")).toBe(true); expect(await argon2.verify(hash, PLAINTEXT)).toBe(true); }); test("verifyPassword accepts a valid argon2 hash without proposing a rehash", async () => { const hash = await hashPassword(PLAINTEXT); const result = await verifyPassword(PLAINTEXT, hash); expect(result.valid).toBe(true); expect(result.rehashedHash).toBeUndefined(); }); test("verifyPassword rejects a wrong password against an argon2 hash", async () => { const hash = await hashPassword(PLAINTEXT); const result = await verifyPassword(WRONG, hash); expect(result.valid).toBe(false); expect(result.rehashedHash).toBeUndefined(); }); test("verifyPassword accepts a valid bcrypt hash AND proposes an argon2 rehash", async () => { const legacyHash = await bcryptjs.hash(PLAINTEXT, 4); expect(legacyHash.startsWith("$2")).toBe(true); const result = await verifyPassword(PLAINTEXT, legacyHash); expect(result.valid).toBe(true); expect(result.rehashedHash).toBeDefined(); expect(result.rehashedHash!.startsWith("$argon2id$")).toBe(true); // The proposed rehash must itself verify the same plaintext. expect(await argon2.verify(result.rehashedHash!, PLAINTEXT)).toBe(true); }); test("verifyPassword rejects a wrong password against a bcrypt hash and does NOT propose a rehash", async () => { const legacyHash = await bcryptjs.hash(PLAINTEXT, 4); const result = await verifyPassword(WRONG, legacyHash); expect(result.valid).toBe(false); expect(result.rehashedHash).toBeUndefined(); }); test("verifyPassword rejects unknown / empty hashes without throwing", async () => { await expect(verifyPassword(PLAINTEXT, "")).resolves.toEqual({ valid: false }); await expect(verifyPassword(PLAINTEXT, "garbage")).resolves.toEqual({ valid: false }); await expect(verifyPassword(PLAINTEXT, "$pbkdf2-sha256$i=29000,l=32$abc$def")).resolves.toEqual({ valid: false }); }); test("verifyPassword swallows decryption errors and reports invalid", async () => { // An argon2-prefixed but structurally invalid hash makes argon2.verify throw. const result = await verifyPassword(PLAINTEXT, "$argon2id$v=19$m=19456,t=2,p=1$THISisNOTaValidSalt$norIsThisAValidHash"); expect(result.valid).toBe(false); }); test("verifyPassword reports invalid when argon2 verification throws", async () => { const verifySpy = vi.spyOn(argon2, "verify").mockRejectedValueOnce(new Error("argon2 unavailable")); await expect(verifyPassword(PLAINTEXT, "$argon2id$valid-looking-prefix")).resolves.toEqual({ valid: false }); expect(verifySpy).toHaveBeenCalled(); verifySpy.mockRestore(); }); test("verifyPassword reports invalid when legacy bcrypt comparison throws", async () => { const compareSpy = vi.spyOn(bcryptjs, "compare").mockRejectedValueOnce(new Error("bcrypt unavailable")); const legacyHash = "$2b$10$abcdefghijklmnopqrstuu5sBKjRarAkPvaemc5yQMk3QHsOJXxUy"; await expect(verifyPassword(PLAINTEXT, legacyHash)).resolves.toEqual({ valid: false }); expect(compareSpy).toHaveBeenCalledWith(PLAINTEXT, legacyHash); compareSpy.mockRestore(); }); test("isKnownHashFormat recognises argon2, bcrypt, and unknown shapes", async () => { expect(isKnownHashFormat(await hashPassword(PLAINTEXT))).toBe("argon2"); expect(isKnownHashFormat(await bcryptjs.hash(PLAINTEXT, 4))).toBe("bcrypt"); expect(isKnownHashFormat("$2a$10$abc")).toBe("bcrypt"); expect(isKnownHashFormat("$2y$12$abc")).toBe("bcrypt"); expect(isKnownHashFormat("$argon2i$v=19$...")).toBe("argon2"); expect(isKnownHashFormat("plaintext")).toBe("unknown"); expect(isKnownHashFormat("")).toBe("unknown"); }); });