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>
510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
/**
|
|
* Auth Database Integration Suite
|
|
*
|
|
* Exercises the parts of the auth system that only mean anything against a real
|
|
* Postgres: the unique email key, verification-token lifecycle, Google account
|
|
* linking, and session lookup.
|
|
*
|
|
* Run with: npm run test:auth
|
|
*
|
|
* Requires a reachable DATABASE_URL with the migrations applied
|
|
* (`docker compose up -d postgres && npm run db:push`). Without a database the
|
|
* script reports a skip and exits 0 rather than pretending to have passed.
|
|
*/
|
|
|
|
// Keep first: populates DATABASE_URL before the database module below reads it.
|
|
import "./loadEnv";
|
|
|
|
import { and, eq, gt, like } from "drizzle-orm";
|
|
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
|
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
|
import {
|
|
email_verification_tokens,
|
|
oauth_accounts,
|
|
password_reset_tokens,
|
|
sessions,
|
|
users,
|
|
} from "../../src/lib/schema/db";
|
|
import {
|
|
consumeVerificationToken,
|
|
createUser,
|
|
findUserByEmail,
|
|
findUserByGoogleId,
|
|
isGoogleOnlyAccount,
|
|
isUniqueViolation,
|
|
issuePasswordResetLink,
|
|
issueVerificationLink,
|
|
linkGoogleAccount,
|
|
peekPasswordResetToken,
|
|
resetPasswordWithToken,
|
|
} from "../../src/lib/auth/accounts";
|
|
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
|
import { hashToken, issueToken, newId } from "../../src/lib/auth/tokens";
|
|
|
|
/** Namespaced so cleanup can never touch a real account. */
|
|
const RUN_ID = Date.now().toString(36);
|
|
const LOCAL_PART = `authtest-${RUN_ID}`;
|
|
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;
|
|
|
|
function extractToken(devLink: string | undefined): string {
|
|
if (!devLink) {
|
|
throw new Error(
|
|
"No dev link returned — the mailer tried to actually send. `loadEnv` should have cleared the SMTP variables; check it is still the first import."
|
|
);
|
|
}
|
|
const token = new URL(devLink).searchParams.get("token");
|
|
if (!token) throw new Error(`No token in dev link: ${devLink}`);
|
|
return token;
|
|
}
|
|
|
|
async function cleanup() {
|
|
// Children cascade from users; the LIKE keeps this scoped to this run.
|
|
await db.delete(users).where(like(users.emailKey, `authtest-%`));
|
|
}
|
|
|
|
function registerSuites() {
|
|
describe("Auth DB — one email, one account", () => {
|
|
test("a fresh signup creates a non-guest, unverified account", async () => {
|
|
const user = await createUser({
|
|
email: BASE_EMAIL,
|
|
name: "Auth Test",
|
|
passwordHash: await hashPassword("Sicher1234!"),
|
|
});
|
|
|
|
expect(user.isGuest).toBe(false);
|
|
expect(user.emailVerifiedAt).toBe(null);
|
|
expect(user.emailKey).toBe(BASE_EMAIL);
|
|
expect(isGoogleOnlyAccount(user)).toBe(false);
|
|
});
|
|
|
|
test("dotted and plus-tagged variants resolve to that same account", async () => {
|
|
const dotted = `${LOCAL_PART.split("").join(".")}@gmail.com`;
|
|
const tagged = `${LOCAL_PART}+throwaway@googlemail.com`;
|
|
|
|
const viaDots = await findUserByEmail(dotted);
|
|
const viaTag = await findUserByEmail(tagged);
|
|
const direct = await findUserByEmail(BASE_EMAIL);
|
|
|
|
expect(viaDots?.id).toBe(direct?.id);
|
|
expect(viaTag?.id).toBe(direct?.id);
|
|
});
|
|
|
|
test("registering an aliased variant is rejected by the unique index", async () => {
|
|
let violated = false;
|
|
try {
|
|
await createUser({
|
|
email: `${LOCAL_PART}+second@gmail.com`,
|
|
passwordHash: await hashPassword("Sicher1234!"),
|
|
});
|
|
} catch (error) {
|
|
violated = isUniqueViolation(error);
|
|
}
|
|
expect(violated).toBe(true);
|
|
});
|
|
|
|
test("twenty aliased signup attempts yield exactly one row", async () => {
|
|
for (let index = 0; index < 20; index += 1) {
|
|
try {
|
|
await createUser({
|
|
email: `${LOCAL_PART}+bulk${index}@gmail.com`,
|
|
passwordHash: "x",
|
|
});
|
|
} catch (error) {
|
|
if (!isUniqueViolation(error)) throw error;
|
|
}
|
|
}
|
|
|
|
const rows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL));
|
|
expect(rows.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("Auth DB — confirmation link lifecycle", () => {
|
|
test("issuing a link stores exactly one pending token", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
await issueVerificationLink(user, "en");
|
|
await issueVerificationLink(user, "en");
|
|
|
|
const pending = await db
|
|
.select()
|
|
.from(email_verification_tokens)
|
|
.where(eq(email_verification_tokens.userId, user.id));
|
|
|
|
// Re-issuing replaces the previous link rather than piling them up.
|
|
expect(pending.length).toBe(1);
|
|
});
|
|
|
|
test("opening the link verifies the account", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issueVerificationLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
const outcome = await consumeVerificationToken(token);
|
|
expect(outcome.status).toBe("success");
|
|
|
|
const refreshed = await findUserByEmail(BASE_EMAIL);
|
|
expect(refreshed?.emailVerifiedAt !== null).toBe(true);
|
|
});
|
|
|
|
test("a token cannot be replayed", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issueVerificationLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
// Already-verified accounts short-circuit; the point is it never re-succeeds.
|
|
const first = await consumeVerificationToken(token);
|
|
const second = await consumeVerificationToken(token);
|
|
expect(first.status === "success" || first.status === "already_verified").toBe(true);
|
|
expect(second.status === "success").toBe(false);
|
|
});
|
|
|
|
test("an unknown token is rejected", async () => {
|
|
const outcome = await consumeVerificationToken(issueToken().token);
|
|
expect(outcome.status).toBe("invalid");
|
|
});
|
|
|
|
test("an elapsed token reports as expired, not invalid", async () => {
|
|
const expiredUser = await createUser({
|
|
email: `${LOCAL_PART}-expired@example.com`,
|
|
passwordHash: "x",
|
|
});
|
|
|
|
const { token, hash } = issueToken();
|
|
await db.insert(email_verification_tokens).values({
|
|
id: hash,
|
|
userId: expiredUser.id,
|
|
email: expiredUser.email ?? "",
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
});
|
|
|
|
const outcome = await consumeVerificationToken(token);
|
|
expect(outcome.status).toBe("expired");
|
|
});
|
|
|
|
test("only the digest is stored — the raw token appears nowhere", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issueVerificationLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
const byRaw = await db
|
|
.select()
|
|
.from(email_verification_tokens)
|
|
.where(eq(email_verification_tokens.id, token));
|
|
const byHash = await db
|
|
.select()
|
|
.from(email_verification_tokens)
|
|
.where(eq(email_verification_tokens.id, hashToken(token)));
|
|
|
|
expect(byRaw.length).toBe(0);
|
|
expect(byHash.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("Auth DB — password reset", () => {
|
|
test("issuing a reset link replaces any earlier pending one", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
await issuePasswordResetLink(user, "en");
|
|
await issuePasswordResetLink(user, "en");
|
|
|
|
const pending = await db
|
|
.select()
|
|
.from(password_reset_tokens)
|
|
.where(eq(password_reset_tokens.userId, user.id));
|
|
|
|
expect(pending.length).toBe(1);
|
|
});
|
|
|
|
test("a live token reads as valid without being spent", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issuePasswordResetLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
expect(await peekPasswordResetToken(token)).toBe("valid");
|
|
// Peeking twice must not consume it — the page loads before the form posts.
|
|
expect(await peekPasswordResetToken(token)).toBe("valid");
|
|
});
|
|
|
|
test("the new password replaces the old one", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issuePasswordResetLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
const outcome = await resetPasswordWithToken(token, "BrandNew5678!");
|
|
expect(outcome.status).toBe("success");
|
|
|
|
const updated = await findUserByEmail(BASE_EMAIL);
|
|
expect(await verifyPassword("BrandNew5678!", updated?.passwordHash ?? null)).toBe(true);
|
|
expect(await verifyPassword("Sicher1234!", updated?.passwordHash ?? null)).toBe(false);
|
|
});
|
|
|
|
test("resetting signs out every existing session", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
await db.insert(sessions).values({
|
|
id: issueToken().hash,
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
await db.insert(sessions).values({
|
|
id: issueToken().hash,
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const mail = await issuePasswordResetLink(user, "en");
|
|
await resetPasswordWithToken(extractToken(mail.devLink), "AnotherOne901!");
|
|
|
|
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
|
expect(remaining.length).toBe(0);
|
|
});
|
|
|
|
test("a reset token cannot be replayed", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const mail = await issuePasswordResetLink(user, "en");
|
|
const token = extractToken(mail.devLink);
|
|
|
|
expect((await resetPasswordWithToken(token, "FirstUse123!")).status).toBe("success");
|
|
expect((await resetPasswordWithToken(token, "SecondUse123!")).status).toBe("invalid");
|
|
expect(await peekPasswordResetToken(token)).toBe("invalid");
|
|
|
|
// The second attempt must not have taken effect.
|
|
const updated = await findUserByEmail(BASE_EMAIL);
|
|
expect(await verifyPassword("FirstUse123!", updated?.passwordHash ?? null)).toBe(true);
|
|
expect(await verifyPassword("SecondUse123!", updated?.passwordHash ?? null)).toBe(false);
|
|
});
|
|
|
|
test("an elapsed reset token reports expired and changes nothing", async () => {
|
|
const user = await createUser({
|
|
email: `${LOCAL_PART}-reset-expired@example.com`,
|
|
passwordHash: await hashPassword("Original123!"),
|
|
});
|
|
|
|
const { token, hash } = issueToken();
|
|
await db.insert(password_reset_tokens).values({
|
|
id: hash,
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
});
|
|
|
|
expect(await peekPasswordResetToken(token)).toBe("expired");
|
|
expect((await resetPasswordWithToken(token, "Replacement123!")).status).toBe("expired");
|
|
|
|
const unchanged = await findUserByEmail(user.email ?? "");
|
|
expect(await verifyPassword("Original123!", unchanged?.passwordHash ?? null)).toBe(true);
|
|
});
|
|
|
|
test("an unknown reset token is rejected", async () => {
|
|
const token = issueToken().token;
|
|
expect(await peekPasswordResetToken(token)).toBe("invalid");
|
|
expect((await resetPasswordWithToken(token, "Whatever123!")).status).toBe("invalid");
|
|
});
|
|
|
|
test("a Google-only account can add a password this way", async () => {
|
|
const googleUser = await createUser({
|
|
email: `${LOCAL_PART}-google-only@example.com`,
|
|
emailVerified: true,
|
|
});
|
|
expect(isGoogleOnlyAccount(googleUser)).toBe(true);
|
|
|
|
const mail = await issuePasswordResetLink(googleUser, "en");
|
|
const outcome = await resetPasswordWithToken(extractToken(mail.devLink), "AddedLater123!");
|
|
expect(outcome.status).toBe("success");
|
|
|
|
const updated = await findUserByEmail(googleUser.email ?? "");
|
|
expect(isGoogleOnlyAccount(updated!)).toBe(false);
|
|
expect(await verifyPassword("AddedLater123!", updated?.passwordHash ?? null)).toBe(true);
|
|
});
|
|
|
|
test("resetting also confirms the address", async () => {
|
|
const pending = await createUser({
|
|
email: `${LOCAL_PART}-unconfirmed@example.com`,
|
|
passwordHash: await hashPassword("Original123!"),
|
|
});
|
|
expect(pending.emailVerifiedAt).toBe(null);
|
|
|
|
const mail = await issuePasswordResetLink(pending, "en");
|
|
await resetPasswordWithToken(extractToken(mail.devLink), "Confirmed123!");
|
|
|
|
// Opening the emailed link proves inbox control, so the address is verified.
|
|
const updated = await findUserByEmail(pending.email ?? "");
|
|
expect(updated?.emailVerifiedAt !== null).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Auth DB — Google identity linking", () => {
|
|
test("a Google identity resolves back to the linked account", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const sub = `google-sub-${RUN_ID}`;
|
|
await linkGoogleAccount(user.id, sub);
|
|
|
|
const found = await findUserByGoogleId(sub);
|
|
expect(found?.id).toBe(user.id);
|
|
});
|
|
|
|
test("linking the same identity twice is idempotent", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const sub = `google-sub-${RUN_ID}`;
|
|
await linkGoogleAccount(user.id, sub);
|
|
await linkGoogleAccount(user.id, sub);
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(oauth_accounts)
|
|
.where(
|
|
and(eq(oauth_accounts.provider, "google"), eq(oauth_accounts.providerAccountId, sub))
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
});
|
|
|
|
test("an unknown Google identity resolves to nothing", async () => {
|
|
expect(await findUserByGoogleId(`no-such-sub-${RUN_ID}`)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe("Auth DB — sessions", () => {
|
|
test("a live session is found by its token digest", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const { token, hash } = issueToken();
|
|
await db.insert(sessions).values({
|
|
id: hash,
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
|
|
const rows = await db
|
|
.select({ user: users })
|
|
.from(sessions)
|
|
.innerJoin(users, eq(sessions.userId, users.id))
|
|
.where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));
|
|
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].user.id).toBe(user.id);
|
|
});
|
|
|
|
test("an elapsed session is not returned", async () => {
|
|
const user = await findUserByEmail(BASE_EMAIL);
|
|
if (!user) throw new Error("Fixture user missing");
|
|
|
|
const { token, hash } = issueToken();
|
|
await db.insert(sessions).values({
|
|
id: hash,
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() - 60_000),
|
|
});
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));
|
|
|
|
expect(rows.length).toBe(0);
|
|
});
|
|
|
|
test("deleting the account removes its sessions, tokens and links", async () => {
|
|
const doomed = await createUser({
|
|
email: `${LOCAL_PART}-cascade@example.com`,
|
|
passwordHash: "x",
|
|
});
|
|
|
|
await db.insert(sessions).values({
|
|
id: issueToken().hash,
|
|
userId: doomed.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
await db.insert(email_verification_tokens).values({
|
|
id: issueToken().hash,
|
|
userId: doomed.id,
|
|
email: doomed.email ?? "",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
await db.insert(oauth_accounts).values({
|
|
id: newId("oa"),
|
|
userId: doomed.id,
|
|
provider: "google",
|
|
providerAccountId: `cascade-${RUN_ID}`,
|
|
});
|
|
|
|
await db.delete(users).where(eq(users.id, doomed.id));
|
|
|
|
const leftoverSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, doomed.id));
|
|
const leftoverTokens = await db
|
|
.select()
|
|
.from(email_verification_tokens)
|
|
.where(eq(email_verification_tokens.userId, doomed.id));
|
|
const leftoverLinks = await db
|
|
.select()
|
|
.from(oauth_accounts)
|
|
.where(eq(oauth_accounts.userId, doomed.id));
|
|
|
|
expect(leftoverSessions.length).toBe(0);
|
|
expect(leftoverTokens.length).toBe(0);
|
|
expect(leftoverLinks.length).toBe(0);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
if (!(await isDatabaseAvailable())) {
|
|
console.log(
|
|
[
|
|
"",
|
|
" SKIPPED — no database reachable at DATABASE_URL.",
|
|
"",
|
|
" Start one and apply the schema, then re-run:",
|
|
" docker compose up -d postgres",
|
|
" npm run db:push",
|
|
" npm run test:auth",
|
|
"",
|
|
].join("\n")
|
|
);
|
|
await pool.end();
|
|
return;
|
|
}
|
|
|
|
// Leftovers from an interrupted earlier run would break the uniqueness tests.
|
|
await cleanup();
|
|
registerSuites();
|
|
|
|
let passed = false;
|
|
try {
|
|
passed = await runAllTests();
|
|
} finally {
|
|
await cleanup();
|
|
await pool.end();
|
|
}
|
|
|
|
if (!passed) process.exit(1);
|
|
}
|
|
|
|
main().catch(async (error) => {
|
|
console.error("Auth DB suite crashed:", error);
|
|
await pool.end().catch(() => undefined);
|
|
process.exit(1);
|
|
});
|