Files
scan-receipts/tests/e2e/auth_security.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

265 lines
11 KiB
TypeScript

/**
* 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);
});
});