import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ BCRYPT_COST: 12, })); vi.mock("@/env", () => ({ env: mockEnv, })); import { checkLogin, hashPassword, isArgon2idOf, isBcryptOf, isMd5Of, md5Hex, verifyPassword, } from "./password"; describe("md5Hex", () => { it("matches PHP md5() on canonical vectors", async () => { expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e"); expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72"); }); }); describe("hashPassword", () => { it("emits a bcrypt hash and round-trips", async () => { const h = await hashPassword("s3cret!"); expect(h).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); describe("isArgon2idOf", () => { it("verifies a legacy argon2id hash (pre-migration accounts)", async () => { const stored = "$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0"; expect(await isArgon2idOf("test-password-123", stored)).toBe(true); expect(await isArgon2idOf("wrong", stored)).toBe(false); expect(await isArgon2idOf("anything", "$2y$12$ABC")).toBe(false); }); }); describe("isBcryptOf", () => { it("verifies a bcrypt hash", async () => { const { bcrypt } = await import("hash-wasm"); const { randomBytes } = await import("node:crypto"); const stored = await bcrypt({ password: "hunter2", salt: randomBytes(16), costFactor: 10, outputType: "encoded", }); expect(await isBcryptOf("hunter2", stored)).toBe(true); expect(await isBcryptOf("wrong", stored)).toBe(false); expect(await isBcryptOf("anything", "$argon2id$v=19$")).toBe(false); }); }); describe("isMd5Of", () => { it("detects a legacy md5 password", async () => { expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true); expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false); expect(await isMd5Of("habbo", "not-a-hash")).toBe(false); }); }); describe("verifyPassword", () => { it("verifies bcrypt hashes", async () => { const h = await hashPassword("hunter2"); expect(h).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("nope", h)).toBe(false); }); }); describe("checkLogin", () => { it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); }); it("does NOT upgrade md5 when conversion is disabled", async () => { const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: false, }); expect(res.valid).toBe(false); expect(res.upgradedHash).toBeUndefined(); }); it("migrates a legacy argon2id hash to bcrypt", async () => { const stored = "$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0"; const res = await checkLogin("test-password-123", stored, { convertPasswords: true, }); expect(res.valid).toBe(true); expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); }); it("accepts an existing bcrypt hash with no rehash", async () => { const { bcrypt } = await import("hash-wasm"); const { randomBytes } = await import("node:crypto"); const stored = await bcrypt({ password: "modern", salt: randomBytes(16), costFactor: 10, outputType: "encoded", }); const res = await checkLogin("modern", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toBeUndefined(); }); });