Files
scan-receipts/tests/e2e/user_enumeration.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

149 lines
6.4 KiB
TypeScript

/**
* User-Enumeration Hardening Suite
*
* The auth endpoints must not reveal whether an email address is registered.
* Signup answers the same 200 `verification_sent` for every outcome, login
* folds Google-only accounts into plain `invalid_credentials`, and the
* revealing error codes (`email_taken`, `email_taken_google`, `use_google`) are
* never emitted by any auth route.
*
* Pure decision logic plus static source assertions — no database required.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, test, expect } from "./runner";
import {
DISCONTINUED_ENUMERATION_CODES,
loginDecision,
signupDecision,
signupResponseBody,
type SignupAccountState,
} from "../../src/lib/auth/neutral";
import { isAuthErrorCode } from "../../src/lib/auth/errors";
/**
* Root of the workspace. The runner is always invoked from the workspace root
* (`npx tsx tests/e2e/runner.ts …`), so the current working directory is the
* anchor — this also keeps the static source assertions working when the suite
* runs from a compiled copy elsewhere.
*/
const WORKSPACE_ROOT = process.cwd();
function readAuthRouteSource(route: string): string {
return readFileSync(resolve(WORKSPACE_ROOT, "src", "app", "api", "auth", route), "utf8");
}
describe("UserEnumeration", () => {
describe("signup answers are identical for every account state", () => {
const ALL_STATES: SignupAccountState[] = ["new", "unverified", "verified", "google_only"];
test("every account state maps to the same verification_sent status", () => {
for (const state of ALL_STATES) {
expect(signupDecision(state).status).toBe("verification_sent");
}
});
test("only new and never-confirmed accounts get a real confirmation mail", () => {
expect(signupDecision("new").sendMail).toBe(true);
expect(signupDecision("unverified").sendMail).toBe(true);
expect(signupDecision("verified").sendMail).toBe(false);
expect(signupDecision("google_only").sendMail).toBe(false);
});
test("the wire body is identical for new, existing and Google-only accounts", () => {
const bodies = ALL_STATES.map((state) => signupResponseBody(signupDecision(state), undefined));
for (const body of bodies) {
expect(body).toEqual({ status: "verification_sent" });
}
// Every pair is byte-for-byte the same shape.
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[1]).toEqual(bodies[2]);
expect(bodies[2]).toEqual(bodies[3]);
});
test("devLink appears only in the dev fallback and only when a mail was produced", () => {
// Production / no dev fallback: never present, for any state.
for (const state of ALL_STATES) {
expect(signupResponseBody(signupDecision(state), undefined)).toEqual({
status: "verification_sent",
});
}
// Dev fallback with a produced mail: unverified accounts get the link…
expect(signupResponseBody(signupDecision("unverified"), "http://localhost/dev-link")).toEqual(
{ status: "verification_sent", devLink: "http://localhost/dev-link" }
);
expect(signupResponseBody(signupDecision("new"), "http://localhost/dev-link")).toEqual(
{ status: "verification_sent", devLink: "http://localhost/dev-link" }
);
// …but verified / Google-only accounts never get a mail, so no devLink either.
expect(signupResponseBody(signupDecision("verified"), "http://localhost/dev-link")).toEqual(
{ status: "verification_sent" }
);
expect(signupResponseBody(signupDecision("google_only"), "http://localhost/dev-link")).toEqual(
{ status: "verification_sent" }
);
});
});
describe("login folds Google-only accounts into invalid_credentials", () => {
test("a Google-only account is indistinguishable from a missing one", () => {
for (const passwordMatches of [true, false]) {
expect(loginDecision("google_only", passwordMatches)).toEqual({ kind: "invalid_credentials" });
expect(loginDecision("none", passwordMatches)).toEqual({ kind: "invalid_credentials" });
}
});
test("a wrong password stays invalid_credentials for local accounts", () => {
expect(loginDecision("local", false)).toEqual({ kind: "invalid_credentials" });
expect(loginDecision("unverified_local", false)).toEqual({ kind: "invalid_credentials" });
});
test("email_not_verified is reserved for correct-password unverified accounts", () => {
expect(loginDecision("unverified_local", true)).toEqual({ kind: "email_not_verified" });
// Never reachable for a missing or Google-only account, even with a "match":
// an attacker without the password can never get this code.
expect(loginDecision("google_only", true)).toEqual({ kind: "invalid_credentials" });
expect(loginDecision("none", true)).toEqual({ kind: "invalid_credentials" });
});
test("only a verified local account with the right password signs in", () => {
expect(loginDecision("local", true)).toEqual({ kind: "sign_in" });
expect(loginDecision("local", false)).toEqual({ kind: "invalid_credentials" });
});
});
describe("revealing codes are never emitted by auth routes", () => {
test("the discontinued codes still exist in the vocabulary for compatibility", () => {
for (const code of DISCONTINUED_ENUMERATION_CODES) {
expect(isAuthErrorCode(code)).toBe(true);
}
});
test("no auth route source contains any discontinued enumeration code", () => {
const routes = [
"signup/route.ts",
"login/route.ts",
"forgot-password/route.ts",
"reset-password/route.ts",
"resend-verification/route.ts",
];
for (const route of routes) {
const source = readAuthRouteSource(route);
for (const code of DISCONTINUED_ENUMERATION_CODES) {
expect(source.includes(code)).toBe(false);
}
}
});
test("the login 403 carries no extra fields that would set it apart", () => {
// The `email_not_verified` failure body must be a plain `{ error }` —
// no `email` field — so its shape matches every other failure.
// (Guard: the login route must not pass an `email` extra into authError.)
const loginSource = readAuthRouteSource("login/route.ts");
expect(loginSource.includes('email_not_verified", 403, { email')).toBe(false);
});
});
});