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:
225
tests/integration/account_deletion.test.ts
Normal file
225
tests/integration/account_deletion.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Account Deletion Integration Suite
|
||||
*
|
||||
* Exercises `deleteUserAccount` — the logged-in account-deletion flow behind
|
||||
* `DELETE /api/auth/delete-account`. Covers the same shape of guarantees as
|
||||
* `password_change.test.ts` (unknown user, Google-only account, wrong
|
||||
* password, success), plus the part specific to deletion: that removing the
|
||||
* user row actually cascades to every table that depends on it, while
|
||||
* `licenses` and `security_events` survive with their `userId` nulled out.
|
||||
*
|
||||
* Run with: npx tsx tests/integration/account_deletion.test.ts
|
||||
*
|
||||
* 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 { eq, like } from "drizzle-orm";
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
||||
import {
|
||||
licenses,
|
||||
line_items,
|
||||
oauth_accounts,
|
||||
projects,
|
||||
receipts,
|
||||
security_events,
|
||||
sessions,
|
||||
users,
|
||||
} from "../../src/lib/schema/db";
|
||||
import { createUser, deleteUserAccount, findUserByEmail } from "../../src/lib/auth/accounts";
|
||||
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
||||
import { createSessionRecord } from "../../src/lib/auth/session";
|
||||
import { 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}`;
|
||||
|
||||
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("Account deletion — verification", () => {
|
||||
test("an unknown user id reports invalid", async () => {
|
||||
const outcome = await deleteUserAccount("usr_nonexistent", "Whatever123!");
|
||||
expect(outcome.status).toBe("invalid");
|
||||
});
|
||||
|
||||
test("a Google-only account (no password hash) deletes without a password", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-google@example.com`,
|
||||
emailVerified: true, // no passwordHash → Google-only
|
||||
});
|
||||
expect(user.passwordHash).toBe(null);
|
||||
|
||||
const outcome = await deleteUserAccount(user.id, null);
|
||||
expect(outcome.status).toBe("success");
|
||||
|
||||
expect(await findUserByEmail(user.email ?? "")).toBe(null);
|
||||
});
|
||||
|
||||
test("a wrong password is rejected and the account survives", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-wrong@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const outcome = await deleteUserAccount(user.id, "NotThePassword!");
|
||||
expect(outcome.status).toBe("wrong_password");
|
||||
|
||||
const still = await findUserByEmail(user.email ?? "");
|
||||
expect(still).not.toBe(null);
|
||||
expect(await verifyPassword("Original123!", still?.passwordHash ?? null)).toBe(true);
|
||||
});
|
||||
|
||||
test("a missing password on a password-protected account is rejected", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-missing@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const outcome = await deleteUserAccount(user.id, null);
|
||||
expect(outcome.status).toBe("wrong_password");
|
||||
expect(await findUserByEmail(user.email ?? "")).not.toBe(null);
|
||||
});
|
||||
|
||||
test("the correct password deletes the account", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-success@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const outcome = await deleteUserAccount(user.id, "Original123!");
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(await findUserByEmail(user.email ?? "")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account deletion — cascade", () => {
|
||||
test("dependent rows cascade away; licenses and security events survive with userId nulled", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-cascade@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" });
|
||||
|
||||
await db.insert(oauth_accounts).values({
|
||||
id: newId("oau"),
|
||||
userId: user.id,
|
||||
provider: "google",
|
||||
providerAccountId: `google-${RUN_ID}`,
|
||||
});
|
||||
|
||||
const projectId = newId("prj");
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
userId: user.id,
|
||||
name: "Reisekosten",
|
||||
});
|
||||
|
||||
const receiptId = newId("rcp");
|
||||
await db.insert(receipts).values({
|
||||
id: receiptId,
|
||||
userId: user.id,
|
||||
imageHash: `hash-${RUN_ID}`,
|
||||
projectId,
|
||||
totalAmount: "42.00",
|
||||
});
|
||||
|
||||
await db.insert(line_items).values({
|
||||
id: newId("li"),
|
||||
receiptId,
|
||||
description: "Kaffee",
|
||||
price: "3.50",
|
||||
});
|
||||
|
||||
await db.insert(licenses).values({
|
||||
id: newId("lic"),
|
||||
userId: user.id,
|
||||
licenseKey: `key-${RUN_ID}`,
|
||||
plan: "lifetime",
|
||||
});
|
||||
|
||||
await db.insert(security_events).values({
|
||||
id: newId("evt"),
|
||||
type: "login.success",
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
const outcome = await deleteUserAccount(user.id, "Original123!");
|
||||
expect(outcome.status).toBe("success");
|
||||
|
||||
// Everything keyed to the user directly, plus the receipt's own child
|
||||
// row, is gone.
|
||||
expect((await db.select().from(sessions).where(eq(sessions.userId, user.id))).length).toBe(0);
|
||||
expect(
|
||||
(await db.select().from(oauth_accounts).where(eq(oauth_accounts.userId, user.id))).length
|
||||
).toBe(0);
|
||||
expect((await db.select().from(projects).where(eq(projects.userId, user.id))).length).toBe(0);
|
||||
expect((await db.select().from(receipts).where(eq(receipts.userId, user.id))).length).toBe(0);
|
||||
expect(
|
||||
(await db.select().from(line_items).where(eq(line_items.receiptId, receiptId))).length
|
||||
).toBe(0);
|
||||
|
||||
// Billing history and the audit trail survive, disowned rather than deleted.
|
||||
const licenseRows = await db.select().from(licenses).where(eq(licenses.licenseKey, `key-${RUN_ID}`));
|
||||
expect(licenseRows.length).toBe(1);
|
||||
expect(licenseRows[0].userId).toBe(null);
|
||||
|
||||
const eventRows = await db
|
||||
.select()
|
||||
.from(security_events)
|
||||
.where(eq(security_events.email, user.email ?? ""));
|
||||
expect(eventRows.length >= 1).toBe(true);
|
||||
expect(eventRows.every((row) => row.userId === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
" npx tsx tests/integration/account_deletion.test.ts",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leftovers from an interrupted earlier run would break the assertions.
|
||||
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("Account deletion suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
509
tests/integration/auth_db.test.ts
Normal file
509
tests/integration/auth_db.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
170
tests/integration/csrf_flow.test.ts
Normal file
170
tests/integration/csrf_flow.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* CSRF Flow Integration Suite
|
||||
*
|
||||
* End-to-end double-submit CSRF check against a real handler: a logged-in user
|
||||
* exists (fixture account + session row), and `POST /api/auth/logout` is driven
|
||||
* with and without the matching `sr_csrf` cookie / `x-csrf-token` header.
|
||||
*
|
||||
* Run with: npx tsx tests/integration/csrf_flow.test.ts
|
||||
*
|
||||
* 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 { eq, like } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, beforeAll, runAllTests } from "../e2e/runner";
|
||||
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
||||
import { sessions, users } from "../../src/lib/schema/db";
|
||||
import { createUser } from "../../src/lib/auth/accounts";
|
||||
import { hashPassword } from "../../src/lib/auth/password";
|
||||
import { issueToken } from "../../src/lib/auth/tokens";
|
||||
import { POST as logoutPOST } from "../../src/app/api/auth/logout/route";
|
||||
import {
|
||||
CSRF_COOKIE,
|
||||
CSRF_HEADER,
|
||||
issueCsrfToken,
|
||||
} from "../../src/lib/auth/csrf";
|
||||
|
||||
/** Namespaced so cleanup can never touch a real account. */
|
||||
const RUN_ID = Date.now().toString(36);
|
||||
const LOCAL_PART = `authtest-csrf-${RUN_ID}`;
|
||||
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;
|
||||
|
||||
async function cleanup() {
|
||||
// Children cascade from users; the LIKE keeps this scoped to authtest rows.
|
||||
await db.delete(users).where(like(users.emailKey, `authtest-%`));
|
||||
}
|
||||
|
||||
/** A NextRequest — the same object shape Next.js passes to route handlers. */
|
||||
function logoutRequest(headers?: HeadersInit): NextRequest {
|
||||
return new NextRequest("http://localhost:3000/api/auth/logout", {
|
||||
method: "POST",
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function registerSuites() {
|
||||
describe("CSRF flow — double-submit guard on POST /api/auth/logout", () => {
|
||||
beforeAll(async () => {
|
||||
const user = await createUser({
|
||||
email: BASE_EMAIL,
|
||||
name: "CSRF Flow Test",
|
||||
passwordHash: await hashPassword("Sicher1234!"),
|
||||
});
|
||||
await db.insert(sessions).values({
|
||||
id: issueToken().hash,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
});
|
||||
|
||||
test("the fixture user and session exist", async () => {
|
||||
const userRows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL));
|
||||
expect(userRows.length).toBe(1);
|
||||
const sessionRows = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userRows[0].id));
|
||||
expect(sessionRows.length).toBe(1);
|
||||
});
|
||||
|
||||
test("a logout without a CSRF token is blocked with 403 csrf_failed", async () => {
|
||||
const response = await logoutPOST(logoutRequest());
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ error: "csrf_failed" });
|
||||
});
|
||||
|
||||
test("a logout with a matching cookie + header succeeds", async () => {
|
||||
const token = issueCsrfToken();
|
||||
// This is the one call in the suite that reaches the real route handler
|
||||
// past the CSRF guard, so it hits `destroySession()` -> `cookies()`.
|
||||
// Outside actual Next.js request handling there is no request-scoped
|
||||
// AsyncLocalStorage for `cookies()` to read, so it throws; the route
|
||||
// already treats that as non-fatal (logout must not "stick" a user
|
||||
// signed in) and logs it via console.error. That's expected only in
|
||||
// this direct-handler test harness, so it's muted here rather than in
|
||||
// the route, which must keep logging real failures in production.
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await logoutPOST(
|
||||
logoutRequest({
|
||||
cookie: `${CSRF_COOKIE}=${token}`,
|
||||
[CSRF_HEADER]: token,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { status?: string };
|
||||
expect(body.status).toBe("signed_out");
|
||||
});
|
||||
|
||||
test("a logout with a mismatched header is blocked with 403", async () => {
|
||||
const response = await logoutPOST(
|
||||
logoutRequest({
|
||||
cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`,
|
||||
[CSRF_HEADER]: issueCsrfToken(),
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ error: "csrf_failed" });
|
||||
});
|
||||
|
||||
test("a logout with a cookie but no header is blocked with 403", async () => {
|
||||
const response = await logoutPOST(
|
||||
logoutRequest({
|
||||
cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`,
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ error: "csrf_failed" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
" npx tsx tests/integration/csrf_flow.test.ts",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leftovers from an interrupted earlier run would break the fixture.
|
||||
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("CSRF flow suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
36
tests/integration/loadEnv.ts
Normal file
36
tests/integration/loadEnv.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Loads `.env.local` as a side effect on import.
|
||||
*
|
||||
* This has to be its own module: ES module imports are hoisted, so calling
|
||||
* `process.loadEnvFile()` at the top of a file that also imports the database
|
||||
* module would run *after* that module had already read `DATABASE_URL`. Imported
|
||||
* first, this module is evaluated first.
|
||||
*
|
||||
* It matters because the fallback connection string targets port 5432, which on
|
||||
* a development machine is quite likely another project's database.
|
||||
*/
|
||||
try {
|
||||
process.loadEnvFile(".env.local");
|
||||
} catch {
|
||||
// No .env.local (CI, fresh clone): use the environment as given.
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard guarantee that the suite never sends mail.
|
||||
*
|
||||
* The fixtures operate on fabricated addresses (`authtest-…@gmail.com`), and a
|
||||
* configured SMTP server would dutifully try to deliver to them — bouncing off
|
||||
* real providers and burning the sending domain's reputation. Clearing these
|
||||
* puts the mailer into its development fallback, which returns the link instead
|
||||
* of sending it, which is also how the tests get hold of the raw token.
|
||||
*/
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASSWORD;
|
||||
|
||||
// The dev fallback throws in production rather than silently not sending.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("Refusing to run the auth integration suite with NODE_ENV=production");
|
||||
}
|
||||
|
||||
export {};
|
||||
169
tests/integration/password_change.test.ts
Normal file
169
tests/integration/password_change.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Password-Change Database Integration Suite
|
||||
*
|
||||
* Exercises `changePasswordForUser` — the logged-in password-change flow behind
|
||||
* `POST /api/auth/change-password`. The core of the hardening: when a user
|
||||
* changes their password, EVERY existing session row for the account must be
|
||||
* deleted, so a device that was already signed in (e.g. one an attacker had
|
||||
* access through) loses access instantly. The route then issues a fresh session
|
||||
* for the current device; that re-issuing lives in the route handler, not here.
|
||||
*
|
||||
* Run with: npx tsx tests/integration/password_change.test.ts
|
||||
*
|
||||
* 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 { eq, like } from "drizzle-orm";
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
||||
import { sessions, users } from "../../src/lib/schema/db";
|
||||
import { changePasswordForUser, createUser, findUserByEmail } from "../../src/lib/auth/accounts";
|
||||
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
||||
import { createSessionRecord } from "../../src/lib/auth/session";
|
||||
import { issueToken } 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}`;
|
||||
|
||||
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("Password change — DB", () => {
|
||||
test("an unknown user id reports invalid", async () => {
|
||||
const outcome = await changePasswordForUser(
|
||||
"usr_nonexistent",
|
||||
"Whatever123!",
|
||||
"NewPassword123!"
|
||||
);
|
||||
expect(outcome.status).toBe("invalid");
|
||||
});
|
||||
|
||||
test("a Google-only account (no password hash) reports no_password", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-google@example.com`,
|
||||
emailVerified: true, // no passwordHash → Google-only
|
||||
});
|
||||
expect(user.passwordHash).toBe(null);
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "Whatever123!", "NewPassword123!");
|
||||
expect(outcome.status).toBe("no_password");
|
||||
|
||||
// Nothing changed for the account.
|
||||
const unchanged = await findUserByEmail(user.email ?? "");
|
||||
expect(unchanged?.passwordHash).toBe(null);
|
||||
});
|
||||
|
||||
test("a wrong current password is rejected and leaves the hash untouched", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-wrong@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
const before = await findUserByEmail(user.email ?? "");
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "NotThePassword!", "Replacement123!");
|
||||
expect(outcome.status).toBe("wrong_password");
|
||||
|
||||
const after = await findUserByEmail(user.email ?? "");
|
||||
expect(after?.passwordHash).toBe(before?.passwordHash);
|
||||
expect(await verifyPassword("Original123!", after?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("Replacement123!", after?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
|
||||
test("success replaces the hash: old password stops verifying, new one works", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-success@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "Original123!", "BrandNew456!");
|
||||
expect(outcome.status).toBe("success");
|
||||
// Narrow the discriminated union so TS knows `userId` exists.
|
||||
if (outcome.status !== "success") throw new Error("expected success");
|
||||
expect(outcome.userId).toBe(user.id);
|
||||
|
||||
const updated = await findUserByEmail(user.email ?? "");
|
||||
expect(updated?.passwordHash !== user.passwordHash).toBe(true);
|
||||
expect(await verifyPassword("BrandNew456!", updated?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("Original123!", updated?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
|
||||
test("success deletes every existing session row for the account", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-sessions@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
// Three devices signed in before the password changes: two via the real
|
||||
// session-issuing helper, one inserted directly.
|
||||
await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" });
|
||||
await createSessionRecord(user.id, { remember: true, userAgent: "iPhone/Safari" });
|
||||
await db.insert(sessions).values({
|
||||
id: issueToken().hash,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const seeded = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(seeded.length).toBe(3);
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "Original123!", "Rotated789!");
|
||||
expect(outcome.status).toBe("success");
|
||||
|
||||
// Every old token is dead — this is the whole point of the hardening.
|
||||
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(remaining.length).toBe(0);
|
||||
|
||||
// The account row itself survives the rotation.
|
||||
const updated = await findUserByEmail(user.email ?? "");
|
||||
expect(updated).not.toBe(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
" npx tsx tests/integration/password_change.test.ts",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leftovers from an interrupted earlier run would break the assertions.
|
||||
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("Password change suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
237
tests/integration/reset_token_expiry.test.ts
Normal file
237
tests/integration/reset_token_expiry.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Reset Token Expiry Integration Suite (Task D)
|
||||
*
|
||||
* Verifies the "Expire reset links" hardening requirement: password-reset links
|
||||
* are only valid for a bounded time window, are single-use, and consuming one
|
||||
* invalidates every existing session for the account.
|
||||
*
|
||||
* The implementation under test lives in `src/lib/auth/accounts.ts`
|
||||
* (`issuePasswordResetLink`, `peekPasswordResetToken`,
|
||||
* `resetPasswordWithToken`) plus `PASSWORD_RESET_TTL_MS` in
|
||||
* `src/lib/auth/config.ts`. This suite only verifies — it never modifies those
|
||||
* files (accounts.ts is owned by Task C).
|
||||
*
|
||||
* Run with: npx tsx tests/integration/reset_token_expiry.test.ts
|
||||
*
|
||||
* 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 { eq, like } from "drizzle-orm";
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
||||
import { password_reset_tokens, sessions, users } from "../../src/lib/schema/db";
|
||||
import {
|
||||
createUser,
|
||||
findUserByEmail,
|
||||
issuePasswordResetLink,
|
||||
peekPasswordResetToken,
|
||||
resetPasswordWithToken,
|
||||
} from "../../src/lib/auth/accounts";
|
||||
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
||||
import { hashToken, issueToken } from "../../src/lib/auth/tokens";
|
||||
import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS } from "../../src/lib/auth/config";
|
||||
|
||||
/** Namespaced so cleanup can never touch a real account. */
|
||||
const RUN_ID = Date.now().toString(36);
|
||||
const LOCAL_PART = `authtest-${RUN_ID}`;
|
||||
|
||||
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("Reset token expiry — issuance & TTL window", () => {
|
||||
test("a freshly issued link is valid and expires inside the TTL window", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-ttl-window@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const mail = await issuePasswordResetLink(user, "en");
|
||||
const token = extractToken(mail.devLink);
|
||||
|
||||
// Peeking reports it live without spending it.
|
||||
expect(await peekPasswordResetToken(token)).toBe("valid");
|
||||
|
||||
// The stored row carries an expiry bounded by the configured TTL.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(password_reset_tokens)
|
||||
.where(eq(password_reset_tokens.id, hashToken(token)));
|
||||
expect(rows.length).toBe(1);
|
||||
|
||||
const remainingMs = rows[0].expiresAt.getTime() - Date.now();
|
||||
expect(remainingMs).toBeGreaterThan(0);
|
||||
expect(remainingMs).toBeLessThanOrEqual(PASSWORD_RESET_TTL_MS);
|
||||
// It was issued moments ago, so the window must be ~the full TTL, not a
|
||||
// token that merely "has some time left".
|
||||
expect(remainingMs).toBeGreaterThan(PASSWORD_RESET_TTL_MS - 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset token expiry — single use", () => {
|
||||
test("consuming a valid token succeeds, installs the password, marks it consumed", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-single-use@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
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(user.email ?? "");
|
||||
expect(await verifyPassword("BrandNew5678!", updated?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("Original123!", updated?.passwordHash ?? null)).toBe(false);
|
||||
|
||||
// The token row is marked consumed so a replay cannot work.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(password_reset_tokens)
|
||||
.where(eq(password_reset_tokens.id, hashToken(token)));
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].consumedAt !== null).toBe(true);
|
||||
});
|
||||
|
||||
test("a reset token cannot be replayed after being spent", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-replay@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
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(user.email ?? "");
|
||||
expect(await verifyPassword("FirstUse123!", updated?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("SecondUse123!", updated?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset token expiry — elapsed tokens", () => {
|
||||
test("an expired token reads expired and cannot change the password", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-elapsed@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);
|
||||
expect(await verifyPassword("Replacement123!", unchanged?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset token expiry — session invalidation", () => {
|
||||
test("consuming a reset link drops every existing session for the account", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-sessions@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
// Precondition: the two sessions really exist.
|
||||
const before = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(before.length).toBe(2);
|
||||
|
||||
const mail = await issuePasswordResetLink(user, "en");
|
||||
const outcome = await resetPasswordWithToken(extractToken(mail.devLink), "AnotherOne901!");
|
||||
expect(outcome.status).toBe("success");
|
||||
|
||||
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(remaining.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset token expiry — configuration constants", () => {
|
||||
test("PASSWORD_RESET_TTL_MS is 15–60 minutes and below VERIFICATION_TTL_MS", () => {
|
||||
// The requirement: reset links should be valid ~15–60 minutes, and reset
|
||||
// links must live far shorter than confirmation links.
|
||||
expect(PASSWORD_RESET_TTL_MS).toBeGreaterThanOrEqual(15 * 60 * 1000);
|
||||
expect(PASSWORD_RESET_TTL_MS).toBeLessThanOrEqual(60 * 60 * 1000);
|
||||
expect(PASSWORD_RESET_TTL_MS).toBeLessThan(VERIFICATION_TTL_MS);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
" npx tsx tests/integration/reset_token_expiry.test.ts",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leftovers from an interrupted earlier run would skew the count assertions.
|
||||
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("Reset token expiry suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
194
tests/integration/security_events.test.ts
Normal file
194
tests/integration/security_events.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Security Events Integration Suite
|
||||
*
|
||||
* Exercises the audit-log foundation against a real Postgres: events written
|
||||
* through `logSecurityEvent` come back through `listRecentSecurityEvents` with
|
||||
* the right type/email, raw IPs are never persisted (only their 64-hex SHA-256
|
||||
* digest), jsonb metadata round-trips, and a bogus user id (FK violation) is
|
||||
* swallowed instead of throwing — logging must never break a request.
|
||||
*
|
||||
* Run with: node --import tsx tests/integration/security_events.test.ts
|
||||
*
|
||||
* 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 { eq, like } from "drizzle-orm";
|
||||
import { describe, test, expect, runAllTests } from "../e2e/runner";
|
||||
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
|
||||
import { security_events, users } from "../../src/lib/schema/db";
|
||||
import { createUser } from "../../src/lib/auth/accounts";
|
||||
import {
|
||||
logSecurityEvent,
|
||||
listRecentSecurityEvents,
|
||||
SecurityEventType,
|
||||
} from "../../src/lib/auth/securityEvents";
|
||||
|
||||
/** 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`;
|
||||
const PLAINTEXT_IP = "203.0.113.7";
|
||||
/** Unique marker in metadata; scopes event cleanup to exactly this run. */
|
||||
const MARKER = `security-events-${RUN_ID}`;
|
||||
|
||||
/** Events written by this suite, so cleanup can delete them explicitly by id. */
|
||||
const createdEventIds: string[] = [];
|
||||
|
||||
async function cleanup() {
|
||||
// The suite's own event rows, scoped by the unique metadata marker.
|
||||
if (createdEventIds.length > 0) {
|
||||
for (const id of createdEventIds) {
|
||||
await db.delete(security_events).where(eq(security_events.id, id)).catch(() => undefined);
|
||||
}
|
||||
createdEventIds.length = 0;
|
||||
}
|
||||
// Belt and suspenders: anything this run left behind (e.g. a marker that did
|
||||
// not round-trip) is swept by the same email namespace the fixtures use.
|
||||
await db
|
||||
.delete(security_events)
|
||||
.where(like(security_events.email, "authtest-%"))
|
||||
.catch(() => undefined);
|
||||
// Fixture users; the security_events.user_id FK is ON DELETE SET NULL, which
|
||||
// is why the event rows above are deleted first.
|
||||
await db.delete(users).where(like(users.emailKey, "authtest-%")).catch(() => undefined);
|
||||
}
|
||||
|
||||
function registerSuites() {
|
||||
describe("Security events — persistence and privacy", () => {
|
||||
test("a logged event appears with the right type/email and hashed IP", async () => {
|
||||
const user = await createUser({
|
||||
email: BASE_EMAIL,
|
||||
name: "Security Event Test",
|
||||
passwordHash: null,
|
||||
});
|
||||
|
||||
await logSecurityEvent({
|
||||
type: SecurityEventType.LOGIN_FAILED,
|
||||
userId: user.id,
|
||||
email: user.email ?? BASE_EMAIL,
|
||||
ip: PLAINTEXT_IP,
|
||||
userAgent: "security-events-test/1.0",
|
||||
metadata: { marker: MARKER, reason: "bad_password", attempts: 3 },
|
||||
});
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(security_events)
|
||||
.where(eq(security_events.userId, user.id));
|
||||
expect(rows.length).toBe(1);
|
||||
|
||||
const row = rows[0];
|
||||
createdEventIds.push(row.id);
|
||||
|
||||
expect(row.type).toBe(SecurityEventType.LOGIN_FAILED);
|
||||
expect(row.email).toBe(BASE_EMAIL);
|
||||
expect(row.userId).toBe(user.id);
|
||||
expect(row.userAgent).toBe("security-events-test/1.0");
|
||||
|
||||
// Raw IP is never stored: the column holds the 64-hex SHA-256 digest.
|
||||
expect(row.ipHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(row.ipHash).not.toBe(PLAINTEXT_IP);
|
||||
expect(JSON.stringify(row)).not.toContain(PLAINTEXT_IP);
|
||||
|
||||
// jsonb metadata round-trips as a real object.
|
||||
expect(row.metadataJson).toEqual({ marker: MARKER, reason: "bad_password", attempts: 3 });
|
||||
|
||||
// And the read API surfaces it again.
|
||||
const recent = await listRecentSecurityEvents(50, SecurityEventType.LOGIN_FAILED);
|
||||
const hit = recent.find((e) => e.email === BASE_EMAIL && e.type === SecurityEventType.LOGIN_FAILED);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.metadata).toEqual({ marker: MARKER, reason: "bad_password", attempts: 3 });
|
||||
});
|
||||
|
||||
test("logSecurityEvent never throws on a bogus userId (FK violation)", async () => {
|
||||
const bogusId = `usr_missing_${RUN_ID}`;
|
||||
|
||||
let threw: unknown = null;
|
||||
try {
|
||||
await logSecurityEvent({
|
||||
type: SecurityEventType.SIGNUP_FAILED,
|
||||
userId: bogusId,
|
||||
email: `${LOCAL_PART}-missing@example.com`,
|
||||
ip: PLAINTEXT_IP,
|
||||
metadata: { marker: MARKER, reason: "bogus-user" },
|
||||
});
|
||||
} catch (error) {
|
||||
threw = error;
|
||||
}
|
||||
|
||||
// The FK error must be swallowed, never propagated to the caller.
|
||||
expect(threw).toBe(null);
|
||||
|
||||
// And no row may exist for the nonexistent user.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(security_events)
|
||||
.where(eq(security_events.userId, bogusId));
|
||||
expect(rows.length).toBe(0);
|
||||
});
|
||||
|
||||
test("an event without a user or IP is still stored with nulls", async () => {
|
||||
await logSecurityEvent({
|
||||
type: SecurityEventType.PASSWORD_RESET_REQUESTED,
|
||||
email: `${LOCAL_PART}-nouser@example.com`,
|
||||
metadata: { marker: MARKER, reason: "account_not_found" },
|
||||
});
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(security_events)
|
||||
.where(eq(security_events.email, `${LOCAL_PART}-nouser@example.com`));
|
||||
expect(rows.length).toBe(1);
|
||||
|
||||
const row = rows[0];
|
||||
createdEventIds.push(row.id);
|
||||
expect(row.userId).toBeNull();
|
||||
expect(row.ipHash).toBeNull();
|
||||
expect(row.metadataJson).toEqual({ marker: MARKER, reason: "account_not_found" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
" node --import tsx tests/integration/security_events.test.ts",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leftovers from an interrupted earlier run would skew the assertions.
|
||||
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("Security events suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
98
tests/integration/tsconfig-paths-hooks.mjs
Normal file
98
tests/integration/tsconfig-paths-hooks.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* ESM hooks for running integration tests without `tsx`.
|
||||
*
|
||||
* The sandbox blocks esbuild's pipe-based service-worker spawn (EPERM), so
|
||||
* this loader performs the TypeScript transform in-process with the locally
|
||||
* installed `typescript` package (ts.transpileModule — pure JS, no child
|
||||
* process), resolves the tsconfig `@/*` path alias, probes extensions for
|
||||
* extensionless relative imports, and falls back to raw package-subpath
|
||||
* files (`next/headers` etc.) that lack an exports map.
|
||||
*
|
||||
* Usage: node --import ./tests/integration/tsconfig-paths-loader.mjs tests/integration/<suite>.test.ts
|
||||
*/
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve as pathResolve } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const ROOT = pathResolve(process.cwd());
|
||||
|
||||
const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
||||
|
||||
function isFile(p) {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a specifier target, probing extensions and index files. */
|
||||
function probe(base) {
|
||||
if (isFile(base)) return base;
|
||||
for (const ext of EXTENSIONS) {
|
||||
if (isFile(base + ext)) return base + ext;
|
||||
}
|
||||
for (const ext of EXTENSIONS) {
|
||||
if (isFile(join(base, "index" + ext))) return join(base, "index" + ext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fallback for bare package subpaths (e.g. `next/headers`) that lack an exports map. */
|
||||
function probePackage(spec) {
|
||||
const base = pathResolve(ROOT, "node_modules", spec);
|
||||
for (const ext of [".js", ".mjs", ".cjs", ".json"]) {
|
||||
if (isFile(base + ext)) return base + ext;
|
||||
}
|
||||
if (isFile(base)) return base;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith("@/")) {
|
||||
const target = probe(pathResolve(ROOT, "src", specifier.slice(2)));
|
||||
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
|
||||
} else if (
|
||||
(specifier.startsWith("./") || specifier.startsWith("../")) &&
|
||||
context.parentURL
|
||||
) {
|
||||
const parent = fileURLToPath(context.parentURL);
|
||||
const target = probe(pathResolve(dirname(parent), specifier));
|
||||
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
|
||||
} else if (!specifier.startsWith("node:")) {
|
||||
try {
|
||||
return await nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
const target = probePackage(specifier);
|
||||
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.startsWith("file:")) {
|
||||
const filePath = fileURLToPath(url);
|
||||
if (/\.(ts|tsx|mts|cts)$/.test(filePath)) {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const out = ts.transpileModule(source, {
|
||||
fileName: filePath,
|
||||
reportDiagnostics: false,
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
isolatedModules: true,
|
||||
esModuleInterop: true,
|
||||
allowJs: true,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
verbatimModuleSyntax: false,
|
||||
},
|
||||
});
|
||||
return { format: "module", source: out.outputText, shortCircuit: true };
|
||||
}
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
7
tests/integration/tsconfig-paths-loader.mjs
Normal file
7
tests/integration/tsconfig-paths-loader.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Loader entry: registers the path-alias hooks module for the current process.
|
||||
* Usage: node --import ./tests/integration/tsconfig-paths-loader.mjs tests/integration/<suite>.test.ts
|
||||
*/
|
||||
import { register } from "node:module";
|
||||
|
||||
register(new URL("./tsconfig-paths-hooks.mjs", import.meta.url));
|
||||
Reference in New Issue
Block a user