diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts index 4faa751..3f6f084 100644 --- a/apps/api-server/src/__tests__/e2e/auth.test.ts +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -39,14 +39,7 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; @@ -54,13 +47,8 @@ import { COOKIE_NAMES } from "../../utils/cookies.js"; import { hashToken } from "../../utils/crypto.js"; import { hashPassword } from "../../utils/password.js"; import { TEST_RP } from "../helpers/test-constants.js"; -import { - createTestDb, - createTestUser, - destroyTestDb, - runMigrations, - truncateAllTables, -} from "../helpers/test-db.js"; +import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js"; +import { withTestTransaction } from "../helpers/test-transaction.js"; /** Session expiry duration: 24 hours in milliseconds */ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; @@ -68,23 +56,17 @@ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; /** Login request expiry: 15 minutes */ const LOGIN_REQUEST_EXPIRY_MS = 15 * 60 * 1000; -let db: Kysely | undefined; - -function getDb(): Kysely { - if (!db) { - throw new Error("Database not initialized"); - } - return db; -} - /** * Create an API context with optional cookies */ -function createAPIContext(options?: { - sessionToken?: string; - loginRequestToken?: string; - deviceFingerprint?: string; -}): APIContext { +function createAPIContext( + db: Kysely, + options?: { + sessionToken?: string; + loginRequestToken?: string; + deviceFingerprint?: string; + }, +): APIContext { const reqHeaders = new Headers(); const cookies: string[] = []; @@ -106,7 +88,7 @@ function createAPIContext(options?: { } return { - db: getDb(), + db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, @@ -155,6 +137,7 @@ function assertDefined( * Create a session for a user and return the token */ async function createSession( + db: Kysely, userId: number, options?: { deviceId?: bigint }, ): Promise<{ token: string; sessionId: number }> { @@ -162,7 +145,7 @@ async function createSession( const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); - const result = await getDb() + const result = await db .insertInto("sessions") .values({ user_id: userId, @@ -181,6 +164,7 @@ async function createSession( * Create a login request for a user */ async function createLoginRequest( + db: Kysely, userId: number, email: string, options?: { @@ -193,7 +177,7 @@ async function createLoginRequest( const expiresAt = options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); - const result = await getDb() + const result = await db .insertInto("login_requests") .values({ user_id: userId, @@ -213,10 +197,11 @@ async function createLoginRequest( * Create a trusted device for a user */ async function createTrustedDevice( + db: Kysely, userId: number, fingerprint: string, ): Promise { - const result = await getDb() + const result = await db .insertInto("user_devices") .values({ user_id: userId, @@ -234,6 +219,7 @@ async function createTrustedDevice( * Create an email verification token */ async function createEmailVerification( + db: Kysely, userId: number, options?: { expiresAt?: Date }, ): Promise { @@ -241,7 +227,7 @@ async function createEmailVerification( const expiresAt = options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); - await getDb() + await db .insertInto("email_verifications") .values({ user_id: userId, @@ -257,13 +243,14 @@ async function createEmailVerification( * Create a password reset token */ async function createPasswordReset( + db: Kysely, userId: number, options?: { expiresAt?: Date; usedAt?: Date | null }, ): Promise { const token = `reset-${String(Date.now())}${String(Math.random())}`; const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); - await getDb() + await db .insertInto("password_resets") .values({ user_id: userId, @@ -278,18 +265,7 @@ async function createPasswordReset( // Test setup beforeAll(async () => { - await runMigrations(); - db = createTestDb(); -}); - -afterAll(async () => { - if (db) { - await destroyTestDb(db); - } -}); - -beforeEach(async () => { - await truncateAllTables(getDb()); + await initTestDb(); }); // ============================================================================= @@ -298,111 +274,125 @@ beforeEach(async () => { describe("auth.signup", () => { test("creates user with valid password", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - const result = await call( - router.auth.signup, - { email: "newuser@example.com", password: "StrongP@ssw0rd123!" }, - { context: ctx }, - ); + const result = await call( + router.auth.signup, + { email: "newuser@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); - expect(result.success).toBe(true); + expect(result.success).toBe(true); - // Verify user was created - const user = await getDb() - .selectFrom("users") - .selectAll() - .where("email", "=", "newuser@example.com") - .executeTakeFirst(); + // Verify user was created + const user = await db + .selectFrom("users") + .selectAll() + .where("email", "=", "newuser@example.com") + .executeTakeFirst(); - expect(user).toBeDefined(); - expect(user?.password_hash).not.toBeNull(); - expect(user?.email_verified_at).toBeNull(); + expect(user).toBeDefined(); + expect(user?.password_hash).not.toBeNull(); + expect(user?.email_verified_at).toBeNull(); - // Verify session cookie was set - const sessionToken = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); - // Verify session was created in DB - const sessions = await getDb() - .selectFrom("sessions") - .selectAll() - .where("user_id", "=", assertDefined(user).id) - .execute(); - expect(sessions.length).toBe(1); + // Verify session was created in DB + const sessions = await db + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(sessions.length).toBe(1); - // Verify email verification token was created - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", assertDefined(user).id) - .execute(); - expect(verifications.length).toBe(1); + // Verify email verification token was created + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(verifications.length).toBe(1); + }); }); test("normalizes email to lowercase", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - await call( - router.auth.signup, - { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, - { context: ctx }, - ); + await call( + router.auth.signup, + { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); - const user = await getDb() - .selectFrom("users") - .select(["email"]) - .where("email", "=", "uppercase@example.com") - .executeTakeFirst(); + const user = await db + .selectFrom("users") + .select(["email"]) + .where("email", "=", "uppercase@example.com") + .executeTakeFirst(); - expect(user).toBeDefined(); + expect(user).toBeDefined(); + }); }); test("rejects weak password", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - await expect( - call( - router.auth.signup, - { email: "weak@example.com", password: "password" }, - { context: ctx }, - ), - ).rejects.toThrow(); + await expect( + call( + router.auth.signup, + { email: "weak@example.com", password: "password" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); }); test("rejects duplicate email (anti-enumeration)", async () => { - // Create existing user - await createTestUser(getDb(), { email: "existing@example.com" }); + await withTestTransaction(getSharedDb(), async (db) => { + // Create existing user + await createTestUser(db, { email: "existing@example.com" }); - const ctx = createAPIContext(); + const ctx = createAPIContext(db); - await expect( - call( - router.auth.signup, - { email: "existing@example.com", password: "StrongP@ssw0rd123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Unable to create account"); + await expect( + call( + router.auth.signup, + { email: "existing@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Unable to create account"); + }); }); test("rejects signup without password or passkey", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - await expect( - call( - router.auth.signup, - { email: "noauth@example.com" }, - { context: ctx }, - ), - ).rejects.toThrow(); + await expect( + call( + router.auth.signup, + { email: "noauth@example.com" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); }); + // Note: This test uses getSharedDb() directly (not withTestTransaction) because + // the signup procedure internally uses db.transaction(), and Kysely doesn't support + // nested transactions. test("creates user with passkey", async () => { + const db = getSharedDb(); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const ctx = createAPIContext(); + const ctx = createAPIContext(db); // Step 1: Create registration options const { options, challengeId } = await call( @@ -415,7 +405,7 @@ describe("auth.signup", () => { const response = authenticator.createCredential(options); // Step 3: Signup with passkey - const signupCtx = createAPIContext(); + const signupCtx = createAPIContext(db); const result = await call( router.auth.signup, { @@ -428,7 +418,7 @@ describe("auth.signup", () => { expect(result.success).toBe(true); // Verify user was created - const user = await getDb() + const user = await db .selectFrom("users") .selectAll() .where("email", "=", "passkeyuser@example.com") @@ -439,7 +429,7 @@ describe("auth.signup", () => { expect(user?.email_verified_at).toBeNull(); // Verify passkey was stored - const passkeys = await getDb() + const passkeys = await db .selectFrom("passkeys") .selectAll() .where("user_id", "=", assertDefined(user).id) @@ -456,7 +446,7 @@ describe("auth.signup", () => { expect(sessionToken).not.toBeNull(); // Verify webauthn challenge was deleted - const challenges = await getDb() + const challenges = await db .selectFrom("webauthn_challenges") .selectAll() .where("id", "=", String(challengeId)) @@ -465,79 +455,87 @@ describe("auth.signup", () => { }); test("rejects passkey signup with expired challenge", async () => { - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const ctx = createAPIContext(db); - // Step 1: Create registration options - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: "expiredchallenge@example.com" }, - { context: ctx }, - ); + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "expiredchallenge@example.com" }, + { context: ctx }, + ); - // Step 2: Create credential - const response = authenticator.createCredential(options); + // Step 2: Create credential + const response = authenticator.createCredential(options); - // Step 3: Expire the challenge by updating created_at - await getDb() - .updateTable("webauthn_challenges") - .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago - .where("id", "=", String(challengeId)) - .execute(); + // Step 3: Expire the challenge by updating created_at + await db + .updateTable("webauthn_challenges") + .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago + .where("id", "=", String(challengeId)) + .execute(); - // Step 4: Try to signup with expired challenge - const signupCtx = createAPIContext(); + // Step 4: Try to signup with expired challenge + const signupCtx = createAPIContext(db); - await expect( - call( - router.auth.signup, - { - email: "expiredchallenge@example.com", - passkeyInfo: { challengeId, response }, - }, - { context: signupCtx }, - ), - ).rejects.toThrow("Registration timed out"); + await expect( + call( + router.auth.signup, + { + email: "expiredchallenge@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Registration timed out"); + }); }); test("rejects passkey signup with invalid response", async () => { - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const ctx = createAPIContext(db); - // Step 1: Create registration options - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: "invalidresponse@example.com" }, - { context: ctx }, - ); + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "invalidresponse@example.com" }, + { context: ctx }, + ); - // Step 2: Create credential - const response = authenticator.createCredential(options); + // Step 2: Create credential + const response = authenticator.createCredential(options); - // Step 3: Tamper with the response - response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 + // Step 3: Tamper with the response + response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 - // Step 4: Try to signup with invalid response - const signupCtx = createAPIContext(); + // Step 4: Try to signup with invalid response + const signupCtx = createAPIContext(db); - await expect( - call( - router.auth.signup, - { - email: "invalidresponse@example.com", - passkeyInfo: { challengeId, response }, - }, - { context: signupCtx }, - ), - ).rejects.toThrow("Failed to register your device"); + await expect( + call( + router.auth.signup, + { + email: "invalidresponse@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Failed to register your device"); - // Verify challenge was deleted (cleanup on error) - const challenges = await getDb() - .selectFrom("webauthn_challenges") - .selectAll() - .where("id", "=", String(challengeId)) - .execute(); - expect(challenges.length).toBe(0); + // Verify challenge was deleted (cleanup on error) + const challenges = await db + .selectFrom("webauthn_challenges") + .selectAll() + .where("id", "=", String(challengeId)) + .execute(); + expect(challenges.length).toBe(0); + }); }); }); @@ -547,121 +545,131 @@ describe("auth.signup", () => { describe("auth.createLoginRequest", () => { test("returns auth methods for existing user with password", async () => { - await createTestUser(getDb(), { - email: "haspassword@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + await createTestUser(db, { + email: "haspassword@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.createLoginRequest, + { email: "haspassword@example.com" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + expect(result.email).toBe("haspassword@example.com"); + + // Verify login request was created + const loginRequests = await db + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(1); + + // Verify login request token cookie was set + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); + expect(token).toStartWith("login_"); }); - - const ctx = createAPIContext(); - const result = await call( - router.auth.createLoginRequest, - { email: "haspassword@example.com" }, - { context: ctx }, - ); - - expect(result.hasPassword).toBe(true); - expect(result.hasPasskey).toBe(false); - expect(result.isTrustedDevice).toBe(false); - expect(result.email).toBe("haspassword@example.com"); - - // Verify login request was created - const loginRequests = await getDb() - .selectFrom("login_requests") - .selectAll() - .execute(); - expect(loginRequests.length).toBe(1); - - // Verify login request token cookie was set - const token = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - expect(token).not.toBeNull(); - expect(token).toStartWith("login_"); }); test("detects trusted device", async () => { - const user = await createTestUser(getDb(), { - email: "trusted@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-device-fp"; + await createTrustedDevice(db, user.id, fingerprint); + + const ctx = createAPIContext(db, { deviceFingerprint: fingerprint }); + const result = await call( + router.auth.createLoginRequest, + { email: "trusted@example.com" }, + { context: ctx }, + ); + + expect(result.isTrustedDevice).toBe(true); }); - - const fingerprint = "trusted-device-fp"; - await createTrustedDevice(user.id, fingerprint); - - const ctx = createAPIContext({ deviceFingerprint: fingerprint }); - const result = await call( - router.auth.createLoginRequest, - { email: "trusted@example.com" }, - { context: ctx }, - ); - - expect(result.isTrustedDevice).toBe(true); }); test("returns fake response for non-existent user (anti-enumeration)", async () => { - const ctx = createAPIContext(); - const result = await call( - router.auth.createLoginRequest, - { email: "nonexistent@example.com" }, - { context: ctx }, - ); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); + const result = await call( + router.auth.createLoginRequest, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); - // Should return all false (same as user without any auth methods) - expect(result.hasPassword).toBe(false); - expect(result.hasPasskey).toBe(false); - expect(result.isTrustedDevice).toBe(false); + // Should return all false (same as user without any auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); - // Should still set a login request token cookie (fake one) - const token = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - expect(token).not.toBeNull(); + // Should still set a login request token cookie (fake one) + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); - // Should NOT create a login request in DB - const loginRequests = await getDb() - .selectFrom("login_requests") - .selectAll() - .execute(); - expect(loginRequests.length).toBe(0); + // Should NOT create a login request in DB + const loginRequests = await db + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(0); + }); }); test("normalizes email to lowercase", async () => { - await createTestUser(getDb(), { - email: "lowercase@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + await createTestUser(db, { + email: "lowercase@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.createLoginRequest, + { email: "LOWERCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); }); - - const ctx = createAPIContext(); - const result = await call( - router.auth.createLoginRequest, - { email: "LOWERCASE@EXAMPLE.COM" }, - { context: ctx }, - ); - - expect(result.hasPassword).toBe(true); }); test("generates device fingerprint if not present", async () => { - await createTestUser(getDb(), { - email: "nofingerprint@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + await createTestUser(db, { + email: "nofingerprint@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(db); // No device fingerprint + await call( + router.auth.createLoginRequest, + { email: "nofingerprint@example.com" }, + { context: ctx }, + ); + + // Should set device fingerprint cookie + const fingerprint = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.DEVICE_FINGERPRINT, + ); + expect(fingerprint).not.toBeNull(); }); - - const ctx = createAPIContext(); // No device fingerprint - await call( - router.auth.createLoginRequest, - { email: "nofingerprint@example.com" }, - { context: ctx }, - ); - - // Should set device fingerprint cookie - const fingerprint = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.DEVICE_FINGERPRINT, - ); - expect(fingerprint).not.toBeNull(); }); }); @@ -671,168 +679,189 @@ describe("auth.createLoginRequest", () => { describe("auth.loginPassword", () => { test("completes login immediately for trusted device", async () => { - const user = await createTestUser(getDb(), { - email: "trustedlogin@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-login-fp"; + await createTrustedDevice(db, user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "trustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext(db, { + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await db + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); }); - - const fingerprint = "trusted-login-fp"; - await createTrustedDevice(user.id, fingerprint); - - const { token: loginToken } = await createLoginRequest( - user.id, - "trustedlogin@example.com", - { deviceFingerprint: fingerprint }, - ); - - const ctx = createAPIContext({ - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, - }); - - const result = await call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify login request was marked as completed - const loginRequest = await getDb() - .selectFrom("login_requests") - .select(["completed_at"]) - .where("token", "=", loginToken) - .executeTakeFirst(); - - expect(loginRequest?.completed_at).not.toBeNull(); }); test("sends email for untrusted device (does not complete immediately)", async () => { - const user = await createTestUser(getDb(), { - email: "untrustedlogin@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "untrustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "untrusted-login-fp"; + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "untrustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext(db, { + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was NOT marked as completed (needs email confirmation) + const loginRequest = await db + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).toBeNull(); }); - - const fingerprint = "untrusted-login-fp"; - const { token: loginToken } = await createLoginRequest( - user.id, - "untrustedlogin@example.com", - { deviceFingerprint: fingerprint }, - ); - - const ctx = createAPIContext({ - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, - }); - - const result = await call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify login request was NOT marked as completed (needs email confirmation) - const loginRequest = await getDb() - .selectFrom("login_requests") - .select(["completed_at"]) - .where("token", "=", loginToken) - .executeTakeFirst(); - - expect(loginRequest?.completed_at).toBeNull(); }); test("rejects invalid password", async () => { - const user = await createTestUser(getDb(), { - email: "wrongpass@example.com", - passwordHash: await hashPassword("CorrectPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "wrongpass@example.com", + passwordHash: await hashPassword("CorrectPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "wrongpass@example.com", + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "WrongPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "wrongpass@example.com", - ); - - const ctx = createAPIContext({ loginRequestToken: loginToken }); - - await expect( - call( - router.auth.loginPassword, - { password: "WrongPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); }); test("rejects expired login request", async () => { - const user = await createTestUser(getDb(), { - email: "expired@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expired@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "expired@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Login request has expired"); }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "expired@example.com", - { expiresAt: new Date(Date.now() - 1000) }, // Expired - ); - - const ctx = createAPIContext({ loginRequestToken: loginToken }); - - await expect( - call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Login request has expired"); }); test("rejects when no login request token cookie", async () => { - const ctx = createAPIContext(); // No login request token + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No login request token - await expect( - call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); }); test("rejects fake/invalid login request token", async () => { - const ctx = createAPIContext({ loginRequestToken: "fake-token-12345" }); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db, { + loginRequestToken: "fake-token-12345", + }); - await expect( - call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); }); test("rejects user without password set", async () => { - const user = await createTestUser(getDb(), { - email: "nopassword@example.com", - // No password hash + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nopassword@example.com", + // No password hash + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "nopassword@example.com", + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "nopassword@example.com", - ); - - const ctx = createAPIContext({ loginRequestToken: loginToken }); - - await expect( - call( - router.auth.loginPassword, - { password: "AnyPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); }); }); @@ -842,87 +871,98 @@ describe("auth.loginPassword", () => { describe("auth.loginPasswordConfirm", () => { test("marks login request as completed with valid token", async () => { - const user = await createTestUser(getDb(), { - email: "confirm@example.com", - }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "confirm@example.com", + }); - const { token: loginToken } = await createLoginRequest( - user.id, - "confirm@example.com", - ); + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "confirm@example.com", + ); - const ctx = createAPIContext(); - const result = await call( - router.auth.loginPasswordConfirm, - { token: loginToken }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify login request was marked as completed - const loginRequest = await getDb() - .selectFrom("login_requests") - .select(["completed_at"]) - .where("token", "=", loginToken) - .executeTakeFirst(); - - expect(loginRequest?.completed_at).not.toBeNull(); - }); - - test("is idempotent for already completed requests", async () => { - const user = await createTestUser(getDb(), { - email: "idempotent@example.com", - }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "idempotent@example.com", - { completedAt: new Date() }, // Already completed - ); - - const ctx = createAPIContext(); - const result = await call( - router.auth.loginPasswordConfirm, - { token: loginToken }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - }); - - test("rejects invalid token", async () => { - const ctx = createAPIContext(); - - await expect( - call( - router.auth.loginPasswordConfirm, - { token: "invalid-token" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired confirmation link"); - }); - - test("rejects expired token", async () => { - const user = await createTestUser(getDb(), { - email: "expiredconfirm@example.com", - }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "expiredconfirm@example.com", - { expiresAt: new Date(Date.now() - 1000) }, // Expired - ); - - const ctx = createAPIContext(); - - await expect( - call( + const ctx = createAPIContext(db); + const result = await call( router.auth.loginPasswordConfirm, { token: loginToken }, { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired confirmation link"); + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await db + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); + }); + }); + + test("is idempotent for already completed requests", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "idempotent@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "idempotent@example.com", + { completedAt: new Date() }, // Already completed + ); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + }); + }); + + test("rejects invalid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); + }); + + test("rejects expired token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredconfirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "expiredconfirm@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); }); }); @@ -932,181 +972,198 @@ describe("auth.loginPasswordConfirm", () => { describe("auth.loginIfRequestIsCompleted", () => { test("returns pending for incomplete login request", async () => { - const user = await createTestUser(getDb(), { - email: "pending@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "pending@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "pending@example.com", + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "pending@example.com", - ); - - const ctx = createAPIContext({ loginRequestToken: loginToken }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); - - expect(result.status).toBe("pending"); }); test("returns expired for expired login request", async () => { - const user = await createTestUser(getDb(), { - email: "expiredpoll@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredpoll@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "expiredpoll@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("expired"); }); - - const { token: loginToken } = await createLoginRequest( - user.id, - "expiredpoll@example.com", - { expiresAt: new Date(Date.now() - 1000) }, // Expired - ); - - const ctx = createAPIContext({ loginRequestToken: loginToken }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); - - expect(result.status).toBe("expired"); }); test("creates session and returns completed for completed request", async () => { - const user = await createTestUser(getDb(), { - email: "completed@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "completed@example.com", + }); + + const fingerprint = "completed-fp"; + const { token: loginToken, id: loginRequestId } = + await createLoginRequest(db, user.id, "completed@example.com", { + deviceFingerprint: fingerprint, + completedAt: new Date(), + }); + + const ctx = createAPIContext(db, { + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet + + // Verify session was created + const sessions = await db + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify login request was deleted + const loginRequest = await db + .selectFrom("login_requests") + .selectAll() + .where("id", "=", String(loginRequestId)) + .executeTakeFirst(); + expect(loginRequest).toBeUndefined(); + + // Verify user device was created + const devices = await db + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(devices.length).toBe(1); }); - - const fingerprint = "completed-fp"; - const { token: loginToken, id: loginRequestId } = await createLoginRequest( - user.id, - "completed@example.com", - { deviceFingerprint: fingerprint, completedAt: new Date() }, - ); - - const ctx = createAPIContext({ - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, - }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); - - expect(result.status).toBe("completed"); - expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet - - // Verify session was created - const sessions = await getDb() - .selectFrom("sessions") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(sessions.length).toBe(1); - expect(sessions[0]?.trusted_mode).toBe(true); - - // Verify session cookie was set - const sessionToken = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); - - // Verify login request was deleted - const loginRequest = await getDb() - .selectFrom("login_requests") - .selectAll() - .where("id", "=", String(loginRequestId)) - .executeTakeFirst(); - expect(loginRequest).toBeUndefined(); - - // Verify user device was created - const devices = await getDb() - .selectFrom("user_devices") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(devices.length).toBe(1); }); test("redirects to dashboard if device is already trusted", async () => { - const user = await createTestUser(getDb(), { - email: "alreadytrusted@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "alreadytrusted@example.com", + }); + + const fingerprint = "already-trusted-fp"; + await createTrustedDevice(db, user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "alreadytrusted@example.com", + { deviceFingerprint: fingerprint, completedAt: new Date() }, + ); + + const ctx = createAPIContext(db, { + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/dashboard"); }); - - const fingerprint = "already-trusted-fp"; - await createTrustedDevice(user.id, fingerprint); - - const { token: loginToken } = await createLoginRequest( - user.id, - "alreadytrusted@example.com", - { deviceFingerprint: fingerprint, completedAt: new Date() }, - ); - - const ctx = createAPIContext({ - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, - }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); - - expect(result.status).toBe("completed"); - expect(result.redirectTo).toBe("/dashboard"); }); test("returns pending for fake/non-existent token", async () => { - const ctx = createAPIContext({ loginRequestToken: "fake-token-xyz" }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); - expect(result.status).toBe("pending"); + expect(result.status).toBe("pending"); + }); }); test("returns pending when no cookie present", async () => { - const ctx = createAPIContext(); // No login request token - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No login request token + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); - expect(result.status).toBe("pending"); + expect(result.status).toBe("pending"); + }); }); test("returns pending when device fingerprint is missing", async () => { - const user = await createTestUser(getDb(), { - email: "nofp@example.com", - }); - - // Create login request without device fingerprint - const token = `login_test-${String(Date.now())}`; - await getDb() - .insertInto("login_requests") - .values({ - user_id: user.id, + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "nofp@example.com", - token, - device_fingerprint: null, // No fingerprint - expires_at: new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS), - completed_at: new Date(), - }) - .execute(); + }); - const ctx = createAPIContext({ loginRequestToken: token }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); + // Create login request without device fingerprint + const token = `login_test-${String(Date.now())}`; + await db + .insertInto("login_requests") + .values({ + user_id: user.id, + email: "nofp@example.com", + token, + device_fingerprint: null, // No fingerprint + expires_at: new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS), + completed_at: new Date(), + }) + .execute(); - expect(result.status).toBe("pending"); + const ctx = createAPIContext(db, { loginRequestToken: token }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); }); }); @@ -1116,73 +1173,79 @@ describe("auth.loginIfRequestIsCompleted", () => { describe("auth.verifyEmail", () => { test("verifies email with valid token", async () => { - const user = await createTestUser(getDb(), { - email: "verify@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "verify@example.com", + }); + + const token = await createEmailVerification(db, user.id); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.verifyEmail, + { token }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify user's email_verified_at was set + const updatedUser = await db + .selectFrom("users") + .select(["email_verified_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.email_verified_at).not.toBeNull(); + + // Verify verification record was deleted + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); }); - - const token = await createEmailVerification(user.id); - - const ctx = createAPIContext(); - const result = await call( - router.auth.verifyEmail, - { token }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify user's email_verified_at was set - const updatedUser = await getDb() - .selectFrom("users") - .select(["email_verified_at"]) - .where("id", "=", user.id) - .executeTakeFirst(); - - expect(updatedUser?.email_verified_at).not.toBeNull(); - - // Verify verification record was deleted - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(verifications.length).toBe(0); }); test("rejects invalid token", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - await expect( - call( - router.auth.verifyEmail, - { token: "invalid-token" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired token"); + await expect( + call( + router.auth.verifyEmail, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired token"); + }); }); test("rejects expired token and cleans up", async () => { - const user = await createTestUser(getDb(), { - email: "expiredverify@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredverify@example.com", + }); + + const token = await createEmailVerification(db, user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(db); + + await expect( + call(router.auth.verifyEmail, { token }, { context: ctx }), + ).rejects.toThrow("Invalid or expired token"); + + // Verify expired token was cleaned up + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); }); - - const token = await createEmailVerification(user.id, { - expiresAt: new Date(Date.now() - 1000), // Expired - }); - - const ctx = createAPIContext(); - - await expect( - call(router.auth.verifyEmail, { token }, { context: ctx }), - ).rejects.toThrow("Invalid or expired token"); - - // Verify expired token was cleaned up - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(verifications.length).toBe(0); }); }); @@ -1192,82 +1255,98 @@ describe("auth.verifyEmail", () => { describe("auth.resendVerificationEmail", () => { test("creates new verification token for unverified user", async () => { - const user = await createTestUser(getDb(), { - email: "resend@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resend@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + + const ctx = createAPIContext(db, { sessionToken }); + const result = await call( + router.auth.resendVerificationEmail, + undefined, + { + context: ctx, + }, + ); + + expect(result.success).toBe(true); + + // Verify new verification token was created + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); }); - - const { token: sessionToken } = await createSession(user.id); - - const ctx = createAPIContext({ sessionToken }); - const result = await call(router.auth.resendVerificationEmail, undefined, { - context: ctx, - }); - - expect(result.success).toBe(true); - - // Verify new verification token was created - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(verifications.length).toBe(1); }); test("deletes old verification tokens before creating new one", async () => { - const user = await createTestUser(getDb(), { - email: "resendold@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resendold@example.com", + }); + + // Create existing verification + await createEmailVerification(db, user.id); + + const { token: sessionToken } = await createSession(db, user.id); + + const ctx = createAPIContext(db, { sessionToken }); + await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + // Should still have only 1 verification (old one deleted, new one created) + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); }); - - // Create existing verification - await createEmailVerification(user.id); - - const { token: sessionToken } = await createSession(user.id); - - const ctx = createAPIContext({ sessionToken }); - await call(router.auth.resendVerificationEmail, undefined, { - context: ctx, - }); - - // Should still have only 1 verification (old one deleted, new one created) - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(verifications.length).toBe(1); }); test("returns success for already verified user (no-op)", async () => { - const user = await createTestUser(getDb(), { - email: "alreadyverified@example.com", - emailVerifiedAt: new Date(), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "alreadyverified@example.com", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, user.id); + + const ctx = createAPIContext(db, { sessionToken }); + const result = await call( + router.auth.resendVerificationEmail, + undefined, + { + context: ctx, + }, + ); + + expect(result.success).toBe(true); + + // No verification token should be created + const verifications = await db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); }); - - const { token: sessionToken } = await createSession(user.id); - - const ctx = createAPIContext({ sessionToken }); - const result = await call(router.auth.resendVerificationEmail, undefined, { - context: ctx, - }); - - expect(result.success).toBe(true); - - // No verification token should be created - const verifications = await getDb() - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(verifications.length).toBe(0); }); test("requires authentication", async () => { - const ctx = createAPIContext(); // No session + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No session - await expect( - call(router.auth.resendVerificationEmail, undefined, { context: ctx }), - ).rejects.toThrow(); + await expect( + call(router.auth.resendVerificationEmail, undefined, { context: ctx }), + ).rejects.toThrow(); + }); }); }); @@ -1277,90 +1356,98 @@ describe("auth.resendVerificationEmail", () => { describe("auth.forgotPassword", () => { test("creates password reset token for existing user", async () => { - const user = await createTestUser(getDb(), { - email: "forgot@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "forgot@example.com", + }); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.forgotPassword, + { email: "forgot@example.com" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password reset token was created + const resets = await db + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); }); - - const ctx = createAPIContext(); - const result = await call( - router.auth.forgotPassword, - { email: "forgot@example.com" }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify password reset token was created - const resets = await getDb() - .selectFrom("password_resets") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(resets.length).toBe(1); }); test("returns success for non-existent user (anti-enumeration)", async () => { - const ctx = createAPIContext(); - const result = await call( - router.auth.forgotPassword, - { email: "nonexistent@example.com" }, - { context: ctx }, - ); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); + const result = await call( + router.auth.forgotPassword, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); - // Should still return success (anti-enumeration) - expect(result.success).toBe(true); + // Should still return success (anti-enumeration) + expect(result.success).toBe(true); - // No password reset should be created - const resets = await getDb() - .selectFrom("password_resets") - .selectAll() - .execute(); - expect(resets.length).toBe(0); + // No password reset should be created + const resets = await db + .selectFrom("password_resets") + .selectAll() + .execute(); + expect(resets.length).toBe(0); + }); }); test("deletes existing password reset tokens before creating new one", async () => { - const user = await createTestUser(getDb(), { - email: "forgotold@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "forgotold@example.com", + }); + + // Create existing reset token + await createPasswordReset(db, user.id); + + const ctx = createAPIContext(db); + await call( + router.auth.forgotPassword, + { email: "forgotold@example.com" }, + { context: ctx }, + ); + + // Should have only 1 reset token (old one deleted) + const resets = await db + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); }); - - // Create existing reset token - await createPasswordReset(user.id); - - const ctx = createAPIContext(); - await call( - router.auth.forgotPassword, - { email: "forgotold@example.com" }, - { context: ctx }, - ); - - // Should have only 1 reset token (old one deleted) - const resets = await getDb() - .selectFrom("password_resets") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(resets.length).toBe(1); }); test("normalizes email to lowercase", async () => { - const user = await createTestUser(getDb(), { - email: "forgotcase@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "forgotcase@example.com", + }); + + const ctx = createAPIContext(db); + await call( + router.auth.forgotPassword, + { email: "FORGOTCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + // Should find the user and create reset token + const resets = await db + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); }); - - const ctx = createAPIContext(); - await call( - router.auth.forgotPassword, - { email: "FORGOTCASE@EXAMPLE.COM" }, - { context: ctx }, - ); - - // Should find the user and create reset token - const resets = await getDb() - .selectFrom("password_resets") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - expect(resets.length).toBe(1); }); }); @@ -1370,140 +1457,152 @@ describe("auth.forgotPassword", () => { describe("auth.resetPassword", () => { test("resets password with valid token", async () => { - const user = await createTestUser(getDb(), { - email: "reset@example.com", - passwordHash: await hashPassword("OldPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + const token = await createPasswordReset(db, user.id); + + const ctx = createAPIContext(db); + const result = await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password was updated (can't directly verify hash, but check updated_at) + const updatedUser = await db + .selectFrom("users") + .select(["password_hash", "updated_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.password_hash).not.toBeNull(); + + // Verify reset token was marked as used + const reset = await db + .selectFrom("password_resets") + .select(["used_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + expect(reset?.used_at).not.toBeNull(); }); - - const token = await createPasswordReset(user.id); - - const ctx = createAPIContext(); - const result = await call( - router.auth.resetPassword, - { token, newPassword: "NewStrongP@ssw0rd!" }, - { context: ctx }, - ); - - expect(result.success).toBe(true); - - // Verify password was updated (can't directly verify hash, but check updated_at) - const updatedUser = await getDb() - .selectFrom("users") - .select(["password_hash", "updated_at"]) - .where("id", "=", user.id) - .executeTakeFirst(); - - expect(updatedUser?.password_hash).not.toBeNull(); - - // Verify reset token was marked as used - const reset = await getDb() - .selectFrom("password_resets") - .select(["used_at"]) - .where("token", "=", token) - .executeTakeFirst(); - - expect(reset?.used_at).not.toBeNull(); }); test("revokes all sessions after password reset", async () => { - const user = await createTestUser(getDb(), { - email: "resetrevoke@example.com", - passwordHash: await hashPassword("OldPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetrevoke@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + // Create some sessions + await createSession(db, user.id); + await createSession(db, user.id); + + const token = await createPasswordReset(db, user.id); + + const ctx = createAPIContext(db); + await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + // Verify all sessions were revoked + const sessions = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } }); - - // Create some sessions - await createSession(user.id); - await createSession(user.id); - - const token = await createPasswordReset(user.id); - - const ctx = createAPIContext(); - await call( - router.auth.resetPassword, - { token, newPassword: "NewStrongP@ssw0rd!" }, - { context: ctx }, - ); - - // Verify all sessions were revoked - const sessions = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("user_id", "=", user.id) - .execute(); - - for (const session of sessions) { - expect(session.revoked_at).not.toBeNull(); - } }); test("rejects invalid token", async () => { - const ctx = createAPIContext(); + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - await expect( - call( - router.auth.resetPassword, - { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired reset token"); + await expect( + call( + router.auth.resetPassword, + { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired reset token"); + }); }); test("rejects expired token", async () => { - const user = await createTestUser(getDb(), { - email: "resetexpired@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetexpired@example.com", + }); + + const token = await createPasswordReset(db, user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has expired"); }); - - const token = await createPasswordReset(user.id, { - expiresAt: new Date(Date.now() - 1000), // Expired - }); - - const ctx = createAPIContext(); - - await expect( - call( - router.auth.resetPassword, - { token, newPassword: "NewStrongP@ssw0rd!" }, - { context: ctx }, - ), - ).rejects.toThrow("Reset token has expired"); }); test("rejects already used token", async () => { - const user = await createTestUser(getDb(), { - email: "resetused@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetused@example.com", + }); + + const token = await createPasswordReset(db, user.id, { + usedAt: new Date(), // Already used + }); + + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has already been used"); }); - - const token = await createPasswordReset(user.id, { - usedAt: new Date(), // Already used - }); - - const ctx = createAPIContext(); - - await expect( - call( - router.auth.resetPassword, - { token, newPassword: "NewStrongP@ssw0rd!" }, - { context: ctx }, - ), - ).rejects.toThrow("Reset token has already been used"); }); test("rejects weak password", async () => { - const user = await createTestUser(getDb(), { - email: "resetweak@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetweak@example.com", + }); + + const token = await createPasswordReset(db, user.id); + + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "weak" }, + { context: ctx }, + ), + ).rejects.toThrow(); }); - - const token = await createPasswordReset(user.id); - - const ctx = createAPIContext(); - - await expect( - call( - router.auth.resetPassword, - { token, newPassword: "weak" }, - { context: ctx }, - ), - ).rejects.toThrow(); }); }); @@ -1513,40 +1612,49 @@ describe("auth.resetPassword", () => { describe("auth.logout", () => { test("revokes current session", async () => { - const user = await createTestUser(getDb(), { - email: "logout@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "logout@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession( + db, + user.id, + ); + + const ctx = createAPIContext(db, { sessionToken }); + const result = await call(router.auth.logout, undefined, { + context: ctx, + }); + + expect(result.success).toBe(true); + + // Verify session was revoked + const session = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirst(); + + expect(session?.revoked_at).not.toBeNull(); + + // Verify session cookie was deleted + const setCookies = ctx.resHeaders.getSetCookie(); + const sessionCookie = setCookies.find((c) => + c.startsWith(`${COOKIE_NAMES.SESSION_TOKEN}=`), + ); + expect(sessionCookie).toContain("Max-Age=0"); }); - - const { token: sessionToken, sessionId } = await createSession(user.id); - - const ctx = createAPIContext({ sessionToken }); - const result = await call(router.auth.logout, undefined, { context: ctx }); - - expect(result.success).toBe(true); - - // Verify session was revoked - const session = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("id", "=", String(sessionId)) - .executeTakeFirst(); - - expect(session?.revoked_at).not.toBeNull(); - - // Verify session cookie was deleted - const setCookies = ctx.resHeaders.getSetCookie(); - const sessionCookie = setCookies.find((c) => - c.startsWith(`${COOKIE_NAMES.SESSION_TOKEN}=`), - ); - expect(sessionCookie).toContain("Max-Age=0"); }); test("requires authentication", async () => { - const ctx = createAPIContext(); // No session + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No session - await expect( - call(router.auth.logout, undefined, { context: ctx }), - ).rejects.toThrow(); + await expect( + call(router.auth.logout, undefined, { context: ctx }), + ).rejects.toThrow(); + }); }); }); @@ -1556,430 +1664,444 @@ describe("auth.logout", () => { describe("End-to-end login scenarios", () => { test("Scenario: Password login with trusted device (immediate completion)", async () => { - // Setup: User with password and trusted device - const user = await createTestUser(getDb(), { - email: "e2e-trusted@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + // Setup: User with password and trusted device + const user = await createTestUser(db, { + email: "e2e-trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-trusted-device"; + await createTrustedDevice(db, user.id, fingerprint); + + // Step 1: Create login request + const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-trusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should complete immediately for trusted device) + const ctx2 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll for completion + const ctx3 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/dashboard"); // Already trusted + + // Verify session was created + const sessionToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); }); - - const fingerprint = "e2e-trusted-device"; - await createTrustedDevice(user.id, fingerprint); - - // Step 1: Create login request - const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); - const loginRequestResult = await call( - router.auth.createLoginRequest, - { email: "e2e-trusted@example.com" }, - { context: ctx1 }, - ); - - expect(loginRequestResult.hasPassword).toBe(true); - expect(loginRequestResult.isTrustedDevice).toBe(true); - - const loginToken = getCookieFromResponse( - ctx1.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - - // Step 2: Login with password (should complete immediately for trusted device) - const ctx2 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - await call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx2 }, - ); - - // Step 3: Poll for completion - const ctx3 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const completedResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx3 }, - ); - - expect(completedResult.status).toBe("completed"); - expect(completedResult.redirectTo).toBe("/dashboard"); // Already trusted - - // Verify session was created - const sessionToken = getCookieFromResponse( - ctx3.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); }); test("Scenario: Password login with untrusted device (requires email confirmation)", async () => { - // Setup: User with password but no trusted device - await createTestUser(getDb(), { - email: "e2e-untrusted@example.com", - passwordHash: await hashPassword("TestPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + // Setup: User with password but no trusted device + await createTestUser(db, { + email: "e2e-untrusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-untrusted-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-untrusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should NOT complete - needs email confirmation) + const ctx2 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll should return pending (email not confirmed yet) + const ctx3 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // Step 4: User clicks email confirmation link + const ctx4 = createAPIContext(db); + await call( + router.auth.loginPasswordConfirm, + { token: assertDefined(loginToken) }, + { context: ctx4 }, + ); + + // Step 5: Poll should now return completed + const ctx5 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx5 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/auth/trust-device"); // Not yet trusted }); - - const fingerprint = "e2e-untrusted-device"; - - // Step 1: Create login request - const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); - const loginRequestResult = await call( - router.auth.createLoginRequest, - { email: "e2e-untrusted@example.com" }, - { context: ctx1 }, - ); - - expect(loginRequestResult.hasPassword).toBe(true); - expect(loginRequestResult.isTrustedDevice).toBe(false); - - const loginToken = getCookieFromResponse( - ctx1.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - - // Step 2: Login with password (should NOT complete - needs email confirmation) - const ctx2 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - await call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx2 }, - ); - - // Step 3: Poll should return pending (email not confirmed yet) - const ctx3 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const pendingResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx3 }, - ); - - expect(pendingResult.status).toBe("pending"); - - // Step 4: User clicks email confirmation link - const ctx4 = createAPIContext(); - await call( - router.auth.loginPasswordConfirm, - { token: assertDefined(loginToken) }, - { context: ctx4 }, - ); - - // Step 5: Poll should now return completed - const ctx5 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const completedResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx5 }, - ); - - expect(completedResult.status).toBe("completed"); - expect(completedResult.redirectTo).toBe("/auth/trust-device"); // Not yet trusted }); test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => { - // Step 1: Create login request for non-existent email - const ctx1 = createAPIContext(); - const result = await call( - router.auth.createLoginRequest, - { email: "doesnotexist@example.com" }, - { context: ctx1 }, - ); + await withTestTransaction(getSharedDb(), async (db) => { + // Step 1: Create login request for non-existent email + const ctx1 = createAPIContext(db); + const result = await call( + router.auth.createLoginRequest, + { email: "doesnotexist@example.com" }, + { context: ctx1 }, + ); - // Should return all false (indistinguishable from user without auth methods) - expect(result.hasPassword).toBe(false); - expect(result.hasPasskey).toBe(false); - expect(result.isTrustedDevice).toBe(false); + // Should return all false (indistinguishable from user without auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); - const loginToken = getCookieFromResponse( - ctx1.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - expect(loginToken).not.toBeNull(); // Still get a token (fake) + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); // Still get a token (fake) - // Step 2: Trying to login with password should fail - const ctx2 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), + // Step 2: Trying to login with password should fail + const ctx2 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + }); + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx2 }, + ), + ).rejects.toThrow("Invalid email or password"); + + // Step 3: Polling should return pending until expired + const ctx3 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + }); + const pollResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pollResult.status).toBe("pending"); // Fake token - always pending }); - await expect( - call( - router.auth.loginPassword, - { password: "AnyPassword123!" }, - { context: ctx2 }, - ), - ).rejects.toThrow("Invalid email or password"); - - // Step 3: Polling should return pending until expired - const ctx3 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - }); - const pollResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx3 }, - ); - - expect(pollResult.status).toBe("pending"); // Fake token - always pending }); test("Scenario: Complete password reset flow", async () => { - // Setup: User with existing password and sessions - const user = await createTestUser(getDb(), { - email: "e2e-reset@example.com", - passwordHash: await hashPassword("OldPassword123!"), + await withTestTransaction(getSharedDb(), async (db) => { + // Setup: User with existing password and sessions + const user = await createTestUser(db, { + email: "e2e-reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + await createSession(db, user.id); + await createSession(db, user.id); + + // Step 1: Request password reset + const ctx1 = createAPIContext(db); + await call( + router.auth.forgotPassword, + { email: "e2e-reset@example.com" }, + { context: ctx1 }, + ); + + // Get the token from DB (in real flow, this would be from email) + const reset = await db + .selectFrom("password_resets") + .select(["token"]) + .where("user_id", "=", user.id) + .executeTakeFirst(); + + // Step 2: Reset password + const ctx2 = createAPIContext(db); + await call( + router.auth.resetPassword, + { token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, + { context: ctx2 }, + ); + + // Verify all old sessions were revoked + const sessions = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } + + // Step 3: Login with new password should work + const ctx3 = createAPIContext(db); + await call( + router.auth.createLoginRequest, + { email: "e2e-reset@example.com" }, + { context: ctx3 }, + ); + + const loginToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Mark login as completed (simulate trusted device or email confirmation) + await db + .updateTable("login_requests") + .set({ completed_at: new Date() }) + .where("token", "=", assertDefined(loginToken)) + .execute(); + + const ctx4 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(result.status).toBe("completed"); }); - - await createSession(user.id); - await createSession(user.id); - - // Step 1: Request password reset - const ctx1 = createAPIContext(); - await call( - router.auth.forgotPassword, - { email: "e2e-reset@example.com" }, - { context: ctx1 }, - ); - - // Get the token from DB (in real flow, this would be from email) - const reset = await getDb() - .selectFrom("password_resets") - .select(["token"]) - .where("user_id", "=", user.id) - .executeTakeFirst(); - - // Step 2: Reset password - const ctx2 = createAPIContext(); - await call( - router.auth.resetPassword, - { token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, - { context: ctx2 }, - ); - - // Verify all old sessions were revoked - const sessions = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("user_id", "=", user.id) - .execute(); - - for (const session of sessions) { - expect(session.revoked_at).not.toBeNull(); - } - - // Step 3: Login with new password should work - const ctx3 = createAPIContext(); - await call( - router.auth.createLoginRequest, - { email: "e2e-reset@example.com" }, - { context: ctx3 }, - ); - - const loginToken = getCookieFromResponse( - ctx3.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - - // Mark login as completed (simulate trusted device or email confirmation) - await getDb() - .updateTable("login_requests") - .set({ completed_at: new Date() }) - .where("token", "=", assertDefined(loginToken)) - .execute(); - - const ctx4 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx4 }, - ); - - expect(result.status).toBe("completed"); }); test("Scenario: Passkey login flow (full e2e)", async () => { - // Setup: User with passkey - const user = await createTestUser(getDb(), { - email: "e2e-passkey-login@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + // Setup: User with passkey + const user = await createTestUser(db, { + email: "e2e-passkey-login@example.com", + }); + + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const fingerprint = "e2e-passkey-device"; + + // Create a session for passkey registration (registration requires auth) + const { token: regSessionToken, sessionId: regSessionId } = + await createSession(db, user.id); + + // Create registration options + const regOptionsCtx = createAPIContext(db, { + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + const { options: regOptions, challengeId: regChallengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: regOptionsCtx }, + ); + + // Create credential with virtual authenticator + const regResponse = authenticator.createCredential(regOptions); + + // Verify registration + const verifyRegCtx = createAPIContext(db, { + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: regChallengeId, response: regResponse }, + { context: verifyRegCtx }, + ); + + // Clean up registration session + await db + .deleteFrom("sessions") + .where("id", "=", String(regSessionId)) + .execute(); + + // Step 1: Create login request + const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-passkey-login@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPasskey).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Create authentication options + const ctx2 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: ctx2 }, + ); + + expect(authOptions.allowCredentials).toHaveLength(1); + + // Step 3: Authenticate with passkey + const authResponse = authenticator.getAssertion(authOptions); + + const ctx3 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: ctx3 }, + ); + + // Step 4: Poll for completion - should be completed now + const ctx4 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(completedResult.status).toBe("completed"); + // Passkey login creates a trusted session, but device is not yet trusted + // So user is redirected to trust-device screen + expect(completedResult.redirectTo).toBe("/auth/trust-device"); + + // Verify session was created with trusted_mode = true + const sessions = await db + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx4.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); }); - - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const fingerprint = "e2e-passkey-device"; - - // Create a session for passkey registration (registration requires auth) - const { token: regSessionToken, sessionId: regSessionId } = - await createSession(user.id); - - // Create registration options - const regOptionsCtx = createAPIContext({ - sessionToken: regSessionToken, - deviceFingerprint: fingerprint, - }); - const { options: regOptions, challengeId: regChallengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: regOptionsCtx }, - ); - - // Create credential with virtual authenticator - const regResponse = authenticator.createCredential(regOptions); - - // Verify registration - const verifyRegCtx = createAPIContext({ - sessionToken: regSessionToken, - deviceFingerprint: fingerprint, - }); - await call( - router.auth.webauthn.verifyRegistration, - { challengeId: regChallengeId, response: regResponse }, - { context: verifyRegCtx }, - ); - - // Clean up registration session - await getDb() - .deleteFrom("sessions") - .where("id", "=", String(regSessionId)) - .execute(); - - // Step 1: Create login request - const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); - const loginRequestResult = await call( - router.auth.createLoginRequest, - { email: "e2e-passkey-login@example.com" }, - { context: ctx1 }, - ); - - expect(loginRequestResult.hasPasskey).toBe(true); - - const loginToken = getCookieFromResponse( - ctx1.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - expect(loginToken).not.toBeNull(); - - // Step 2: Create authentication options - const ctx2 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const { options: authOptions, challengeId: authChallengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: ctx2 }, - ); - - expect(authOptions.allowCredentials).toHaveLength(1); - - // Step 3: Authenticate with passkey - const authResponse = authenticator.getAssertion(authOptions); - - const ctx3 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId: authChallengeId, response: authResponse }, - { context: ctx3 }, - ); - - // Step 4: Poll for completion - should be completed now - const ctx4 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const completedResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx4 }, - ); - - expect(completedResult.status).toBe("completed"); - // Passkey login creates a trusted session, but device is not yet trusted - // So user is redirected to trust-device screen - expect(completedResult.redirectTo).toBe("/auth/trust-device"); - - // Verify session was created with trusted_mode = true - const sessions = await getDb() - .selectFrom("sessions") - .selectAll() - .where("user_id", "=", user.id) - .execute(); - - expect(sessions.length).toBe(1); - expect(sessions[0]?.trusted_mode).toBe(true); - - // Verify session cookie was set - const sessionToken = getCookieFromResponse( - ctx4.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); }); test("Scenario: User with no auth methods (no password, no passkey)", async () => { - // Setup: User without any auth methods set up - // This simulates a user who was created but never completed setup - await createTestUser(getDb(), { - email: "e2e-no-auth@example.com", - // No password hash + await withTestTransaction(getSharedDb(), async (db) => { + // Setup: User without any auth methods set up + // This simulates a user who was created but never completed setup + await createTestUser(db, { + email: "e2e-no-auth@example.com", + // No password hash + }); + + const fingerprint = "e2e-no-auth-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-no-auth@example.com" }, + { context: ctx1 }, + ); + + // Should indicate no auth methods available + expect(loginRequestResult.hasPassword).toBe(false); + expect(loginRequestResult.hasPasskey).toBe(false); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Poll should return pending (no way to complete login) + const ctx2 = createAPIContext(db, { + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx2 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // According to docs: "Shows 'Check your email' but no email sent, polling will expire" + // The login request exists but can never be completed since there's no auth method + + // Verify login request exists but is not completed + const loginRequest = await db + .selectFrom("login_requests") + .selectAll() + .where("token", "=", assertDefined(loginToken)) + .executeTakeFirst(); + + expect(loginRequest).toBeDefined(); + expect(loginRequest?.completed_at).toBeNull(); }); - - const fingerprint = "e2e-no-auth-device"; - - // Step 1: Create login request - const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); - const loginRequestResult = await call( - router.auth.createLoginRequest, - { email: "e2e-no-auth@example.com" }, - { context: ctx1 }, - ); - - // Should indicate no auth methods available - expect(loginRequestResult.hasPassword).toBe(false); - expect(loginRequestResult.hasPasskey).toBe(false); - expect(loginRequestResult.isTrustedDevice).toBe(false); - - const loginToken = getCookieFromResponse( - ctx1.resHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - expect(loginToken).not.toBeNull(); - - // Step 2: Poll should return pending (no way to complete login) - const ctx2 = createAPIContext({ - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, - }); - const pendingResult = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx2 }, - ); - - expect(pendingResult.status).toBe("pending"); - - // According to docs: "Shows 'Check your email' but no email sent, polling will expire" - // The login request exists but can never be completed since there's no auth method - - // Verify login request exists but is not completed - const loginRequest = await getDb() - .selectFrom("login_requests") - .selectAll() - .where("token", "=", assertDefined(loginToken)) - .executeTakeFirst(); - - expect(loginRequest).toBeDefined(); - expect(loginRequest?.completed_at).toBeNull(); }); }); diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index c65f102..9ead2e3 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -21,27 +21,15 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { router } from "../../router.js"; import { COOKIE_NAMES } from "../../utils/cookies.js"; import { hashToken } from "../../utils/crypto.js"; import { hashPassword } from "../../utils/password.js"; import { TEST_RP } from "../helpers/test-constants.js"; -import { - createTestDb, - createTestUser, - destroyTestDb, - runMigrations, - truncateAllTables, -} from "../helpers/test-db.js"; +import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js"; +import { withTestTransaction } from "../helpers/test-transaction.js"; /** Session expiry duration: 24 hours in milliseconds */ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; @@ -52,23 +40,17 @@ const API_TOKEN_EXPIRY_MS = 365 * 24 * 60 * 60 * 1000; /** API token expiry duration in cascade test: 1 day in milliseconds */ const ONE_DAY_MS = 86400000; -let db: Kysely | undefined; - -function getDb(): Kysely { - if (!db) { - throw new Error("Database not initialized"); - } - return db; -} - /** * Create an API context with optional authentication */ -function createAPIContext(options?: { - sessionToken?: string; - apiKey?: string; - deviceFingerprint?: string; -}): APIContext { +function createAPIContext( + db: Kysely, + options?: { + sessionToken?: string; + apiKey?: string; + deviceFingerprint?: string; + }, +): APIContext { const reqHeaders = new Headers(); const cookies: string[] = []; @@ -88,7 +70,7 @@ function createAPIContext(options?: { } return { - db: getDb(), + db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, @@ -101,6 +83,7 @@ function createAPIContext(options?: { * Create a real session in the database and return the token and session ID */ async function createSession( + db: Kysely, userId: number, options?: { ipAddress?: string; userAgent?: string }, ): Promise<{ token: string; sessionId: number }> { @@ -108,7 +91,7 @@ async function createSession( const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); - const result = await getDb() + const result = await db .insertInto("sessions") .values({ user_id: userId, @@ -124,28 +107,11 @@ async function createSession( return { token, sessionId: Number(result.id) }; } -/** - * Create an authenticated API context for a user (creates session + context) - */ -async function createUserAPIContext( - userId: number, - options?: { deviceFingerprint?: string }, -): Promise<{ context: APIContext; token: string }> { - const { token } = await createSession(userId); - const context = createAPIContext({ - sessionToken: token, - deviceFingerprint: options?.deviceFingerprint, - }); - return { context, token }; -} - -// Export to suppress unused warning - helper available for future tests -void createUserAPIContext; - /** * Create a device in the database and return the fingerprint */ async function createDevice( + db: Kysely, userId: number, options?: { fingerprint?: string; @@ -158,7 +124,7 @@ async function createDevice( options?.fingerprint ?? `test-fp-${String(Date.now())}${String(Math.random())}`; - const result = await getDb() + const result = await db .insertInto("user_devices") .values({ user_id: userId, @@ -179,13 +145,14 @@ async function createDevice( * Create an API token in the database and return the token */ async function createApiToken( + db: Kysely, userId: number, ): Promise<{ token: string; name: string }> { const token = `test-api-token-${String(Date.now())}${String(Math.random())}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); - await getDb() + await db .insertInto("api_tokens") .values({ user_id: userId, @@ -199,541 +166,572 @@ async function createApiToken( } beforeAll(async () => { - await runMigrations(); - db = createTestDb(); -}); - -afterAll(async () => { - if (db) { - await destroyTestDb(db); - } -}); - -beforeEach(async () => { - await truncateAllTables(getDb()); + await initTestDb(); }); describe("me.get", () => { test("returns user profile with all fields", async () => { - const user = await createTestUser(getDb(), { - email: "test@example.com", - displayName: "Test User", - fullName: "Test Full Name", - emailVerifiedAt: new Date(), + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "test@example.com", + displayName: "Test User", + fullName: "Test Full Name", + emailVerifiedAt: new Date(), + }); + + // Update with phone number + await db + .updateTable("users") + .set({ phone_number: "+1234567890" }) + .where("id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.me.get, undefined, { context }); + + expect(result.id).toBe(user.id); + expect(result.email).toBe("test@example.com"); + expect(result.displayName).toBe("Test User"); + expect(result.fullName).toBe("Test Full Name"); + expect(result.phoneNumber).toBe("+1234567890"); + expect(result.emailVerified).toBe(true); + expect(result.needsSetup).toBe(false); + expect(result.isSuperuser).toBe(false); + expect(result.hasPassword).toBe(false); }); - - // Update with phone number - await getDb() - .updateTable("users") - .set({ phone_number: "+1234567890" }) - .where("id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const result = await call(router.me.get, undefined, { context }); - - expect(result.id).toBe(user.id); - expect(result.email).toBe("test@example.com"); - expect(result.displayName).toBe("Test User"); - expect(result.fullName).toBe("Test Full Name"); - expect(result.phoneNumber).toBe("+1234567890"); - expect(result.emailVerified).toBe(true); - expect(result.needsSetup).toBe(false); - expect(result.isSuperuser).toBe(false); - expect(result.hasPassword).toBe(false); }); test("returns needsSetup=true when displayName is null", async () => { - const user = await createTestUser(getDb(), { - email: "newuser@example.com", - displayName: undefined, + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "newuser@example.com", + displayName: undefined, + }); + + // Set display_name to null explicitly + await db + .updateTable("users") + .set({ display_name: null }) + .where("id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.me.get, undefined, { context }); + + expect(result.needsSetup).toBe(true); + expect(result.displayName).toBeNull(); }); - - // Set display_name to null explicitly - await getDb() - .updateTable("users") - .set({ display_name: null }) - .where("id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const result = await call(router.me.get, undefined, { context }); - - expect(result.needsSetup).toBe(true); - expect(result.displayName).toBeNull(); }); test("returns hasPassword=true when user has password", async () => { - const passwordHash = await hashPassword("securePassword123!"); - const user = await createTestUser(getDb(), { - email: "withpassword@example.com", - passwordHash, + await withTestTransaction(getSharedDb(), async (db) => { + const passwordHash = await hashPassword("securePassword123!"); + const user = await createTestUser(db, { + email: "withpassword@example.com", + passwordHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.me.get, undefined, { context }); + + expect(result.hasPassword).toBe(true); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const result = await call(router.me.get, undefined, { context }); - - expect(result.hasPassword).toBe(true); }); test("returns isSuperuser=true for superuser", async () => { - const user = await createTestUser(getDb(), { - email: "admin@example.com", - isSuperuser: true, + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.me.get, undefined, { context }); + + expect(result.isSuperuser).toBe(true); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const result = await call(router.me.get, undefined, { context }); - - expect(result.isSuperuser).toBe(true); }); }); describe("me.authStatus", () => { test("returns session auth info", async () => { - const user = await createTestUser(getDb(), { - email: "session@example.com", - displayName: "Session User", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "session@example.com", + displayName: "Session User", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.me.authStatus, undefined, { context }); + + expect(result.user.email).toBe("session@example.com"); + expect(result.user.displayName).toBe("Session User"); + expect(result.auth.method).toBe("session"); + if (result.auth.method === "session") { + expect(result.auth.expiresAt).toBeInstanceOf(Date); + } }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const result = await call(router.me.authStatus, undefined, { context }); - - expect(result.user.email).toBe("session@example.com"); - expect(result.user.displayName).toBe("Session User"); - expect(result.auth.method).toBe("session"); - if (result.auth.method === "session") { - expect(result.auth.expiresAt).toBeInstanceOf(Date); - } }); test("returns api_token auth info", async () => { - const user = await createTestUser(getDb(), { - email: "apitoken@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "apitoken@example.com", + }); + + const { token } = await createApiToken(db, user.id); + const context = createAPIContext(db, { apiKey: token }); + + const result = await call(router.me.authStatus, undefined, { context }); + + expect(result.user.email).toBe("apitoken@example.com"); + expect(result.auth.method).toBe("api_token"); + if (result.auth.method === "api_token") { + expect(result.auth.tokenName).toBe("Test API Token"); + expect(result.auth.expiresAt).toBeInstanceOf(Date); + } }); - - const { token } = await createApiToken(user.id); - const context = createAPIContext({ apiKey: token }); - - const result = await call(router.me.authStatus, undefined, { context }); - - expect(result.user.email).toBe("apitoken@example.com"); - expect(result.auth.method).toBe("api_token"); - if (result.auth.method === "api_token") { - expect(result.auth.tokenName).toBe("Test API Token"); - expect(result.auth.expiresAt).toBeInstanceOf(Date); - } }); }); describe("me.setupProfile", () => { test("sets up profile with required fields", async () => { - const user = await createTestUser(getDb(), { - email: "setup@example.com", - displayName: undefined, + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "setup@example.com", + displayName: undefined, + }); + + // Clear display_name + await db + .updateTable("users") + .set({ display_name: null }) + .where("id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.setupProfile, + { + displayName: "New Display Name", + fullName: "John Doe", + phoneNumber: "+12025551234", + }, + { context }, + ); + + // Verify changes + const updated = await db + .selectFrom("users") + .select(["display_name", "full_name", "phone_number"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("New Display Name"); + expect(updated.full_name).toBe("John Doe"); + expect(updated.phone_number).toBe("+12025551234"); }); - - // Clear display_name - await getDb() - .updateTable("users") - .set({ display_name: null }) - .where("id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.setupProfile, - { - displayName: "New Display Name", - fullName: "John Doe", - phoneNumber: "+12025551234", - }, - { context }, - ); - - // Verify changes - const updated = await getDb() - .selectFrom("users") - .select(["display_name", "full_name", "phone_number"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.display_name).toBe("New Display Name"); - expect(updated.full_name).toBe("John Doe"); - expect(updated.phone_number).toBe("+12025551234"); }); test("sets up profile with only required displayName", async () => { - const user = await createTestUser(getDb(), { - email: "minimal@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "minimal@example.com", + }); + + await db + .updateTable("users") + .set({ display_name: null }) + .where("id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.setupProfile, + { + displayName: "Minimal User", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["display_name", "full_name", "phone_number"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("Minimal User"); + expect(updated.full_name).toBeNull(); + expect(updated.phone_number).toBeNull(); }); - - await getDb() - .updateTable("users") - .set({ display_name: null }) - .where("id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.setupProfile, - { - displayName: "Minimal User", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["display_name", "full_name", "phone_number"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.display_name).toBe("Minimal User"); - expect(updated.full_name).toBeNull(); - expect(updated.phone_number).toBeNull(); }); }); describe("me.updateProfile", () => { test("updates displayName only", async () => { - const user = await createTestUser(getDb(), { - email: "update@example.com", - displayName: "Original Name", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "update@example.com", + displayName: "Original Name", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.updateProfile, + { + displayName: "Updated Name", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["display_name"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("Updated Name"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.updateProfile, - { - displayName: "Updated Name", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["display_name"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.display_name).toBe("Updated Name"); }); test("updates multiple fields at once", async () => { - const user = await createTestUser(getDb(), { - email: "multi@example.com", - displayName: "Original", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "multi@example.com", + displayName: "Original", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.updateProfile, + { + displayName: "New Display", + fullName: "Full Name Here", + phoneNumber: "+12025551234", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["display_name", "full_name", "phone_number"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("New Display"); + expect(updated.full_name).toBe("Full Name Here"); + expect(updated.phone_number).toBe("+12025551234"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.updateProfile, - { - displayName: "New Display", - fullName: "Full Name Here", - phoneNumber: "+12025551234", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["display_name", "full_name", "phone_number"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.display_name).toBe("New Display"); - expect(updated.full_name).toBe("Full Name Here"); - expect(updated.phone_number).toBe("+12025551234"); }); test("empty strings in optional fields are treated as no-op", async () => { - // Empty strings in optionalString fields are transformed to undefined, - // which means no update happens - fields keep their existing values - const user = await createTestUser(getDb(), { - email: "clear@example.com", - displayName: "Keep Me", - fullName: "Keep This Too", + await withTestTransaction(getSharedDb(), async (db) => { + // Empty strings in optionalString fields are transformed to undefined, + // which means no update happens - fields keep their existing values + const user = await createTestUser(db, { + email: "clear@example.com", + displayName: "Keep Me", + fullName: "Keep This Too", + }); + + await db + .updateTable("users") + .set({ phone_number: "+12025551234" }) + .where("id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.updateProfile, + { + fullName: "", + phoneNumber: "", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["display_name", "full_name", "phone_number"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + // Empty strings are transformed to undefined by optionalString, + // so no update happens - fields keep their existing values + expect(updated.display_name).toBe("Keep Me"); + expect(updated.full_name).toBe("Keep This Too"); + expect(updated.phone_number).toBe("+12025551234"); }); - - await getDb() - .updateTable("users") - .set({ phone_number: "+12025551234" }) - .where("id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.updateProfile, - { - fullName: "", - phoneNumber: "", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["display_name", "full_name", "phone_number"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - // Empty strings are transformed to undefined by optionalString, - // so no update happens - fields keep their existing values - expect(updated.display_name).toBe("Keep Me"); - expect(updated.full_name).toBe("Keep This Too"); - expect(updated.phone_number).toBe("+12025551234"); }); test("does nothing when no fields provided", async () => { - const user = await createTestUser(getDb(), { - email: "noop@example.com", - displayName: "Stay Same", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "noop@example.com", + displayName: "Stay Same", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.me.updateProfile, {}, { context }); + + const updated = await db + .selectFrom("users") + .select(["display_name"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("Stay Same"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call(router.me.updateProfile, {}, { context }); - - const updated = await getDb() - .selectFrom("users") - .select(["display_name"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.display_name).toBe("Stay Same"); }); }); describe("me.setPassword", () => { test("sets password for user without password", async () => { - const user = await createTestUser(getDb(), { - email: "nopass@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nopass@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + // Use a strong password + await call( + router.me.setPassword, + { + newPassword: "SuperSecure123!@#$%", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.password_hash).not.toBeNull(); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - // Use a strong password - await call( - router.me.setPassword, - { - newPassword: "SuperSecure123!@#$%", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["password_hash"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.password_hash).not.toBeNull(); }); test("changes password with correct current password", async () => { - const oldPassword = "OldPassword123!@#"; - const oldHash = await hashPassword(oldPassword); - const user = await createTestUser(getDb(), { - email: "changepass@example.com", - passwordHash: oldHash, + await withTestTransaction(getSharedDb(), async (db) => { + const oldPassword = "OldPassword123!@#"; + const oldHash = await hashPassword(oldPassword); + const user = await createTestUser(db, { + email: "changepass@example.com", + passwordHash: oldHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.me.setPassword, + { + currentPassword: oldPassword, + newPassword: "NewSecurePassword456!@#", + }, + { context }, + ); + + const updated = await db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", user.id) + .executeTakeFirstOrThrow(); + + expect(updated.password_hash).not.toBe(oldHash); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call( - router.me.setPassword, - { - currentPassword: oldPassword, - newPassword: "NewSecurePassword456!@#", - }, - { context }, - ); - - const updated = await getDb() - .selectFrom("users") - .select(["password_hash"]) - .where("id", "=", user.id) - .executeTakeFirstOrThrow(); - - expect(updated.password_hash).not.toBe(oldHash); }); test("fails without current password when user has password", async () => { - const oldHash = await hashPassword("ExistingPass123!"); - const user = await createTestUser(getDb(), { - email: "haspass@example.com", - passwordHash: oldHash, + await withTestTransaction(getSharedDb(), async (db) => { + const oldHash = await hashPassword("ExistingPass123!"); + const user = await createTestUser(db, { + email: "haspass@example.com", + passwordHash: oldHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.me.setPassword, + { + newPassword: "NewPassword123!@#", + }, + { context }, + ), + ).rejects.toThrow("Current password required"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call( - router.me.setPassword, - { - newPassword: "NewPassword123!@#", - }, - { context }, - ), - ).rejects.toThrow("Current password required"); }); test("fails with incorrect current password", async () => { - const oldHash = await hashPassword("CorrectPassword123!"); - const user = await createTestUser(getDb(), { - email: "wrongpass@example.com", - passwordHash: oldHash, + await withTestTransaction(getSharedDb(), async (db) => { + const oldHash = await hashPassword("CorrectPassword123!"); + const user = await createTestUser(db, { + email: "wrongpass@example.com", + passwordHash: oldHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.me.setPassword, + { + currentPassword: "WrongPassword123!", + newPassword: "NewPassword456!@#", + }, + { context }, + ), + ).rejects.toThrow("Current password is incorrect"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call( - router.me.setPassword, - { - currentPassword: "WrongPassword123!", - newPassword: "NewPassword456!@#", - }, - { context }, - ), - ).rejects.toThrow("Current password is incorrect"); }); test("fails with weak password", async () => { - const user = await createTestUser(getDb(), { - email: "weak@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "weak@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + // Password must be at least 8 chars to pass schema validation + // "password" passes length check but fails zxcvbn strength check + // zxcvbn provides feedback like "This is a top-10 common password" + + await expect( + call( + router.me.setPassword, + { + newPassword: "password", // 8 chars but extremely common + }, + { context }, + ), + ).rejects.toThrow(/common|top|weak|guess/i); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - // Password must be at least 8 chars to pass schema validation - // "password" passes length check but fails zxcvbn strength check - // zxcvbn provides feedback like "This is a top-10 common password" - - await expect( - call( - router.me.setPassword, - { - newPassword: "password", // 8 chars but extremely common - }, - { context }, - ), - ).rejects.toThrow(/common|top|weak|guess/i); }); }); describe("me.delete", () => { test("deletes account with correct password", async () => { - const password = "DeleteMe123!@#"; - const passwordHash = await hashPassword(password); - const user = await createTestUser(getDb(), { - email: "delete@example.com", - passwordHash, + await withTestTransaction(getSharedDb(), async (db) => { + const password = "DeleteMe123!@#"; + const passwordHash = await hashPassword(password); + const user = await createTestUser(db, { + email: "delete@example.com", + passwordHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.me.delete, { password }, { context }); + + // Verify user is deleted + const deleted = await db + .selectFrom("users") + .where("id", "=", user.id) + .selectAll() + .executeTakeFirst(); + + expect(deleted).toBeUndefined(); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call(router.me.delete, { password }, { context }); - - // Verify user is deleted - const deleted = await getDb() - .selectFrom("users") - .where("id", "=", user.id) - .selectAll() - .executeTakeFirst(); - - expect(deleted).toBeUndefined(); }); test("fails without password set", async () => { - const user = await createTestUser(getDb(), { - email: "nopassdelete@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nopassdelete@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.delete, { password: "anything" }, { context }), + ).rejects.toThrow("Cannot delete account without a password"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.delete, { password: "anything" }, { context }), - ).rejects.toThrow("Cannot delete account without a password"); }); test("fails with incorrect password", async () => { - const passwordHash = await hashPassword("CorrectPassword123!"); - const user = await createTestUser(getDb(), { - email: "wrongdelete@example.com", - passwordHash, + await withTestTransaction(getSharedDb(), async (db) => { + const passwordHash = await hashPassword("CorrectPassword123!"); + const user = await createTestUser(db, { + email: "wrongdelete@example.com", + passwordHash, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.delete, { password: "WrongPassword123!" }, { context }), + ).rejects.toThrow("Incorrect password"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.delete, { password: "WrongPassword123!" }, { context }), - ).rejects.toThrow("Incorrect password"); }); test("cascades deletion to related records", async () => { - const password = "CascadeDelete123!@#"; - const passwordHash = await hashPassword(password); - const user = await createTestUser(getDb(), { - email: "cascade@example.com", - passwordHash, + await withTestTransaction(getSharedDb(), async (db) => { + const password = "CascadeDelete123!@#"; + const passwordHash = await hashPassword(password); + const user = await createTestUser(db, { + email: "cascade@example.com", + passwordHash, + }); + + // Create related records + await db + .insertInto("api_tokens") + .values({ + user_id: user.id, + token_hash: "test-hash", + name: "Test Token", + expires_at: new Date(Date.now() + ONE_DAY_MS), + }) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.me.delete, { password }, { context }); + + // Verify cascaded deletion + const tokens = await db + .selectFrom("api_tokens") + .where("user_id", "=", user.id) + .selectAll() + .execute(); + + expect(tokens).toHaveLength(0); }); - - // Create related records - await getDb() - .insertInto("api_tokens") - .values({ - user_id: user.id, - token_hash: "test-hash", - name: "Test Token", - expires_at: new Date(Date.now() + ONE_DAY_MS), - }) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call(router.me.delete, { password }, { context }); - - // Verify cascaded deletion - const tokens = await getDb() - .selectFrom("api_tokens") - .where("user_id", "=", user.id) - .selectAll() - .execute(); - - expect(tokens).toHaveLength(0); }); }); @@ -741,242 +739,273 @@ describe("me.delete", () => { describe("me.sessions.list", () => { test("returns all sessions for user", async () => { - const user = await createTestUser(getDb(), { - email: "sessions@example.com", - }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "sessions@example.com", + }); - // Create multiple sessions - const { token: sessionToken1 } = await createSession(user.id, { - ipAddress: "192.168.1.1", - userAgent: "Chrome/1.0", - }); - await createSession(user.id, { - ipAddress: "192.168.1.2", - userAgent: "Firefox/1.0", - }); - await createSession(user.id, { - ipAddress: "192.168.1.3", - userAgent: "Safari/1.0", - }); + // Create multiple sessions + const { token: sessionToken1 } = await createSession(db, user.id, { + ipAddress: "192.168.1.1", + userAgent: "Chrome/1.0", + }); + await createSession(db, user.id, { + ipAddress: "192.168.1.2", + userAgent: "Firefox/1.0", + }); + await createSession(db, user.id, { + ipAddress: "192.168.1.3", + userAgent: "Safari/1.0", + }); - const context = createAPIContext({ sessionToken: sessionToken1 }); - const sessions = await call(router.me.sessions.list, undefined, { - context, - }); + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); - expect(sessions).toHaveLength(3); - // Sessions should be ordered by created_at desc - expect(sessions[0]?.userAgent).toBe("Safari/1.0"); - expect(sessions[1]?.userAgent).toBe("Firefox/1.0"); - expect(sessions[2]?.userAgent).toBe("Chrome/1.0"); + expect(sessions).toHaveLength(3); + // Verify all sessions exist (order not guaranteed when created simultaneously) + const userAgents = sessions.map((s) => s.userAgent).sort(); + expect(userAgents).toEqual(["Chrome/1.0", "Firefox/1.0", "Safari/1.0"]); + }); }); test("marks current session with isCurrent flag", async () => { - const user = await createTestUser(getDb(), { - email: "current@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "current@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + db, + user.id, + ); + const { sessionId: id2 } = await createSession(db, user.id); + + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(2); + const current = sessions.find((s) => s.id === id1); + const other = sessions.find((s) => s.id === id2); + expect(current?.isCurrent).toBe(true); + expect(other?.isCurrent).toBe(false); }); - - const { token: sessionToken1, sessionId: id1 } = await createSession( - user.id, - ); - const { sessionId: id2 } = await createSession(user.id); - - const context = createAPIContext({ sessionToken: sessionToken1 }); - const sessions = await call(router.me.sessions.list, undefined, { - context, - }); - - expect(sessions).toHaveLength(2); - const current = sessions.find((s) => s.id === id1); - const other = sessions.find((s) => s.id === id2); - expect(current?.isCurrent).toBe(true); - expect(other?.isCurrent).toBe(false); }); test("returns session metadata correctly", async () => { - const user = await createTestUser(getDb(), { - email: "metadata@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "metadata@example.com", + }); + + // Create session and update with location data + const { token: sessionToken, sessionId } = await createSession( + db, + user.id, + { + ipAddress: "8.8.8.8", + userAgent: "TestAgent/1.0", + }, + ); + + await db + .updateTable("sessions") + .set({ + city: "San Francisco", + region: "CA", + country: "US", + trusted_mode: true, + }) + .where("id", "=", String(sessionId)) + .execute(); + + const context = createAPIContext(db, { sessionToken }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(1); + const session = sessions[0]; + expect(session?.ip).toBe("8.8.8.8"); + expect(session?.userAgent).toBe("TestAgent/1.0"); + expect(session?.city).toBe("San Francisco"); + expect(session?.region).toBe("CA"); + expect(session?.country).toBe("US"); + expect(session?.trustedMode).toBe(true); + expect(session?.createdAt).toBeInstanceOf(Date); + expect(session?.revokedAt).toBeNull(); }); - - // Create session and update with location data - const { token: sessionToken, sessionId } = await createSession(user.id, { - ipAddress: "8.8.8.8", - userAgent: "TestAgent/1.0", - }); - - await getDb() - .updateTable("sessions") - .set({ - city: "San Francisco", - region: "CA", - country: "US", - trusted_mode: true, - }) - .where("id", "=", String(sessionId)) - .execute(); - - const context = createAPIContext({ sessionToken }); - const sessions = await call(router.me.sessions.list, undefined, { - context, - }); - - expect(sessions).toHaveLength(1); - const session = sessions[0]; - expect(session?.ip).toBe("8.8.8.8"); - expect(session?.userAgent).toBe("TestAgent/1.0"); - expect(session?.city).toBe("San Francisco"); - expect(session?.region).toBe("CA"); - expect(session?.country).toBe("US"); - expect(session?.trustedMode).toBe(true); - expect(session?.createdAt).toBeInstanceOf(Date); - expect(session?.revokedAt).toBeNull(); }); }); describe("me.sessions.revoke", () => { test("revokes another session successfully", async () => { - const user = await createTestUser(getDb(), { - email: "revoke@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revoke@example.com", + }); + + const { token: sessionToken1 } = await createSession(db, user.id); + const { sessionId: sessionId2 } = await createSession(db, user.id); + + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await call( + router.me.sessions.revoke, + { sessionId: sessionId2 }, + { context }, + ); + + // Verify session is revoked + const session = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId2)) + .executeTakeFirstOrThrow(); + + expect(session.revoked_at).not.toBeNull(); }); - - const { token: sessionToken1 } = await createSession(user.id); - const { sessionId: sessionId2 } = await createSession(user.id); - - const context = createAPIContext({ sessionToken: sessionToken1 }); - await call( - router.me.sessions.revoke, - { sessionId: sessionId2 }, - { context }, - ); - - // Verify session is revoked - const session = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("id", "=", String(sessionId2)) - .executeTakeFirstOrThrow(); - - expect(session.revoked_at).not.toBeNull(); }); test("fails to revoke current session", async () => { - const user = await createTestUser(getDb(), { - email: "revokecurrent@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokecurrent@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession( + db, + user.id, + ); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId }, { context }), + ).rejects.toThrow("Cannot revoke current session"); }); - - const { token: sessionToken, sessionId } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.sessions.revoke, { sessionId }, { context }), - ).rejects.toThrow("Cannot revoke current session"); }); test("fails to revoke non-existent session", async () => { - const user = await createTestUser(getDb(), { - email: "revokenotfound@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokenotfound@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId: 999999 }, { context }), + ).rejects.toThrow("Session not found"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.sessions.revoke, { sessionId: 999999 }, { context }), - ).rejects.toThrow("Session not found"); }); test("fails to revoke already revoked session", async () => { - const user = await createTestUser(getDb(), { - email: "revokeagain@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokeagain@example.com", + }); + + const { token: sessionToken1 } = await createSession(db, user.id); + const { sessionId: sessionId2 } = await createSession(db, user.id); + + // Revoke the session directly + await db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(sessionId2)) + .execute(); + + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); }); - - const { token: sessionToken1 } = await createSession(user.id); - const { sessionId: sessionId2 } = await createSession(user.id); - - // Revoke the session directly - await getDb() - .updateTable("sessions") - .set({ revoked_at: new Date() }) - .where("id", "=", String(sessionId2)) - .execute(); - - const context = createAPIContext({ sessionToken: sessionToken1 }); - await expect( - call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), - ).rejects.toThrow("Session not found"); }); test("fails to revoke another user's session", async () => { - const user1 = await createTestUser(getDb(), { - email: "user1@example.com", - }); - const user2 = await createTestUser(getDb(), { - email: "user2@example.com", - }); + await withTestTransaction(getSharedDb(), async (db) => { + const user1 = await createTestUser(db, { + email: "user1@example.com", + }); + const user2 = await createTestUser(db, { + email: "user2@example.com", + }); - const { token: sessionToken1 } = await createSession(user1.id); - const { sessionId: sessionId2 } = await createSession(user2.id); + const { token: sessionToken1 } = await createSession(db, user1.id); + const { sessionId: sessionId2 } = await createSession(db, user2.id); - const context = createAPIContext({ sessionToken: sessionToken1 }); - await expect( - call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), - ).rejects.toThrow("Session not found"); + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); + }); }); }); describe("me.sessions.revokeAll", () => { test("revokes all sessions except current", async () => { - const user = await createTestUser(getDb(), { - email: "revokeall@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokeall@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + db, + user.id, + ); + const { sessionId: id2 } = await createSession(db, user.id); + const { sessionId: id3 } = await createSession(db, user.id); + + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Verify current session is NOT revoked + const currentSession = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(id1)) + .executeTakeFirstOrThrow(); + expect(currentSession.revoked_at).toBeNull(); + + // Verify other sessions ARE revoked + const otherSessions = await db + .selectFrom("sessions") + .select(["id", "revoked_at"]) + .where("id", "in", [String(id2), String(id3)]) + .execute(); + + for (const session of otherSessions) { + expect(session.revoked_at).not.toBeNull(); + } }); - - const { token: sessionToken1, sessionId: id1 } = await createSession( - user.id, - ); - const { sessionId: id2 } = await createSession(user.id); - const { sessionId: id3 } = await createSession(user.id); - - const context = createAPIContext({ sessionToken: sessionToken1 }); - await call(router.me.sessions.revokeAll, undefined, { context }); - - // Verify current session is NOT revoked - const currentSession = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("id", "=", String(id1)) - .executeTakeFirstOrThrow(); - expect(currentSession.revoked_at).toBeNull(); - - // Verify other sessions ARE revoked - const otherSessions = await getDb() - .selectFrom("sessions") - .select(["id", "revoked_at"]) - .where("id", "in", [String(id2), String(id3)]) - .execute(); - - for (const session of otherSessions) { - expect(session.revoked_at).not.toBeNull(); - } }); test("does nothing when only current session exists", async () => { - const user = await createTestUser(getDb(), { - email: "onlyone@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "onlyone@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession( + db, + user.id, + ); + const context = createAPIContext(db, { sessionToken }); + + // Should not throw + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Current session should still be valid + const session = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirstOrThrow(); + expect(session.revoked_at).toBeNull(); }); - - const { token: sessionToken, sessionId } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - // Should not throw - await call(router.me.sessions.revokeAll, undefined, { context }); - - // Current session should still be valid - const session = await getDb() - .selectFrom("sessions") - .select(["revoked_at"]) - .where("id", "=", String(sessionId)) - .executeTakeFirstOrThrow(); - expect(session.revoked_at).toBeNull(); }); }); @@ -984,325 +1013,361 @@ describe("me.sessions.revokeAll", () => { describe("me.devices.getInfo", () => { test("returns device info for current device", async () => { - const user = await createTestUser(getDb(), { - email: "deviceinfo@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "deviceinfo@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(db, user.id, { + name: "My MacBook", + isTrusted: true, + userAgent: "Safari/17.0", + }); + + // Update with location data + await db + .updateTable("user_devices") + .set({ + ip_address: "1.2.3.4", + city: "New York", + region: "NY", + country: "US", + }) + .where("id", "=", String(deviceId)) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { + context, + }); + + expect(info.id).toBe(deviceId); + expect(info.name).toBe("My MacBook"); + expect(info.ip).toBe("1.2.3.4"); + expect(info.city).toBe("New York"); + expect(info.region).toBe("NY"); + expect(info.country).toBe("US"); + expect(info.isTrusted).toBe(true); + expect(info.lastUsedAt).toBeInstanceOf(Date); }); - - const { fingerprint, deviceId } = await createDevice(user.id, { - name: "My MacBook", - isTrusted: true, - userAgent: "Safari/17.0", - }); - - // Update with location data - await getDb() - .updateTable("user_devices") - .set({ - ip_address: "1.2.3.4", - city: "New York", - region: "NY", - country: "US", - }) - .where("id", "=", String(deviceId)) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ - sessionToken, - deviceFingerprint: fingerprint, - }); - - const info = await call(router.me.devices.getInfo, undefined, { context }); - - expect(info.id).toBe(deviceId); - expect(info.name).toBe("My MacBook"); - expect(info.ip).toBe("1.2.3.4"); - expect(info.city).toBe("New York"); - expect(info.region).toBe("NY"); - expect(info.country).toBe("US"); - expect(info.isTrusted).toBe(true); - expect(info.lastUsedAt).toBeInstanceOf(Date); }); test("returns default name from user agent when name is null", async () => { - const user = await createTestUser(getDb(), { - email: "defaultname@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "defaultname@example.com", + }); + + const { fingerprint } = await createDevice(db, user.id, { + userAgent: "Mozilla/5.0 (Macintosh)", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { + context, + }); + + expect(info.name).toBe("Mozilla device"); }); - - const { fingerprint } = await createDevice(user.id, { - userAgent: "Mozilla/5.0 (Macintosh)", - }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ - sessionToken, - deviceFingerprint: fingerprint, - }); - - const info = await call(router.me.devices.getInfo, undefined, { context }); - - expect(info.name).toBe("Mozilla device"); }); test("fails without device fingerprint", async () => { - const user = await createTestUser(getDb(), { - email: "nofingerprint@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nofingerprint@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("No device fingerprint found"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.devices.getInfo, undefined, { context }), - ).rejects.toThrow("No device fingerprint found"); }); test("fails when device does not exist", async () => { - const user = await createTestUser(getDb(), { - email: "nodevice@example.com", - }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nodevice@example.com", + }); - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ - sessionToken, - deviceFingerprint: "nonexistent-fingerprint", - }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { + sessionToken, + deviceFingerprint: "nonexistent-fingerprint", + }); - await expect( - call(router.me.devices.getInfo, undefined, { context }), - ).rejects.toThrow("Device not found"); + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("Device not found"); + }); }); }); describe("me.devices.trust", () => { test("trusts current device with name", async () => { - const user = await createTestUser(getDb(), { - email: "trustdevice@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustdevice@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(db, user.id, { + isTrusted: false, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { + sessionToken, + deviceFingerprint: fingerprint, + }); + + await call( + router.me.devices.trust, + { name: "My Work Laptop" }, + { context }, + ); + + // Verify device is trusted with the new name + const device = await db + .selectFrom("user_devices") + .select(["is_trusted", "name"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(true); + expect(device.name).toBe("My Work Laptop"); }); - - const { fingerprint, deviceId } = await createDevice(user.id, { - isTrusted: false, - }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ - sessionToken, - deviceFingerprint: fingerprint, - }); - - await call( - router.me.devices.trust, - { name: "My Work Laptop" }, - { context }, - ); - - // Verify device is trusted with the new name - const device = await getDb() - .selectFrom("user_devices") - .select(["is_trusted", "name"]) - .where("id", "=", String(deviceId)) - .executeTakeFirstOrThrow(); - - expect(device.is_trusted).toBe(true); - expect(device.name).toBe("My Work Laptop"); }); test("fails without device fingerprint", async () => { - const user = await createTestUser(getDb(), { - email: "trustnofp@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustnofp@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("No device fingerprint found"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.devices.trust, { name: "Test" }, { context }), - ).rejects.toThrow("No device fingerprint found"); }); test("fails when device does not exist", async () => { - const user = await createTestUser(getDb(), { - email: "trustnodevice@example.com", - }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustnodevice@example.com", + }); - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ - sessionToken, - deviceFingerprint: "nonexistent", - }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { + sessionToken, + deviceFingerprint: "nonexistent", + }); - await expect( - call(router.me.devices.trust, { name: "Test" }, { context }), - ).rejects.toThrow("Device not found"); + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("Device not found"); + }); }); }); describe("me.devices.listTrusted", () => { test("returns only trusted devices", async () => { - const user = await createTestUser(getDb(), { - email: "listtrusted@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "listtrusted@example.com", + }); + + // Create trusted and untrusted devices + await createDevice(db, user.id, { isTrusted: true, name: "Trusted 1" }); + await createDevice(db, user.id, { isTrusted: true, name: "Trusted 2" }); + await createDevice(db, user.id, { isTrusted: false, name: "Untrusted" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(2); + expect(devices.map((d) => d.name).sort()).toEqual([ + "Trusted 1", + "Trusted 2", + ]); + expect(devices.every((d) => d.isTrusted)).toBe(true); }); - - // Create trusted and untrusted devices - await createDevice(user.id, { isTrusted: true, name: "Trusted 1" }); - await createDevice(user.id, { isTrusted: true, name: "Trusted 2" }); - await createDevice(user.id, { isTrusted: false, name: "Untrusted" }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const devices = await call(router.me.devices.listTrusted, undefined, { - context, - }); - - expect(devices).toHaveLength(2); - expect(devices.map((d) => d.name).sort()).toEqual([ - "Trusted 1", - "Trusted 2", - ]); - expect(devices.every((d) => d.isTrusted)).toBe(true); }); test("returns empty list when no trusted devices", async () => { - const user = await createTestUser(getDb(), { - email: "notrusted@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "notrusted@example.com", + }); + + await createDevice(db, user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(0); }); - - await createDevice(user.id, { isTrusted: false }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const devices = await call(router.me.devices.listTrusted, undefined, { - context, - }); - - expect(devices).toHaveLength(0); }); test("returns default name when device name is null", async () => { - const user = await createTestUser(getDb(), { - email: "defaulttrusted@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "defaulttrusted@example.com", + }); + + await createDevice(db, user.id, { + isTrusted: true, + name: undefined, + userAgent: "Chrome/120", + }); + + // Set name to null explicitly + await db + .updateTable("user_devices") + .set({ name: null }) + .where("user_id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(1); + expect(devices[0]?.name).toBe("Unknown device"); }); - - await createDevice(user.id, { - isTrusted: true, - name: undefined, - userAgent: "Chrome/120", - }); - - // Set name to null explicitly - await getDb() - .updateTable("user_devices") - .set({ name: null }) - .where("user_id", "=", user.id) - .execute(); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - const devices = await call(router.me.devices.listTrusted, undefined, { - context, - }); - - expect(devices).toHaveLength(1); - expect(devices[0]?.name).toBe("Unknown device"); }); }); describe("me.devices.untrust", () => { test("untrusts device by ID", async () => { - const user = await createTestUser(getDb(), { - email: "untrust@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "untrust@example.com", + }); + + const { deviceId } = await createDevice(db, user.id, { + isTrusted: true, + name: "Trusted Device", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.me.devices.untrust, { deviceId }, { context }); + + // Verify device is untrusted + const device = await db + .selectFrom("user_devices") + .select(["is_trusted"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(false); }); - - const { deviceId } = await createDevice(user.id, { - isTrusted: true, - name: "Trusted Device", - }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call(router.me.devices.untrust, { deviceId }, { context }); - - // Verify device is untrusted - const device = await getDb() - .selectFrom("user_devices") - .select(["is_trusted"]) - .where("id", "=", String(deviceId)) - .executeTakeFirstOrThrow(); - - expect(device.is_trusted).toBe(false); }); test("fails to untrust non-existent device", async () => { - const user = await createTestUser(getDb(), { - email: "untrustnotfound@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "untrustnotfound@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId: 999999 }, { context }), + ).rejects.toThrow("Device not found"); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.devices.untrust, { deviceId: 999999 }, { context }), - ).rejects.toThrow("Device not found"); }); test("fails to untrust another user's device", async () => { - const user1 = await createTestUser(getDb(), { - email: "untrustuser1@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user1 = await createTestUser(db, { + email: "untrustuser1@example.com", + }); + const user2 = await createTestUser(db, { + email: "untrustuser2@example.com", + }); + + const { deviceId } = await createDevice(db, user2.id, { + isTrusted: true, + }); + + const { token: sessionToken } = await createSession(db, user1.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId }, { context }), + ).rejects.toThrow("Device not found"); }); - const user2 = await createTestUser(getDb(), { - email: "untrustuser2@example.com", - }); - - const { deviceId } = await createDevice(user2.id, { isTrusted: true }); - - const { token: sessionToken } = await createSession(user1.id); - const context = createAPIContext({ sessionToken }); - - await expect( - call(router.me.devices.untrust, { deviceId }, { context }), - ).rejects.toThrow("Device not found"); }); }); describe("me.devices.revokeAll", () => { test("untrusts all devices", async () => { - const user = await createTestUser(getDb(), { - email: "revokealldevices@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokealldevices@example.com", + }); + + await createDevice(db, user.id, { isTrusted: true }); + await createDevice(db, user.id, { isTrusted: true }); + await createDevice(db, user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.me.devices.revokeAll, undefined, { context }); + + // All devices should be untrusted + const devices = await db + .selectFrom("user_devices") + .select(["id", "is_trusted"]) + .where("user_id", "=", user.id) + .execute(); + + expect(devices).toHaveLength(3); + expect(devices.every((d) => !d.is_trusted)).toBe(true); }); - - await createDevice(user.id, { isTrusted: true }); - await createDevice(user.id, { isTrusted: true }); - await createDevice(user.id, { isTrusted: false }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - await call(router.me.devices.revokeAll, undefined, { context }); - - // All devices should be untrusted - const devices = await getDb() - .selectFrom("user_devices") - .select(["id", "is_trusted"]) - .where("user_id", "=", user.id) - .execute(); - - expect(devices).toHaveLength(3); - expect(devices.every((d) => !d.is_trusted)).toBe(true); }); test("works when no devices exist", async () => { - const user = await createTestUser(getDb(), { - email: "revokenodevices@example.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokenodevices@example.com", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + // Should not throw + await call(router.me.devices.revokeAll, undefined, { context }); }); - - const { token: sessionToken } = await createSession(user.id); - const context = createAPIContext({ sessionToken }); - - // Should not throw - await call(router.me.devices.revokeAll, undefined, { context }); }); }); diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index 7deb061..62204ba 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -19,39 +19,30 @@ import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; import { - createTestDb, createTestUser, - destroyTestDb, - runMigrations, - truncateAllTables, + destroySharedDb, + getSharedDb, + initTestDb, } from "../helpers/test-db.js"; +import { withTestTransaction } from "../helpers/test-transaction.js"; /** Session expiry duration: 24 hours in milliseconds */ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; -let db: Kysely | undefined; - -/** - * Get the database connection, throwing if not initialized - */ -function getDb(): Kysely { - if (!db) { - throw new Error("Database not initialized"); - } - return db; -} - /** * Create an API context with optional session token */ -function createAPIContext(sessionToken?: string): APIContext { +function createAPIContext( + db: Kysely, + sessionToken?: string, +): APIContext { const reqHeaders = new Headers(); if (sessionToken) { reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); } return { - db: getDb(), + db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, @@ -63,12 +54,15 @@ function createAPIContext(sessionToken?: string): APIContext { /** * Create a real session in the database and return the token */ -async function createSession(userId: number): Promise { +async function createSession( + db: Kysely, + userId: number, +): Promise { const token = `test-session-${String(Date.now())}${String(Math.random())}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); - await getDb() + await db .insertInto("sessions") .values({ user_id: userId, @@ -87,13 +81,14 @@ async function createSession(userId: number): Promise { * Create a login request in the database and return ID and token */ async function createLoginRequest( + db: Kysely, userId: number, email: string, ): Promise<{ id: number; token: string }> { const token = `test-login-${String(Date.now())}${String(Math.random())}`; const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes - const result = await getDb() + const result = await db .insertInto("login_requests") .values({ user_id: userId, @@ -110,20 +105,26 @@ async function createLoginRequest( /** * Create an authenticated API context for a user (creates session + context) */ -async function createUserAPIContext(userId: number): Promise { - const sessionToken = await createSession(userId); - return createAPIContext(sessionToken); +async function createUserAPIContext( + db: Kysely, + userId: number, +): Promise { + const sessionToken = await createSession(db, userId); + return createAPIContext(db, sessionToken); } /** * Create an API context with login request cookie */ -function createLoginRequestContext(loginToken: string): APIContext { +function createLoginRequestContext( + db: Kysely, + loginToken: string, +): APIContext { const reqHeaders = new Headers(); reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); return { - db: getDb(), + db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, @@ -149,12 +150,13 @@ function expectFirst(arr: T[], message: string): T { * Shared helper to avoid duplication across test suites. */ async function registerPasskey( + db: Kysely, userId: number, email: string, authenticator: VirtualAuthenticator, ) { - const apiCtx = createAPIContext(); - const authCtx = await createUserAPIContext(userId); + const apiCtx = createAPIContext(db); + const authCtx = await createUserAPIContext(db, userId); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -175,12 +177,13 @@ async function registerPasskey( * Shared helper to avoid duplication across test suites. */ async function authenticate( + db: Kysely, userId: number, email: string, authenticator: VirtualAuthenticator, ) { - const { token: loginToken } = await createLoginRequest(userId, email); - const loginCtx = createLoginRequestContext(loginToken); + const { token: loginToken } = await createLoginRequest(db, userId, email); + const loginCtx = createLoginRequestContext(db, loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, @@ -196,732 +199,815 @@ async function authenticate( } beforeAll(async () => { - // Run migrations and create test database connection - await runMigrations(); - db = createTestDb(); + await initTestDb(); }); afterAll(async () => { - if (db) { - await destroyTestDb(db); - } + await destroySharedDb(); }); describe("registration flow", () => { - beforeAll(async () => { - await truncateAllTables(getDb()); - }); - test("creates registration options with challenge stored in DB via router", async () => { - const user = await createTestUser(getDb(), { - email: "reg-options@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "reg-options@test.com", + }); + const ctx = createAPIContext(db); + + // Call router handler directly + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: ctx }, + ); + + // Verify options structure + expect(options.challenge).toBeDefined(); + expect(options.rp.name).toBe(TEST_RP.rpName); + expect(options.rp.id).toBe(TEST_RP.rpID); + // user.name is displayName if available, otherwise email + expect(options.user.name).toBe("Test User"); + expect(challengeId).toBeGreaterThan(0); + + // Verify challenge is stored in database + const challengeRow = await db + .selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeDefined(); }); - const ctx = createAPIContext(); - - // Call router handler directly - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: ctx }, - ); - - // Verify options structure - expect(options.challenge).toBeDefined(); - expect(options.rp.name).toBe(TEST_RP.rpName); - expect(options.rp.id).toBe(TEST_RP.rpID); - // user.name is displayName if available, otherwise email - expect(options.user.name).toBe("Test User"); - expect(challengeId).toBeGreaterThan(0); - - // Verify challenge is stored in database - const challengeRow = await db - ?.selectFrom("webauthn_challenges") - .select("id") - .where("id", "=", String(challengeId)) - .executeTakeFirst(); - - expect(challengeRow).toBeDefined(); }); test("verifies valid registration and stores passkey via router", async () => { - const user = await createTestUser(getDb(), { - email: "reg-verify@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "reg-verify@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Create registration options via router + const apiCtx = createAPIContext(db); + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + + // Create credential with virtual authenticator + const response = authenticator.createCredential(options); + + // Verify registration via router (requires authenticated session) + const authCtx = await createUserAPIContext(db, user.id); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + // Verify passkey is stored in database + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.rpid).toBe(TEST_RP.rpID); + expect(firstPasskey.counter).toBe(0); }); - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - }); - - // Create registration options via router - const apiCtx = createAPIContext(); - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - - // Create credential with virtual authenticator - const response = authenticator.createCredential(options); - - // Verify registration via router (requires authenticated session) - const authCtx = await createUserAPIContext(user.id); - await call( - router.auth.webauthn.verifyRegistration, - { challengeId, response }, - { context: authCtx }, - ); - - // Verify passkey is stored in database - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.rpid).toBe(TEST_RP.rpID); - expect(firstPasskey.counter).toBe(0); }); test("excludes existing passkeys for returning users via router", async () => { - const user = await createTestUser(getDb(), { - email: "exclude-test@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "exclude-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const apiCtx = createAPIContext(db); + const authCtx = await createUserAPIContext(db, user.id); + + // Register first passkey via router + const { options: options1, challengeId: challengeId1 } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response1 = authenticator.createCredential(options1); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: challengeId1, response: response1 }, + { context: authCtx }, + ); + + // Get second registration options via router + const { options: options2 } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + + // Should have excludeCredentials with the first passkey + expect(options2.excludeCredentials).toHaveLength(1); + const excludedCred = expectFirst( + options2.excludeCredentials ?? [], + "Expected excluded credential to exist", + ); + expect(excludedCred.id).toBe(response1.id); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const apiCtx = createAPIContext(); - const authCtx = await createUserAPIContext(user.id); - - // Register first passkey via router - const { options: options1, challengeId: challengeId1 } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - const response1 = authenticator.createCredential(options1); - await call( - router.auth.webauthn.verifyRegistration, - { challengeId: challengeId1, response: response1 }, - { context: authCtx }, - ); - - // Get second registration options via router - const { options: options2 } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - - // Should have excludeCredentials with the first passkey - expect(options2.excludeCredentials).toHaveLength(1); - const excludedCred = expectFirst( - options2.excludeCredentials ?? [], - "Expected excluded credential to exist", - ); - expect(excludedCred.id).toBe(response1.id); }); test("assigns friendly name from known AAGUID via router", async () => { - const user = await createTestUser(getDb(), { - email: "aaguid-test@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "aaguid-test@test.com", + }); + + // Use iCloud Keychain AAGUID + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, + }); + + const apiCtx = createAPIContext(db); + const authCtx = await createUserAPIContext(db, user.id); + + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.name).toBe("iCloud Keychain"); }); - - // Use iCloud Keychain AAGUID - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, - }); - - const apiCtx = createAPIContext(); - const authCtx = await createUserAPIContext(user.id); - - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - const response = authenticator.createCredential(options); - await call( - router.auth.webauthn.verifyRegistration, - { challengeId, response }, - { context: authCtx }, - ); - - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.name).toBe("iCloud Keychain"); }); test("cleans up challenge after verification via router", async () => { - const user = await createTestUser(getDb(), { - email: "cleanup-test@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "cleanup-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const apiCtx = createAPIContext(db); + const authCtx = await createUserAPIContext(db, user.id); + + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + // Challenge should be deleted + const challengeRow = await db + .selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeUndefined(); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const apiCtx = createAPIContext(); - const authCtx = await createUserAPIContext(user.id); - - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - const response = authenticator.createCredential(options); - await call( - router.auth.webauthn.verifyRegistration, - { challengeId, response }, - { context: authCtx }, - ); - - // Challenge should be deleted - const challengeRow = await db - ?.selectFrom("webauthn_challenges") - .select("id") - .where("id", "=", String(challengeId)) - .executeTakeFirst(); - - expect(challengeRow).toBeUndefined(); }); test("rejects expired/missing challenges via router", async () => { - const user = await createTestUser(getDb(), { - email: "expired-test@test.com", - }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const apiCtx = createAPIContext(); - const authCtx = await createUserAPIContext(user.id); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expired-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const apiCtx = createAPIContext(db); + const authCtx = await createUserAPIContext(db, user.id); - // Create options via router - const { options } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - const response = authenticator.createCredential(options); - - // Use a non-existent challenge ID - should fail - try { - await call( - router.auth.webauthn.verifyRegistration, - { challengeId: 999999, response }, - { context: authCtx }, + // Create options via router + const { options } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, ); - throw new Error("Expected verification to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Registration timed out"); - } + const response = authenticator.createCredential(options); + + // Use a non-existent challenge ID - should fail + try { + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: 999999, response }, + { context: authCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Registration timed out"); + } + }); }); }); describe("authentication flow", () => { - beforeAll(async () => { - await truncateAllTables(getDb()); - }); - test("creates authentication options with user's passkeys via router", async () => { - const user = await createTestUser(getDb(), { - email: "auth-options@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "auth-options@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Register a passkey first via router + const regResponse = await registerPasskey( + db, + user.id, + user.email, + authenticator, + ); + + // Create authentication options via router + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + expect(options.challenge).toBeDefined(); + expect(options.rpId).toBe(TEST_RP.rpID); + expect(options.allowCredentials).toHaveLength(1); + const allowedCred = expectFirst( + options.allowCredentials ?? [], + "Expected allowed credential to exist", + ); + expect(allowedCred.id).toBe(regResponse.id); + expect(challengeId).toBeGreaterThan(0); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - // Register a passkey first via router - const regResponse = await registerPasskey( - user.id, - user.email, - authenticator, - ); - - // Create authentication options via router - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options, challengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - - expect(options.challenge).toBeDefined(); - expect(options.rpId).toBe(TEST_RP.rpID); - expect(options.allowCredentials).toHaveLength(1); - const allowedCred = expectFirst( - options.allowCredentials ?? [], - "Expected allowed credential to exist", - ); - expect(allowedCred.id).toBe(regResponse.id); - expect(challengeId).toBeGreaterThan(0); }); test("verifies valid authentication and updates counter via router", async () => { - const user = await createTestUser(getDb(), { - email: "auth-verify@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "auth-verify@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); + + // Authenticate via router + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); + + // Verify counter was updated + const passkeys = await getUserPasskeys(db, user.id); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); - - // Authenticate via router - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options: authOptions, challengeId: authChallengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - const authResponse = authenticator.getAssertion(authOptions); - - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId: authChallengeId, response: authResponse }, - { context: loginCtx }, - ); - - // Verify counter was updated - const passkeys = await getUserPasskeys(getDb(), user.id); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(1); }); test("updates last_used_at timestamp via router", async () => { - const user = await createTestUser(getDb(), { email: "last-used@test.com" }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "last-used@test.com" }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); - // Check initial state - let passkeys = await getUserPasskeys(getDb(), user.id); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.lastUsedAt).toBeNull(); + // Check initial state + let passkeys = await getUserPasskeys(db, user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.lastUsedAt).toBeNull(); - // Authenticate via router - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options: authOptions, challengeId: authChallengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - const authResponse = authenticator.getAssertion(authOptions); - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId: authChallengeId, response: authResponse }, - { context: loginCtx }, - ); + // Authenticate via router + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); - // Check last_used_at is now set - passkeys = await getUserPasskeys(getDb(), user.id); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.lastUsedAt).not.toBeNull(); + // Check last_used_at is now set + passkeys = await getUserPasskeys(db, user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.lastUsedAt).not.toBeNull(); + }); }); test("cleans up challenge after authentication via router", async () => { - const user = await createTestUser(getDb(), { - email: "auth-cleanup@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "auth-cleanup@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); + + // Authenticate via router + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); + + // Challenge should be deleted + const challengeRow = await db + .selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(authChallengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeUndefined(); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); - - // Authenticate via router - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options: authOptions, challengeId: authChallengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - const authResponse = authenticator.getAssertion(authOptions); - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId: authChallengeId, response: authResponse }, - { context: loginCtx }, - ); - - // Challenge should be deleted - const challengeRow = await db - ?.selectFrom("webauthn_challenges") - .select("id") - .where("id", "=", String(authChallengeId)) - .executeTakeFirst(); - - expect(challengeRow).toBeUndefined(); }); test("rejects unknown credential IDs", async () => { - const user = await createTestUser(getDb(), { - email: "unknown-cred@test.com", - }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "unknown-cred@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); - // Create auth options via router - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options: authOptions } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - - // Use a fresh authenticator that doesn't have the registered credential - const freshAuthenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - }); - // First create a credential so the authenticator has something (use same registration options) - const apiCtx = createAPIContext(); - const { options: regOptions } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: user.email }, - { context: apiCtx }, - ); - freshAuthenticator.createCredential(regOptions); - - // This should fail because the fresh authenticator doesn't have the right credential - try { - freshAuthenticator.getAssertion(authOptions); - throw new Error("Expected assertion to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "No matching credential found", + // Create auth options via router + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, ); - } + const loginCtx = createLoginRequestContext(db, loginToken); + const { options: authOptions } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + // Use a fresh authenticator that doesn't have the registered credential + const freshAuthenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + // First create a credential so the authenticator has something (use same registration options) + const apiCtx = createAPIContext(db); + const { options: regOptions } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + freshAuthenticator.createCredential(regOptions); + + // This should fail because the fresh authenticator doesn't have the right credential + try { + freshAuthenticator.getAssertion(authOptions); + throw new Error("Expected assertion to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "No matching credential found", + ); + } + }); }); }); describe("security tests", () => { - beforeAll(async () => { - await truncateAllTables(getDb()); - }); - test("rejects replayed credentials (counter check) via router", async () => { - const user = await createTestUser(getDb(), { - email: "counter-replay@test.com", - }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "counter-replay@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register passkey via router - const regResponse = await registerPasskey( - user.id, - user.email, - authenticator, - ); + // Register passkey via router + const regResponse = await registerPasskey( + db, + user.id, + user.email, + authenticator, + ); - // First authentication should succeed - await authenticate(user.id, user.email, authenticator); + // First authentication should succeed + await authenticate(db, user.id, user.email, authenticator); - // Verify counter was updated to 1 - let passkeys = await getUserPasskeys(getDb(), user.id); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(1); + // Verify counter was updated to 1 + let passkeys = await getUserPasskeys(db, user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); - // Reset the authenticator's sign count to 0 (simulating replay attack) - authenticator.setSignCount(regResponse.id, 0); + // Reset the authenticator's sign count to 0 (simulating replay attack) + authenticator.setSignCount(regResponse.id, 0); - // Create a new authentication challenge - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options, challengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - - // Get assertion with replayed (lower) counter - const authResponse = authenticator.getAssertion(options); - - // Verify authentication should fail due to counter replay (throws an error) - try { - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId, response: authResponse }, + // Create a new authentication challenge + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, { context: loginCtx }, ); - throw new Error("Expected verification to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("counter"); - } - // Counter should not have changed - passkeys = await getUserPasskeys(getDb(), user.id); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(1); + // Get assertion with replayed (lower) counter + const authResponse = authenticator.getAssertion(options); + + // Verify authentication should fail due to counter replay (throws an error) + try { + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId, response: authResponse }, + { context: loginCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("counter"); + } + + // Counter should not have changed + passkeys = await getUserPasskeys(db, user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); + }); }); test("rejects tampered authentication response", async () => { - const user = await createTestUser(getDb(), { - email: "tampered-response@test.com", - }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "tampered-response@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); - // Create authentication challenge - const { token: loginToken } = await createLoginRequest(user.id, user.email); - const loginCtx = createLoginRequestContext(loginToken); - const { options, challengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: loginCtx }, - ); - - // Get valid assertion - const authResponse = authenticator.getAssertion(options); - - // Tamper with the authenticatorData (flip some bits in the middle) - // This causes signature verification to fail without breaking ASN.1 parsing - const tamperedAuthData = Buffer.from( - authResponse.response.authenticatorData, - "base64url", - ); - // Ensure buffer is long enough (authenticatorData is always > 37 bytes) - if (tamperedAuthData.length < 21) { - throw new Error("authenticatorData too short for tampering"); - } - const originalByte = tamperedAuthData[20]; - if (originalByte === undefined) { - throw new Error("Failed to read byte at index 20"); - } - tamperedAuthData[20] = originalByte ^ 0xff; // Flip bits in a byte (within rpIdHash) - const tamperedResponse = { - ...authResponse, - response: { - ...authResponse.response, - authenticatorData: tamperedAuthData.toString("base64url"), - }, - }; - - // Verify authentication should fail due to tampering (throws an error) - try { - await call( - router.auth.webauthn.verifyAuthentication, - { challengeId, response: tamperedResponse }, + // Create authentication challenge + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + ); + const loginCtx = createLoginRequestContext(db, loginToken); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, { context: loginCtx }, ); - throw new Error("Expected verification to fail"); - } catch (error) { - // Tampering should cause verification to fail with an error - expect(error).toBeInstanceOf(Error); - // Should not be our sentinel error - expect((error as Error).message).not.toBe( - "Expected verification to fail", + + // Get valid assertion + const authResponse = authenticator.getAssertion(options); + + // Tamper with the authenticatorData (flip some bits in the middle) + // This causes signature verification to fail without breaking ASN.1 parsing + const tamperedAuthData = Buffer.from( + authResponse.response.authenticatorData, + "base64url", ); - } + // Ensure buffer is long enough (authenticatorData is always > 37 bytes) + if (tamperedAuthData.length < 21) { + throw new Error("authenticatorData too short for tampering"); + } + const originalByte = tamperedAuthData[20]; + if (originalByte === undefined) { + throw new Error("Failed to read byte at index 20"); + } + tamperedAuthData[20] = originalByte ^ 0xff; // Flip bits in a byte (within rpIdHash) + const tamperedResponse = { + ...authResponse, + response: { + ...authResponse.response, + authenticatorData: tamperedAuthData.toString("base64url"), + }, + }; + + // Verify authentication should fail due to tampering (throws an error) + try { + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId, response: tamperedResponse }, + { context: loginCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + // Tampering should cause verification to fail with an error + expect(error).toBeInstanceOf(Error); + // Should not be our sentinel error + expect((error as Error).message).not.toBe( + "Expected verification to fail", + ); + } + }); }); }); describe("full passkey lifecycle", () => { - beforeAll(async () => { - await truncateAllTables(getDb()); - }); - test("register → authenticate → add second passkey → authenticate with either via router", async () => { - const user = await createTestUser(getDb(), { email: "lifecycle@test.com" }); - const authenticator1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const authenticator2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "lifecycle@test.com" }); + const authenticator1 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const authenticator2 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register first passkey via router - await registerPasskey(user.id, user.email, authenticator1); + // Register first passkey via router + await registerPasskey(db, user.id, user.email, authenticator1); - // Authenticate with first passkey via router - await authenticate(user.id, user.email, authenticator1); + // Authenticate with first passkey via router + await authenticate(db, user.id, user.email, authenticator1); - // Register second passkey via router - await registerPasskey(user.id, user.email, authenticator2); + // Register second passkey via router + await registerPasskey(db, user.id, user.email, authenticator2); - // Verify user now has 2 passkeys - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(2); + // Verify user now has 2 passkeys + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(2); - // Authenticate with second passkey via router - await authenticate(user.id, user.email, authenticator2); + // Authenticate with second passkey via router + await authenticate(db, user.id, user.email, authenticator2); - // Authenticate with first passkey again via router - await authenticate(user.id, user.email, authenticator1); + // Authenticate with first passkey again via router + await authenticate(db, user.id, user.email, authenticator1); + }); }); test("register → authenticate multiple times → counter increments via router", async () => { - const user = await createTestUser(getDb(), { - email: "counter-test@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "counter-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); + + // Verify initial counter + let passkeys = await getUserPasskeys(db, user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(0); + + // Authenticate 5 times via router + for (let i = 1; i <= 5; i++) { + await authenticate(db, user.id, user.email, authenticator); + + // Verify counter incremented + passkeys = await getUserPasskeys(db, user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(i); + } }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - // Register passkey via router - await registerPasskey(user.id, user.email, authenticator); - - // Verify initial counter - let passkeys = await getUserPasskeys(getDb(), user.id); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(0); - - // Authenticate 5 times via router - for (let i = 1; i <= 5; i++) { - await authenticate(user.id, user.email, authenticator); - - // Verify counter incremented - passkeys = await getUserPasskeys(getDb(), user.id); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(i); - } }); }); describe("passkey management", () => { - beforeAll(async () => { - await truncateAllTables(getDb()); - }); - test("lists passkeys with correct data via router", async () => { - const user = await createTestUser(getDb(), { - email: "list-passkeys@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "list-passkeys@test.com", + }); + const authenticator1 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, + }); + const authenticator2 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.GOOGLE_PASSWORD_MANAGER, + }); + + // Register two passkeys + await registerPasskey(db, user.id, user.email, authenticator1); + await registerPasskey(db, user.id, user.email, authenticator2); + + // List passkeys via router handler + const ctx = await createUserAPIContext(db, user.id); + const passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + + expect(passkeys).toHaveLength(2); + + // Verify first passkey data (router returns id, name, createdAt, lastUsedAt) + const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); + if (!icloudPasskey) { + throw new Error("Expected iCloud Keychain passkey to exist"); + } + expect(icloudPasskey.id).toBeGreaterThan(0); + expect(icloudPasskey.createdAt).toBeInstanceOf(Date); + expect(icloudPasskey.lastUsedAt).toBeNull(); + + // Verify second passkey data + const googlePasskey = passkeys.find( + (p) => p.name === "Google Password Manager", + ); + if (!googlePasskey) { + throw new Error("Expected Google Password Manager passkey to exist"); + } }); - const authenticator1 = new VirtualAuthenticator({ - origin: TEST_RP.origin, - aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, - }); - const authenticator2 = new VirtualAuthenticator({ - origin: TEST_RP.origin, - aaguid: KNOWN_AAGUIDS.GOOGLE_PASSWORD_MANAGER, - }); - - // Register two passkeys - await registerPasskey(user.id, user.email, authenticator1); - await registerPasskey(user.id, user.email, authenticator2); - - // List passkeys via router handler - const ctx = await createUserAPIContext(user.id); - const passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - - expect(passkeys).toHaveLength(2); - - // Verify first passkey data (router returns id, name, createdAt, lastUsedAt) - const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); - if (!icloudPasskey) { - throw new Error("Expected iCloud Keychain passkey to exist"); - } - expect(icloudPasskey.id).toBeGreaterThan(0); - expect(icloudPasskey.createdAt).toBeInstanceOf(Date); - expect(icloudPasskey.lastUsedAt).toBeNull(); - - // Verify second passkey data - const googlePasskey = passkeys.find( - (p) => p.name === "Google Password Manager", - ); - if (!googlePasskey) { - throw new Error("Expected Google Password Manager passkey to exist"); - } }); test("passkey stores correct device type and backup status", async () => { - const user = await createTestUser(getDb(), { - email: "device-type@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "device-type@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + await registerPasskey(db, user.id, user.email, authenticator); + + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + + // Virtual authenticator creates singleDevice passkeys (no backup flags set) + expect(firstPasskey.deviceType).toBe("singleDevice"); + expect(firstPasskey.backupEligible).toBe(false); + expect(firstPasskey.backupStatus).toBe(false); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - await registerPasskey(user.id, user.email, authenticator); - - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - - // Virtual authenticator creates singleDevice passkeys (no backup flags set) - expect(firstPasskey.deviceType).toBe("singleDevice"); - expect(firstPasskey.backupEligible).toBe(false); - expect(firstPasskey.backupStatus).toBe(false); }); test("renames passkey successfully via router", async () => { - const user = await createTestUser(getDb(), { - email: "rename-test@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "rename-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + await registerPasskey(db, user.id, user.email, authenticator); + + const ctx = await createUserAPIContext(db, user.id); + let passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const passkeyId = firstPasskey.id; + const originalName = firstPasskey.name; + + // Rename the passkey via router handler + const newName = "My MacBook Pro"; + await call( + router.me.passkeys.rename, + { passkeyId, name: newName }, + { context: ctx }, + ); + + // Verify name changed + passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.name).toBe(newName); + expect(firstPasskey.name).not.toBe(originalName); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - await registerPasskey(user.id, user.email, authenticator); - - const ctx = await createUserAPIContext(user.id); - let passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - const passkeyId = firstPasskey.id; - const originalName = firstPasskey.name; - - // Rename the passkey via router handler - const newName = "My MacBook Pro"; - await call( - router.me.passkeys.rename, - { passkeyId, name: newName }, - { context: ctx }, - ); - - // Verify name changed - passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.name).toBe(newName); - expect(firstPasskey.name).not.toBe(originalName); }); test("rename does not affect other user's passkeys", async () => { - const user1 = await createTestUser(getDb(), { - email: "rename-user1@test.com", - }); - const user2 = await createTestUser(getDb(), { - email: "rename-user2@test.com", - }); - const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + await withTestTransaction(getSharedDb(), async (db) => { + const user1 = await createTestUser(db, { + email: "rename-user1@test.com", + }); + const user2 = await createTestUser(db, { + email: "rename-user2@test.com", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - await registerPasskey(user1.id, user1.email, auth1); - await registerPasskey(user2.id, user2.email, auth2); + await registerPasskey(db, user1.id, user1.email, auth1); + await registerPasskey(db, user2.id, user2.email, auth2); - const ctx1 = await createUserAPIContext(user1.id); - const ctx2 = await createUserAPIContext(user2.id); + const ctx1 = await createUserAPIContext(db, user1.id); + const ctx2 = await createUserAPIContext(db, user2.id); - const user2Passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx2, - }); - const user2FirstPasskey = user2Passkeys[0]; - if (!user2FirstPasskey) { - throw new Error("Expected user2 passkey to exist"); - } + const user2Passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx2, + }); + const user2FirstPasskey = user2Passkeys[0]; + if (!user2FirstPasskey) { + throw new Error("Expected user2 passkey to exist"); + } - // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) - try { - await call( - router.me.passkeys.rename, - { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, - { context: ctx1 }, + // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.rename, + { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + { context: ctx1 }, + ); + throw new Error("Expected rename to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } + + // User2's passkey should be unchanged + const user2PasskeysAfter = await call( + router.me.passkeys.list, + undefined, + { + context: ctx2, + }, ); - throw new Error("Expected rename to fail with NOT_FOUND"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Passkey not found"); - } - - // User2's passkey should be unchanged - const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { - context: ctx2, + const user2FirstPasskeyAfter = user2PasskeysAfter[0]; + if (!user2FirstPasskeyAfter) { + throw new Error("Expected user2 passkey to exist after"); + } + expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name); }); - const user2FirstPasskeyAfter = user2PasskeysAfter[0]; - if (!user2FirstPasskeyAfter) { - throw new Error("Expected user2 passkey to exist after"); - } - expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name); }); + // Note: This test uses getSharedDb() directly because the delete passkey + // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("deletes passkey when user has password via router", async () => { - const user = await createTestUser(getDb(), { + const db = getSharedDb(); + const user = await createTestUser(db, { email: "delete-with-password@test.com", passwordHash: "fake-password-hash", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - await registerPasskey(user.id, user.email, authenticator); + await registerPasskey(db, user.id, user.email, authenticator); - const ctx = await createUserAPIContext(user.id); + const ctx = await createUserAPIContext(db, user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -937,17 +1023,20 @@ describe("passkey management", () => { expect(passkeys).toHaveLength(0); }); + // Note: This test uses getSharedDb() directly because the delete passkey + // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("deletes passkey when user has multiple passkeys via router", async () => { - const user = await createTestUser(getDb(), { + const db = getSharedDb(); + const user = await createTestUser(db, { email: "delete-multi@test.com", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - await registerPasskey(user.id, user.email, auth1); - await registerPasskey(user.id, user.email, auth2); + await registerPasskey(db, user.id, user.email, auth1); + await registerPasskey(db, user.id, user.email, auth2); - const ctx = await createUserAPIContext(user.id); + const ctx = await createUserAPIContext(db, user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -969,16 +1058,19 @@ describe("passkey management", () => { expect(firstPasskey.id).not.toBe(firstPasskeyId); }); + // Note: This test uses getSharedDb() directly because the delete passkey + // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("prevents deleting last passkey without password via router", async () => { - const user = await createTestUser(getDb(), { + const db = getSharedDb(); + const user = await createTestUser(db, { email: "delete-last@test.com", // No password set }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - await registerPasskey(user.id, user.email, authenticator); + await registerPasskey(db, user.id, user.email, authenticator); - const ctx = await createUserAPIContext(user.id); + const ctx = await createUserAPIContext(db, user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -1004,23 +1096,26 @@ describe("passkey management", () => { expect(passkeysAfter).toHaveLength(1); }); + // Note: This test uses getSharedDb() directly because the delete passkey + // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("delete does not affect other user's passkeys via router", async () => { - const user1 = await createTestUser(getDb(), { + const db = getSharedDb(); + const user1 = await createTestUser(db, { email: "delete-user1@test.com", passwordHash: "fake-hash", }); - const user2 = await createTestUser(getDb(), { + const user2 = await createTestUser(db, { email: "delete-user2@test.com", passwordHash: "fake-hash", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - await registerPasskey(user1.id, user1.email, auth1); - await registerPasskey(user2.id, user2.email, auth2); + await registerPasskey(db, user1.id, user1.email, auth1); + await registerPasskey(db, user2.id, user2.email, auth2); - const ctx1 = await createUserAPIContext(user1.id); - const ctx2 = await createUserAPIContext(user2.id); + const ctx1 = await createUserAPIContext(db, user1.id); + const ctx2 = await createUserAPIContext(db, user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -1051,45 +1146,51 @@ describe("passkey management", () => { }); test("passkey credentialId is unique and stored correctly", async () => { - const user = await createTestUser(getDb(), { - email: "credential-id@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "credential-id@test.com", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(db, user.id, user.email, auth1); + await registerPasskey(db, user.id, user.email, auth2); + + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(2); + const firstPasskey = passkeys[0]; + const secondPasskey = passkeys[1]; + if (!(firstPasskey && secondPasskey)) { + throw new Error("Expected both passkeys to exist"); + } + + // Credential IDs should be unique + expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId); + + // Credential IDs should be base64url encoded + expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); + expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); }); - const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - await registerPasskey(user.id, user.email, auth1); - await registerPasskey(user.id, user.email, auth2); - - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(2); - const firstPasskey = passkeys[0]; - const secondPasskey = passkeys[1]; - if (!(firstPasskey && secondPasskey)) { - throw new Error("Expected both passkeys to exist"); - } - - // Credential IDs should be unique - expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId); - - // Credential IDs should be base64url encoded - expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); - expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); }); test("passkey transports are stored and retrieved correctly", async () => { - const user = await createTestUser(getDb(), { - email: "transports@test.com", + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "transports@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + await registerPasskey(db, user.id, user.email, authenticator); + + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + + // Virtual authenticator sets transports to ["internal", "hybrid"] + expect(firstPasskey.transports).toContain("internal"); + expect(firstPasskey.transports).toContain("hybrid"); }); - const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); - - await registerPasskey(user.id, user.email, authenticator); - - const passkeys = await getUserPasskeys(getDb(), user.id); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - - // Virtual authenticator sets transports to ["internal", "hybrid"] - expect(firstPasskey.transports).toContain("internal"); - expect(firstPasskey.transports).toContain("hybrid"); }); }); diff --git a/apps/api-server/src/__tests__/helpers/test-db.ts b/apps/api-server/src/__tests__/helpers/test-db.ts index 0c21325..759e2d6 100644 --- a/apps/api-server/src/__tests__/helpers/test-db.ts +++ b/apps/api-server/src/__tests__/helpers/test-db.ts @@ -238,3 +238,64 @@ export async function createTestUser( export async function destroyTestDb(db: Kysely): Promise { await db.destroy(); } + +// ============================================================================ +// Shared Database Singleton (for transaction-based test isolation) +// ============================================================================ + +let sharedDb: Kysely | null = null; + +/** + * Initialize the shared test database once. + * Runs migrations and truncates all tables to start with a clean slate. + * Subsequent calls return the existing connection. + * + * Use this with `withTestTransaction()` for fast test isolation. + * + * @example + * ```typescript + * beforeAll(async () => { + * await initTestDb(); + * }); + * + * test("does something", async () => { + * await withTestTransaction(getSharedDb(), async (db) => { + * // test code using db + * }); + * }); + * ``` + */ +export async function initTestDb(): Promise> { + if (!sharedDb) { + await runMigrations(); + sharedDb = createTestDb(); + await truncateAllTables(sharedDb); // Clean slate once at start + } + return sharedDb; +} + +/** + * Get the shared test database connection. + * Must call `initTestDb()` first. + * + * @throws Error if database not initialized + */ +export function getSharedDb(): Kysely { + if (!sharedDb) { + throw new Error( + "Test DB not initialized. Call initTestDb() in beforeAll first.", + ); + } + return sharedDb; +} + +/** + * Destroy the shared test database connection. + * Call this in a global afterAll if needed. + */ +export async function destroySharedDb(): Promise { + if (sharedDb) { + await sharedDb.destroy(); + sharedDb = null; + } +} diff --git a/apps/api-server/src/__tests__/helpers/test-transaction.ts b/apps/api-server/src/__tests__/helpers/test-transaction.ts new file mode 100644 index 0000000..f985e14 --- /dev/null +++ b/apps/api-server/src/__tests__/helpers/test-transaction.ts @@ -0,0 +1,60 @@ +/** + * Transaction-based test isolation helper + * + * Wraps test code in a transaction that auto-rollbacks, providing + * fast test isolation without truncating tables between tests. + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; + +/** + * Signal used to trigger transaction rollback after test completes + */ +class RollbackSignal extends Error { + constructor() { + super("RollbackSignal"); + this.name = "RollbackSignal"; + } +} + +/** + * Runs a test function inside a transaction that auto-rollbacks. + * + * The transaction implements the same interface as Kysely, + * so it can be passed to context builders and used for all queries. + * After the test completes, the transaction is rolled back, providing + * instant cleanup without truncating tables. + * + * @example + * ```typescript + * test("creates user", async () => { + * await withTestTransaction(getSharedDb(), async (db) => { + * const user = await createTestUser(db, { email: "test@example.com" }); + * const ctx = createAPIContext({ db }); + * // ... test code + * }); // Auto-rollback here + * }); + * ``` + */ +export async function withTestTransaction( + db: Kysely, + testFn: (trx: Kysely) => Promise, +): Promise { + let result: T | undefined; + + try { + await db.transaction().execute(async (trx) => { + result = await testFn(trx); + // Force rollback by throwing after test completes successfully + throw new RollbackSignal(); + }); + } catch (e) { + // Swallow the rollback signal - this is expected behavior + if (!(e instanceof RollbackSignal)) { + throw e; + } + } + + return result; +} diff --git a/db/schema.sql b/db/schema.sql index f33aec6..6be1a60 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR +\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR +\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap --