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>
This commit is contained in:
148
tests/e2e/user_enumeration.test.ts
Normal file
148
tests/e2e/user_enumeration.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user