Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/password-policy.server.test.ts
T

215 lines
9.1 KiB
TypeScript

// @vitest-environment node
import { createHash } from "node:crypto";
import { describe, expect, test, vi } from "vitest";
import {
assertPasswordPolicy,
isPasswordPwned,
loadPasswordPolicy,
validatePassword,
validatePasswordPolicy,
} from "./password-policy.server.js";
function sha1Upper(value: string): string {
return createHash("sha1").update(value).digest("hex").toUpperCase();
}
function hibpFetcherFor(pwnedPasswords: Map<string, number>): typeof fetch {
return (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const prefix = url.slice(url.lastIndexOf("/") + 1).toUpperCase();
const matches: string[] = [];
for (const [password, count] of pwnedPasswords) {
const hash = sha1Upper(password);
if (hash.startsWith(prefix)) {
matches.push(`${hash.slice(5)}:${count}`);
}
}
matches.push("0".repeat(35) + ":0"); // padding row
return new Response(matches.join("\n"), { status: 200 });
}) as unknown as typeof fetch;
}
const ALL_FLAGS_OFF = {
minLength: 1,
requireUpper: false,
requireLower: false,
requireDigit: false,
requireSpecial: false,
hibpCheck: false,
hibpMaxOccurrences: 0,
};
describe("validatePassword", () => {
test("rejects empty input with code 'empty'", async () => {
const result = await validatePassword("", {}, { policy: ALL_FLAGS_OFF });
expect(result.valid).toBe(false);
expect(result.errors).toEqual([{ code: "auth.password.errors.empty" }]);
});
test("enforces minLength at the boundary", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 12 };
expect((await validatePassword("12345678901", {}, { policy })).valid).toBe(false);
expect((await validatePassword("123456789012", {}, { policy })).valid).toBe(true);
});
test("flags missingUpper / missingLower / missingDigit / missingSpecial independently", async () => {
const policy = { ...ALL_FLAGS_OFF, requireUpper: true, requireLower: true, requireDigit: true, requireSpecial: true };
const result = await validatePassword("abcdef", {}, { policy });
const codes = result.errors.map((e) => e.code);
expect(codes).toContain("auth.password.errors.missingUpper");
expect(codes).toContain("auth.password.errors.missingDigit");
expect(codes).toContain("auth.password.errors.missingSpecial");
expect(codes).not.toContain("auth.password.errors.missingLower");
});
test("accepts a password that meets every active rule", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 12, requireUpper: true, requireLower: true, requireDigit: true, requireSpecial: true };
const result = await validatePassword("Strong-Pass1word!", {}, { policy });
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
test("rejects passwords that contain the email local-part", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
const result = await validatePassword("grace1234567890", { email: "grace@example.com" }, { policy });
expect(result.valid).toBe(false);
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.containsEmail");
});
test("ignores email local-parts shorter than 3 chars to avoid false positives", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
const result = await validatePassword("any-password-here", { email: "x@example.com" }, { policy });
expect(result.errors.map((e) => e.code)).not.toContain("auth.password.errors.containsEmail");
});
test("HIBP-pwned password is rejected", async () => {
const pwnedPasswords = new Map<string, number>([["Password123!", 12345]]);
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
const result = await validatePassword("Password123!", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
expect(result.valid).toBe(false);
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.pwned");
});
test("HIBP-clean password passes", async () => {
const pwnedPasswords = new Map<string, number>();
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
const result = await validatePassword("Never-Pwned-XyZ-12345", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
expect(result.valid).toBe(true);
expect(result.hibpBypassed).toBeUndefined();
});
test("HIBP-pwned but under hibpMaxOccurrences is accepted", async () => {
const pwnedPasswords = new Map<string, number>([["RarelyLeaked-XyZ", 1]]);
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true, hibpMaxOccurrences: 5 };
const result = await validatePassword("RarelyLeaked-XyZ", {}, { policy, fetcher: hibpFetcherFor(pwnedPasswords) });
expect(result.valid).toBe(true);
});
test("HIBP-5xx fails open with hibpBypassed=true", async () => {
const fetcher = (async () => new Response("", { status: 503 })) as unknown as typeof fetch;
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
const result = await validatePassword("Long-Enough-Password", {}, { policy, fetcher });
expect(result.valid).toBe(true);
expect(result.hibpBypassed).toBe(true);
});
test("HIBP-network-error fails open with hibpBypassed=true", async () => {
const fetcher = vi.fn(async () => {
throw new Error("ENOTFOUND api.pwnedpasswords.com");
}) as unknown as typeof fetch;
const policy = { ...ALL_FLAGS_OFF, hibpCheck: true };
const result = await validatePassword("Long-Enough-Password", {}, { policy, fetcher });
expect(result.valid).toBe(true);
expect(result.hibpBypassed).toBe(true);
});
test("Unicode passwords are length-counted by JS string length (sufficient for the policy)", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 8 };
const result = await validatePassword("Pässwört✓123", {}, { policy });
expect(result.valid).toBe(true);
});
test("absurdly long passwords are rejected by the ABSOLUTE_MAX_LENGTH guard", async () => {
const policy = { ...ALL_FLAGS_OFF, minLength: 1 };
const result = await validatePassword("a".repeat(2048), {}, { policy });
expect(result.valid).toBe(false);
expect(result.errors.map((e) => e.code)).toContain("auth.password.errors.tooLong");
});
});
describe("loadPasswordPolicy", () => {
test("uses OWASP-aligned defaults when env is empty", () => {
const policy = loadPasswordPolicy({});
expect(policy.minLength).toBe(12);
expect(policy.requireUpper).toBe(true);
expect(policy.requireLower).toBe(true);
expect(policy.requireDigit).toBe(true);
expect(policy.requireSpecial).toBe(false);
expect(policy.hibpCheck).toBe(true);
expect(policy.hibpMaxOccurrences).toBe(0);
});
test("reads overrides from env vars", () => {
const policy = loadPasswordPolicy({
AUTH_PASSWORD_MIN_LENGTH: "16",
AUTH_PASSWORD_REQUIRE_UPPER: "false",
AUTH_PASSWORD_REQUIRE_DIGIT: "no",
AUTH_PASSWORD_REQUIRE_SPECIAL: "true",
AUTH_PASSWORD_HIBP_CHECK: "0",
AUTH_PASSWORD_HIBP_MAX_OCCURRENCES: "10",
});
expect(policy.minLength).toBe(16);
expect(policy.requireUpper).toBe(false);
expect(policy.requireLower).toBe(true);
expect(policy.requireDigit).toBe(false);
expect(policy.requireSpecial).toBe(true);
expect(policy.hibpCheck).toBe(false);
expect(policy.hibpMaxOccurrences).toBe(10);
});
test("invalid env values fall back to defaults", () => {
const policy = loadPasswordPolicy({
AUTH_PASSWORD_MIN_LENGTH: "garbage",
AUTH_PASSWORD_REQUIRE_UPPER: "maybe",
});
expect(policy.minLength).toBe(12);
expect(policy.requireUpper).toBe(true);
});
});
describe("prototype compatibility shims", () => {
test("isPasswordPwned returns true only when HIBP reports the password above the threshold", async () => {
const fetcher = hibpFetcherFor(new Map<string, number>([["Password123!", 12345]]));
await expect(isPasswordPwned("Password123!", fetcher)).resolves.toBe(true);
await expect(isPasswordPwned("Never-Pwned-XyZ-12345", fetcher)).resolves.toBe(false);
});
test("validatePasswordPolicy maps canonical errors to legacy issue names", async () => {
const fetcher = hibpFetcherFor(new Map<string, number>([["short", 99]]));
await expect(validatePasswordPolicy("short", { fetchImpl: fetcher })).resolves.toEqual({
ok: false,
issues: ["too-short", "pwned"],
});
});
test("validatePasswordPolicy reports unavailable HIBP checks as a legacy issue", async () => {
const fetcher = vi.fn(async () => {
throw new Error("network down");
}) as unknown as typeof fetch;
await expect(validatePasswordPolicy("Long-Enough-Password", { fetchImpl: fetcher })).resolves.toEqual({
ok: false,
issues: ["pwned-check-unavailable"],
});
});
test("assertPasswordPolicy resolves successful checks and throws with legacy issues on failure", async () => {
const cleanFetcher = hibpFetcherFor(new Map());
await expect(assertPasswordPolicy("Strong-Enough-Password1", { fetchImpl: cleanFetcher })).resolves.toEqual({
ok: true,
issues: [],
});
await expect(assertPasswordPolicy("short", { fetchImpl: cleanFetcher })).rejects.toThrow(/too-short/u);
});
});