import { hash as bcryptHash } from "bcrypt"; import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ PASSWORD_HASH: undefined as string | undefined, ARGON2_PARALLELISM: 1, ARGON2_ITERATIONS: 4, ARGON2_MEMORY_SIZE: 65536, BCRYPT_ROUNDS: 12, })); vi.mock("@/env", () => ({ env: mockEnv, })); import { checkLogin, hashPassword, 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 (default driver: bcrypt)", () => { it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => { mockEnv.PASSWORD_HASH = undefined; const h = await hashPassword("s3cret!"); expect(h).toMatch(/^\$2y\$/); expect(h.length).toBeLessThanOrEqual(60); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); describe("hashPassword (PASSWORD_HASH=argon2id)", () => { it("hashes with the AtomCMS params and round-trips", async () => { mockEnv.PASSWORD_HASH = "argon2id"; mockEnv.ARGON2_MEMORY_SIZE = 1024; mockEnv.ARGON2_ITERATIONS = 1; const h = await hashPassword("s3cret!"); expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); describe("bcrypt", () => { it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => { mockEnv.BCRYPT_ROUNDS = 4; const h = await bcryptHash("hunter2", 4); expect(await verifyPassword("hunter2", h)).toBe(true); const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$"); expect(await verifyPassword("hunter2", phpStyle)).toBe(true); expect(await verifyPassword("nope", h)).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("checkLogin", () => { it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => { mockEnv.PASSWORD_HASH = undefined; const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toMatch(/^\$2y\$/); expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60); 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("validates an existing modern hash with no upgrade", async () => { mockEnv.PASSWORD_HASH = undefined; const stored = await hashPassword("modern"); const res = await checkLogin("modern", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toBeUndefined(); }); });