Files
EpicNext-Cms/src/lib/auth/password.test.ts
T
2026-07-28 21:26:31 +02:00

115 lines
3.4 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { argon2id } from "hash-wasm";
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 bcrypt hash and round-trips", async () => {
mockEnv.PASSWORD_HASH = undefined;
const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$2y\$\d{2}\$/);
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("verifyPassword", () => {
it("verifies argon2id hashes", async () => {
const h = await argon2id({
password: "hunter2",
salt: randomBytes(16),
outputType: "encoded",
parallelism: 1,
iterations: 4,
memorySize: 1024,
hashLength: 32,
});
expect(await verifyPassword("hunter2", h)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false);
});
it("verifies legacy bcrypt hashes ($2y$)", async () => {
mockEnv.PASSWORD_HASH = undefined;
const h = await hashPassword("hunter2");
expect(h).toMatch(/^\$2y\$/);
expect(await verifyPassword("hunter2", h)).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 bcrypt 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(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();
});
});