/** * Auth Security Suite * * Covers the rules that keep one person from holding twenty accounts: email * alias normalisation, disposable-domain rejection, password hashing, and * single-use token handling. Pure logic only — no database required. */ import { describe, test, expect } from "./runner"; import { canonicaliseEmail, isDisposableEmail, normaliseEmail, splitEmail, } from "../../src/lib/auth/email"; import { equaliseTiming, hashPassword, verifyPassword } from "../../src/lib/auth/password"; import { hashIp, hashToken, issueToken, newId } from "../../src/lib/auth/tokens"; import { MIN_PASSWORD_LENGTH, scorePassword, validateEmail, validatePassword, } from "../../src/lib/auth/validation"; import { authErrorMessage, isAuthErrorCode } from "../../src/lib/auth/errors"; import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS } from "../../src/lib/auth/config"; describe("Auth — email normalisation (one inbox, one account)", () => { test("Gmail dots are cosmetic and collapse onto the same key", () => { expect(normaliseEmail("t.i.m.o@gmail.com")).toBe("timo@gmail.com"); expect(normaliseEmail("timo@gmail.com")).toBe("timo@gmail.com"); }); test("plus-tag aliases collapse onto the base address", () => { expect(normaliseEmail("timo+spam1@gmail.com")).toBe("timo@gmail.com"); expect(normaliseEmail("timo+spam2@gmail.com")).toBe("timo@gmail.com"); }); test("googlemail.com is an alias of gmail.com", () => { expect(normaliseEmail("timo@googlemail.com")).toBe("timo@gmail.com"); }); test("twenty Gmail variants all reduce to a single uniqueness key", () => { const variants = Array.from({ length: 20 }, (_, index) => `t.i.mo+throwaway${index}@gmail.com`); const keys = new Set(variants.map((variant) => normaliseEmail(variant))); expect(keys.size).toBe(1); expect([...keys][0]).toBe("timo@gmail.com"); }); test("plus-tags are stripped on non-Gmail providers too", () => { expect(normaliseEmail("bob+newsletter@outlook.com")).toBe("bob@outlook.com"); }); test("dots are preserved outside Gmail — they are significant there", () => { expect(normaliseEmail("first.last@company.de")).toBe("first.last@company.de"); }); test("normalisation is case-insensitive", () => { expect(normaliseEmail("Timo@Example.COM")).toBe("timo@example.com"); expect(canonicaliseEmail(" Timo@Example.COM ")).toBe("timo@example.com"); }); test("addresses with multiple @ use the last one as the separator", () => { expect(splitEmail("weird\"@\"name@example.com")?.domain).toBe("example.com"); }); test("malformed input yields no key rather than a bogus one", () => { expect(normaliseEmail("not-an-email")).toBe(null); expect(normaliseEmail("@example.com")).toBe(null); expect(normaliseEmail("user@")).toBe(null); expect(normaliseEmail("user@localhost")).toBe(null); expect(normaliseEmail("+tag@gmail.com")).toBe("+tag@gmail.com"); expect(normaliseEmail("")).toBe(null); }); }); describe("Auth — disposable mailbox rejection", () => { test("known throw-away providers are refused", () => { expect(isDisposableEmail("abc@mailinator.com")).toBe(true); expect(isDisposableEmail("abc@guerrillamail.com")).toBe(true); expect(isDisposableEmail("abc@yopmail.com")).toBe(true); }); test("detection ignores casing and surrounding whitespace", () => { expect(isDisposableEmail(" ABC@MAILINATOR.COM ")).toBe(true); }); test("real providers pass through", () => { expect(isDisposableEmail("timo@gmail.com")).toBe(false); expect(isDisposableEmail("timo@company.de")).toBe(false); }); test("malformed addresses are not treated as disposable", () => { expect(isDisposableEmail("nonsense")).toBe(false); }); }); describe("Auth — password hashing", () => { test("digest is self-describing and carries its cost parameters", async () => { const digest = await hashPassword("Sicher1234!"); const parts = digest.split("$"); expect(parts[0]).toBe("scrypt"); expect(parts.length).toBe(6); expect(Number(parts[1]) >= 16384).toBe(true); }); test("the plaintext never appears in the digest", async () => { const digest = await hashPassword("Sicher1234!"); expect(digest.includes("Sicher1234!")).toBe(false); }); test("the same password hashes differently every time (unique salt)", async () => { const first = await hashPassword("Sicher1234!"); const second = await hashPassword("Sicher1234!"); expect(first === second).toBe(false); }); test("verification accepts the correct password", async () => { const digest = await hashPassword("Sicher1234!"); expect(await verifyPassword("Sicher1234!", digest)).toBe(true); }); test("verification rejects a wrong password, empty input and a null digest", async () => { const digest = await hashPassword("Sicher1234!"); expect(await verifyPassword("sicher1234!", digest)).toBe(false); expect(await verifyPassword("", digest)).toBe(false); expect(await verifyPassword("Sicher1234!", null)).toBe(false); }); test("a tampered or foreign digest is rejected, never crashes", async () => { expect(await verifyPassword("Sicher1234!", "garbage")).toBe(false); expect(await verifyPassword("Sicher1234!", "scrypt$1$2$3$4$5")).toBe(false); expect(await verifyPassword("Sicher1234!", "bcrypt$16384$8$1$c2FsdA==$aGFzaA==")).toBe(false); }); test("unicode passwords survive normalisation round-trip", async () => { const digest = await hashPassword("Pässwörd-Ünïcode1!"); expect(await verifyPassword("Pässwörd-Ünïcode1!", digest)).toBe(true); }); test("the timing equaliser resolves without throwing", async () => { await equaliseTiming(); expect(true).toBe(true); }); }); describe("Auth — tokens and identifiers", () => { test("only the digest is meant for storage — it differs from the token", () => { const { token, hash } = issueToken(); expect(token === hash).toBe(false); expect(hash.length).toBe(64); }); test("hashing is deterministic, so a cookie can be looked up", () => { const { token, hash } = issueToken(); expect(hashToken(token)).toBe(hash); }); test("tokens are unique across many issuances", () => { const tokens = new Set(Array.from({ length: 500 }, () => issueToken().token)); expect(tokens.size).toBe(500); }); test("IP hashing is stable and returns null for missing addresses", () => { expect(hashIp("203.0.113.7")).toBe(hashIp("203.0.113.7")); expect(hashIp(null)).toBe(null); expect(hashIp(undefined)).toBe(null); }); test("generated ids stay inside the varchar(64) column", () => { const id = newId("usr"); expect(id.startsWith("usr_")).toBe(true); expect(id.length <= 64).toBe(true); }); }); describe("Auth — credential validation", () => { test("signup demands length plus variety", () => { expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true); expect(validatePassword("alllowercase", false, { requireStrength: true }) !== null).toBe(true); expect(validatePassword("Sicher1234", false, { requireStrength: true })).toBe(null); }); test("login accepts any non-empty password so old accounts keep working", () => { expect(validatePassword("legacy", false)).toBe(null); expect(validatePassword("", false) !== null).toBe(true); }); test("strength scoring gates on length before counting variety", () => { expect(scorePassword("Ab1!").score).toBe(1); expect(scorePassword("").score).toBe(0); expect(scorePassword("abcdefgh").score).toBe(1); expect(scorePassword("Abcdefgh").score).toBe(2); expect(scorePassword("Abcdefg1").score).toBe(3); expect(scorePassword("Abcdefg1!").score).toBe(4); }); test("the minimum length constant matches the scoring gate", () => { expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH - 1, "x")).checks.length).toBe(false); expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH, "x")).checks.length).toBe(true); }); test("email validation rejects the usual malformed shapes", () => { expect(validateEmail("timo@example.com", false)).toBe(null); expect(validateEmail("", false) !== null).toBe(true); expect(validateEmail("timo@", false) !== null).toBe(true); expect(validateEmail("timo example.com", false) !== null).toBe(true); }); }); describe("Auth — password reset link shape", () => { test("reset links live an hour, confirmation links a day", () => { expect(PASSWORD_RESET_TTL_MS).toBe(60 * 60 * 1000); expect(VERIFICATION_TTL_MS).toBe(24 * 60 * 60 * 1000); // A reset hands over an existing account, so its window must be the shorter one. expect(PASSWORD_RESET_TTL_MS < VERIFICATION_TTL_MS).toBe(true); }); test("a reset token is a fresh secret each time, stored only as a digest", () => { const first = issueToken(); const second = issueToken(); expect(first.token === second.token).toBe(false); expect(first.hash === hashToken(first.token)).toBe(true); expect(first.hash.includes(first.token)).toBe(false); }); test("the new password faces the same strength rules as signup", () => { expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true); expect(validatePassword("Sicher1234!", false, { requireStrength: true })).toBe(null); }); test("link and expiry codes are distinct and both localised", () => { expect(isAuthErrorCode("invalid_token")).toBe(true); expect(isAuthErrorCode("expired_token")).toBe(true); expect(authErrorMessage("invalid_token", true) === authErrorMessage("expired_token", true)).toBe( false ); expect(authErrorMessage("expired_token", true) === authErrorMessage("expired_token", false)).toBe( false ); }); }); describe("Auth — error vocabulary", () => { test("known codes resolve in both languages", () => { expect(isAuthErrorCode("email_taken")).toBe(true); expect(authErrorMessage("email_taken", true).length > 0).toBe(true); expect(authErrorMessage("email_taken", false).length > 0).toBe(true); expect(authErrorMessage("email_taken", true) === authErrorMessage("email_taken", false)).toBe( false ); }); test("unknown codes fall back instead of leaking raw values", () => { expect(isAuthErrorCode("wat")).toBe(false); expect(authErrorMessage("wat", false)).toBe(authErrorMessage("server_error", false)); expect(authErrorMessage(null, false)).toBe(authErrorMessage("server_error", false)); }); test("rate limiting reports the wait in whole minutes", () => { expect(authErrorMessage("rate_limited", false, 90).includes("2 minutes")).toBe(true); expect(authErrorMessage("rate_limited", false, 30).includes("1 minute")).toBe(true); expect(authErrorMessage("rate_limited", true, 120).includes("2 Minuten")).toBe(true); }); });