diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 90fae99..2a70b18 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -9,9 +9,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", - "test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", - "test:unit": "bun test src/__tests__/unit", - "test": "bun test --coverage src/utils" + "test": "bun test src/ --no-parallel" }, "dependencies": { "@formatjs/intl-durationformat": "^0.9.2", @@ -34,12 +32,11 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", + "@reviq/test-helpers": "workspace:*", "@reviq/virtual-authenticator": "workspace:*", "@types/bun": "catalog:", - "@types/pg": "^8.16.0", "@types/zxcvbn": "^4.4.5", "eslint": "catalog:", - "pg": "^8.16.3", "pino-pretty": "^13.1.3", "typescript": "catalog:" } diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts index 3f6f084..2452a2c 100644 --- a/apps/api-server/src/__tests__/e2e/auth.test.ts +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -41,14 +41,19 @@ import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; import { beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; +import { + createTestUser, + describeE2E, + getSharedDb, + initTestDb, + TEST_RP, + withTestTransaction, +} from "@reviq/test-helpers"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; 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 { 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; @@ -263,24 +268,159 @@ async function createPasswordReset( return token; } -// Test setup -beforeAll(async () => { - await initTestDb(); -}); +describeE2E("auth", () => { + // Test setup + beforeAll(async () => { + await initTestDb(); + }); -// ============================================================================= -// auth.signup tests -// ============================================================================= + // ============================================================================= + // auth.signup tests + // ============================================================================= -describe("auth.signup", () => { - test("creates user with valid password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + describe("auth.signup", () => { + test("creates user with valid password", async () => { + 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 }, + ); + + expect(result.success).toBe(true); + + // 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(); + + // 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 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 db + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(verifications.length).toBe(1); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); + + await call( + router.auth.signup, + { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); + + const user = await db + .selectFrom("users") + .select(["email"]) + .where("email", "=", "uppercase@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + }); + }); + + test("rejects weak password", async () => { + 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(); + }); + }); + + test("rejects duplicate email (anti-enumeration)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + // Create existing user + await createTestUser(db, { email: "existing@example.com" }); + + 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"); + }); + }); + + test("rejects signup without password or passkey", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); + + 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(db); + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "passkeyuser@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential with virtual authenticator + const response = authenticator.createCredential(options); + + // Step 3: Signup with passkey + const signupCtx = createAPIContext(db); const result = await call( router.auth.signup, - { email: "newuser@example.com", password: "StrongP@ssw0rd123!" }, - { context: ctx }, + { + email: "passkeyuser@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, ); expect(result.success).toBe(true); @@ -289,246 +429,31 @@ describe("auth.signup", () => { const user = await db .selectFrom("users") .selectAll() - .where("email", "=", "newuser@example.com") + .where("email", "=", "passkeyuser@example.com") .executeTakeFirst(); expect(user).toBeDefined(); - expect(user?.password_hash).not.toBeNull(); + expect(user?.password_hash).toBeNull(); // No password for passkey signup expect(user?.email_verified_at).toBeNull(); + // Verify passkey was stored + const passkeys = await db + .selectFrom("passkeys") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + + expect(passkeys.length).toBe(1); + expect(passkeys[0]?.name).toBeDefined(); + // Verify session cookie was set const sessionToken = getCookieFromResponse( - ctx.resHeaders, + signupCtx.resHeaders, COOKIE_NAMES.SESSION_TOKEN, ); expect(sessionToken).not.toBeNull(); - // 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 db - .selectFrom("email_verifications") - .selectAll() - .where("user_id", "=", assertDefined(user).id) - .execute(); - expect(verifications.length).toBe(1); - }); - }); - - test("normalizes email to lowercase", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); - - await call( - router.auth.signup, - { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, - { context: ctx }, - ); - - const user = await db - .selectFrom("users") - .select(["email"]) - .where("email", "=", "uppercase@example.com") - .executeTakeFirst(); - - expect(user).toBeDefined(); - }); - }); - - test("rejects weak password", async () => { - 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(); - }); - }); - - test("rejects duplicate email (anti-enumeration)", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - // Create existing user - await createTestUser(db, { email: "existing@example.com" }); - - 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"); - }); - }); - - test("rejects signup without password or passkey", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); - - 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(db); - - // Step 1: Create registration options - const { options, challengeId } = await call( - router.auth.webauthn.createRegistrationOptions, - { email: "passkeyuser@example.com" }, - { context: ctx }, - ); - - // Step 2: Create credential with virtual authenticator - const response = authenticator.createCredential(options); - - // Step 3: Signup with passkey - const signupCtx = createAPIContext(db); - const result = await call( - router.auth.signup, - { - email: "passkeyuser@example.com", - passkeyInfo: { challengeId, response }, - }, - { context: signupCtx }, - ); - - expect(result.success).toBe(true); - - // Verify user was created - const user = await db - .selectFrom("users") - .selectAll() - .where("email", "=", "passkeyuser@example.com") - .executeTakeFirst(); - - expect(user).toBeDefined(); - expect(user?.password_hash).toBeNull(); // No password for passkey signup - expect(user?.email_verified_at).toBeNull(); - - // Verify passkey was stored - const passkeys = await db - .selectFrom("passkeys") - .selectAll() - .where("user_id", "=", assertDefined(user).id) - .execute(); - - expect(passkeys.length).toBe(1); - expect(passkeys[0]?.name).toBeDefined(); - - // Verify session cookie was set - const sessionToken = getCookieFromResponse( - signupCtx.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); - - // Verify webauthn challenge was deleted - const challenges = await db - .selectFrom("webauthn_challenges") - .selectAll() - .where("id", "=", String(challengeId)) - .execute(); - expect(challenges.length).toBe(0); - }); - - test("rejects passkey signup with expired challenge", async () => { - 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 2: Create credential - const response = authenticator.createCredential(options); - - // 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(db); - - 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 () => { - 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 2: Create credential - const response = authenticator.createCredential(options); - - // Step 3: Tamper with the response - response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 - - // 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"); - - // Verify challenge was deleted (cleanup on error) + // Verify webauthn challenge was deleted const challenges = await db .selectFrom("webauthn_challenges") .selectAll() @@ -536,1572 +461,1664 @@ describe("auth.signup", () => { .execute(); expect(challenges.length).toBe(0); }); - }); -}); -// ============================================================================= -// auth.createLoginRequest tests -// ============================================================================= + test("rejects passkey signup with expired challenge", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + const ctx = createAPIContext(db); -describe("auth.createLoginRequest", () => { - test("returns auth methods for existing user with password", async () => { - 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_"); - }); - }); - - test("detects trusted device", async () => { - 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); - }); - }); - - test("returns fake response for non-existent user (anti-enumeration)", async () => { - 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 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 db - .selectFrom("login_requests") - .selectAll() - .execute(); - expect(loginRequests.length).toBe(0); - }); - }); - - test("normalizes email to lowercase", async () => { - 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); - }); - }); - - test("generates device fingerprint if not present", async () => { - 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(); - }); - }); -}); - -// ============================================================================= -// auth.loginPassword tests -// ============================================================================= - -describe("auth.loginPassword", () => { - test("completes login immediately for trusted device", async () => { - 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(); - }); - }); - - test("sends email for untrusted device (does not complete immediately)", async () => { - 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(); - }); - }); - - test("rejects invalid password", async () => { - 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!" }, + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "expiredchallenge@example.com" }, { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); + ); + + // Step 2: Create credential + const response = authenticator.createCredential(options); + + // 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(db); + + 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 () => { + 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 2: Create credential + const response = authenticator.createCredential(options); + + // Step 3: Tamper with the response + response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 + + // 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"); + + // 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); + }); }); }); - test("rejects expired login request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "expired@example.com", - passwordHash: await hashPassword("TestPassword123!"), + // ============================================================================= + // auth.createLoginRequest tests + // ============================================================================= + + describe("auth.createLoginRequest", () => { + test("returns auth methods for existing user with password", async () => { + 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 { token: loginToken } = await createLoginRequest( - db, - user.id, - "expired@example.com", - { expiresAt: new Date(Date.now() - 1000) }, // Expired - ); + test("detects trusted device", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); - const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + const fingerprint = "trusted-device-fp"; + await createTrustedDevice(db, user.id, fingerprint); - await expect( - call( + const ctx = createAPIContext(db, { 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 () => { + 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 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 db + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(0); + }); + }); + + test("normalizes email to lowercase", async () => { + 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); + }); + }); + + test("generates device fingerprint if not present", async () => { + 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(); + }); + }); + }); + + // ============================================================================= + // auth.loginPassword tests + // ============================================================================= + + describe("auth.loginPassword", () => { + test("completes login immediately for trusted device", async () => { + 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 }, - ), - ).rejects.toThrow("Login request has expired"); + ); + + 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("rejects when no login request token cookie", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); // No login request token + test("sends email for untrusted device (does not complete immediately)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "untrustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); - await expect( - call( + 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 }, - ), - ).rejects.toThrow("Invalid email or password"); - }); - }); + ); - test("rejects fake/invalid login request token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db, { - loginRequestToken: "fake-token-12345", + 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(); }); - - await expect( - call( - router.auth.loginPassword, - { password: "TestPassword123!" }, - { context: ctx }, - ), - ).rejects.toThrow("Invalid email or password"); }); - }); - test("rejects user without password set", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "nopassword@example.com", - // No password hash + test("rejects invalid password", async () => { + 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( - 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"); }); - }); -}); -// ============================================================================= -// auth.loginPasswordConfirm tests -// ============================================================================= + test("rejects expired login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expired@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); -describe("auth.loginPasswordConfirm", () => { - test("marks login request as completed with valid token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "confirm@example.com", + 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( - db, - user.id, - "confirm@example.com", - ); - - const ctx = createAPIContext(db); - 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 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", + test("rejects when no login request token cookie", async () => { + 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"); }); - - 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); + test("rejects fake/invalid login request token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db, { + loginRequestToken: "fake-token-12345", + }); - 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", + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); }); + }); - const { token: loginToken } = await createLoginRequest( - db, - user.id, - "expiredconfirm@example.com", - { expiresAt: new Date(Date.now() - 1000) }, // Expired - ); + test("rejects user without password set", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nopassword@example.com", + // No password hash + }); - const ctx = createAPIContext(db); + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "nopassword@example.com", + ); - await expect( - call( + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + }); + }); + + // ============================================================================= + // auth.loginPasswordConfirm tests + // ============================================================================= + + describe("auth.loginPasswordConfirm", () => { + test("marks login request as completed with valid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "confirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "confirm@example.com", + ); + + const ctx = createAPIContext(db); + const result = await call( router.auth.loginPasswordConfirm, { token: loginToken }, { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired confirmation link"); - }); - }); -}); + ); -// ============================================================================= -// auth.loginIfRequestIsCompleted tests -// ============================================================================= + expect(result.success).toBe(true); -describe("auth.loginIfRequestIsCompleted", () => { - test("returns pending for incomplete login request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "pending@example.com", + // 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 { 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"); }); - }); - test("returns expired for expired login request", async () => { - 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"); - }); - }); - - test("creates session and returns completed for completed request", async () => { - 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(), + test("is idempotent for already completed requests", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "idempotent@example.com", }); - const ctx = createAPIContext(db, { - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, + 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); }); - 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 + test("rejects invalid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - // 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); + await expect( + call( + router.auth.loginPasswordConfirm, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); + }); - // Verify session cookie was set - const sessionToken = getCookieFromResponse( - ctx.resHeaders, - COOKIE_NAMES.SESSION_TOKEN, - ); - expect(sessionToken).not.toBeNull(); + test("rejects expired token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredconfirm@example.com", + }); - // Verify login request was deleted - const loginRequest = await db - .selectFrom("login_requests") - .selectAll() - .where("id", "=", String(loginRequestId)) - .executeTakeFirst(); - expect(loginRequest).toBeUndefined(); + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "expiredconfirm@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); - // 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 ctx = createAPIContext(db); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); }); }); - test("redirects to dashboard if device is already trusted", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "alreadytrusted@example.com", + // ============================================================================= + // auth.loginIfRequestIsCompleted tests + // ============================================================================= + + describe("auth.loginIfRequestIsCompleted", () => { + test("returns pending for incomplete login request", async () => { + 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 fingerprint = "already-trusted-fp"; - await createTrustedDevice(db, user.id, fingerprint); + test("returns expired for expired login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredpoll@example.com", + }); - const { token: loginToken } = await createLoginRequest( - db, - user.id, - "alreadytrusted@example.com", - { deviceFingerprint: fingerprint, completedAt: new Date() }, - ); + const { token: loginToken } = await createLoginRequest( + db, + user.id, + "expiredpoll@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); - const ctx = createAPIContext(db, { - loginRequestToken: loginToken, - deviceFingerprint: fingerprint, + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("expired"); }); - 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 () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" }); - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); + test("creates session and returns completed for completed request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "completed@example.com", + }); - expect(result.status).toBe("pending"); - }); - }); + const fingerprint = "completed-fp"; + const { token: loginToken, id: loginRequestId } = + await createLoginRequest(db, user.id, "completed@example.com", { + deviceFingerprint: fingerprint, + completedAt: new Date(), + }); - test("returns pending when no cookie present", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); // No login request token - const result = await call( - router.auth.loginIfRequestIsCompleted, - undefined, - { context: ctx }, - ); + const ctx = createAPIContext(db, { + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); - expect(result.status).toBe("pending"); - }); - }); + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet - test("returns pending when device fingerprint is missing", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "nofp@example.com", + // 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); }); + }); - // Create login request without device fingerprint - const token = `login_test-${String(Date.now())}`; - await db - .insertInto("login_requests") - .values({ - user_id: user.id, + test("redirects to dashboard if device is already trusted", async () => { + 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"); + }); + }); + + test("returns pending for fake/non-existent token", async () => { + 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"); + }); + }); + + test("returns pending when no cookie present", async () => { + 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"); + }); + }); + + test("returns pending when device fingerprint is missing", async () => { + 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(db, { 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 }, + ); -// ============================================================================= -// auth.verifyEmail tests -// ============================================================================= - -describe("auth.verifyEmail", () => { - test("verifies email with valid token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "verify@example.com", + expect(result.status).toBe("pending"); }); - - 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); }); }); - test("rejects invalid token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); + // ============================================================================= + // auth.verifyEmail tests + // ============================================================================= - await expect( - call( + describe("auth.verifyEmail", () => { + test("verifies email with valid token", async () => { + 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: "invalid-token" }, + { token }, { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired token"); + ); + + 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); + }); + }); + + test("rejects invalid token", async () => { + 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"); + }); + }); + + test("rejects expired token and cleans up", async () => { + 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); + }); }); }); - test("rejects expired token and cleans up", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "expiredverify@example.com", + // ============================================================================= + // auth.resendVerificationEmail tests + // ============================================================================= + + describe("auth.resendVerificationEmail", () => { + test("creates new verification token for unverified user", async () => { + 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 = 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); }); - }); -}); -// ============================================================================= -// auth.resendVerificationEmail tests -// ============================================================================= + test("deletes old verification tokens before creating new one", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resendold@example.com", + }); -describe("auth.resendVerificationEmail", () => { - test("creates new verification token for unverified user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "resend@example.com", - }); + // Create existing verification + await createEmailVerification(db, user.id); - const { token: sessionToken } = await createSession(db, user.id); + const { token: sessionToken } = await createSession(db, user.id); - const ctx = createAPIContext(db, { sessionToken }); - const result = await call( - router.auth.resendVerificationEmail, - undefined, - { + const ctx = createAPIContext(db, { sessionToken }); + 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); - }); - }); - - test("deletes old verification tokens before creating new one", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "resendold@example.com", + // 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(db, user.id); + test("returns success for already verified user (no-op)", async () => { + 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 { token: sessionToken } = await createSession(db, user.id); - const ctx = createAPIContext(db, { sessionToken }); - await call(router.auth.resendVerificationEmail, undefined, { - context: ctx, + 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); }); - - // 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); }); - }); - test("returns success for already verified user (no-op)", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "alreadyverified@example.com", - emailVerifiedAt: new Date(), + test("requires authentication", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No session + + await expect( + call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }), + ).rejects.toThrow(); }); - - 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); }); }); - test("requires authentication", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); // No session + // ============================================================================= + // auth.forgotPassword tests + // ============================================================================= - await expect( - call(router.auth.resendVerificationEmail, undefined, { context: ctx }), - ).rejects.toThrow(); - }); - }); -}); + describe("auth.forgotPassword", () => { + test("creates password reset token for existing user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "forgot@example.com", + }); -// ============================================================================= -// auth.forgotPassword tests -// ============================================================================= - -describe("auth.forgotPassword", () => { - test("creates password reset token for existing user", async () => { - 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); - }); - }); - - test("returns success for non-existent user (anti-enumeration)", async () => { - 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); - - // 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 () => { - 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); - }); - }); - - test("normalizes email to lowercase", async () => { - 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); - }); - }); -}); - -// ============================================================================= -// auth.resetPassword tests -// ============================================================================= - -describe("auth.resetPassword", () => { - test("resets password with valid token", async () => { - 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(); - }); - }); - - test("revokes all sessions after password reset", async () => { - 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(); - } - }); - }); - - test("rejects invalid token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); - - await expect( - call( - router.auth.resetPassword, - { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, + const ctx = createAPIContext(db); + const result = await call( + router.auth.forgotPassword, + { email: "forgot@example.com" }, { context: ctx }, - ), - ).rejects.toThrow("Invalid or expired reset token"); + ); + + 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); + }); + }); + + test("returns success for non-existent user (anti-enumeration)", async () => { + 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); + + // 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 () => { + 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); + }); + }); + + test("normalizes email to lowercase", async () => { + 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); + }); }); }); - test("rejects expired token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "resetexpired@example.com", - }); + // ============================================================================= + // auth.resetPassword tests + // ============================================================================= - const token = await createPasswordReset(db, user.id, { - expiresAt: new Date(Date.now() - 1000), // Expired - }); + describe("auth.resetPassword", () => { + test("resets password with valid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); - const ctx = createAPIContext(db); + const token = await createPasswordReset(db, user.id); - await expect( - call( + const ctx = createAPIContext(db); + const result = await call( router.auth.resetPassword, { token, newPassword: "NewStrongP@ssw0rd!" }, { context: ctx }, - ), - ).rejects.toThrow("Reset token has expired"); + ); + + 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(); + }); }); - }); - test("rejects already used token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "resetused@example.com", - }); + test("revokes all sessions after password reset", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetrevoke@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); - 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"); - }); - }); - - test("rejects weak password", async () => { - 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(); - }); - }); -}); - -// ============================================================================= -// auth.logout tests -// ============================================================================= - -describe("auth.logout", () => { - test("revokes current session", async () => { - 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"); - }); - }); - - test("requires authentication", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const ctx = createAPIContext(db); // No session - - await expect( - call(router.auth.logout, undefined, { context: ctx }), - ).rejects.toThrow(); - }); - }); -}); - -// ============================================================================= -// End-to-end login scenarios from docs/initial-app.md -// ============================================================================= - -describe("End-to-end login scenarios", () => { - test("Scenario: Password login with trusted device (immediate completion)", async () => { - 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(); - }); - }); - - test("Scenario: Password login with untrusted device (requires email confirmation)", async () => { - 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 - }); - }); - - test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => { - 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); - - 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(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 - }); - }); - - test("Scenario: Complete password reset flow", async () => { - 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"); - }); - }); - - test("Scenario: Passkey login flow (full e2e)", async () => { - 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 } = + // Create some sessions + await createSession(db, user.id); await createSession(db, user.id); - // Create registration options - const regOptionsCtx = createAPIContext(db, { - sessionToken: regSessionToken, - deviceFingerprint: fingerprint, + 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(); + } }); - 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); + test("rejects invalid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); - // Verify registration - const verifyRegCtx = createAPIContext(db, { - sessionToken: regSessionToken, - deviceFingerprint: fingerprint, + await expect( + call( + router.auth.resetPassword, + { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired reset token"); }); - 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(); + test("rejects expired token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetexpired@example.com", + }); - // 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 }, - ); + const token = await createPasswordReset(db, user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); - expect(loginRequestResult.hasPasskey).toBe(true); + const ctx = createAPIContext(db); - 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, + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has expired"); }); - const { options: authOptions, challengeId: authChallengeId } = await call( - router.auth.webauthn.createAuthenticationOptions, - undefined, - { context: ctx2 }, - ); + }); - expect(authOptions.allowCredentials).toHaveLength(1); + test("rejects already used token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "resetused@example.com", + }); - // Step 3: Authenticate with passkey - const authResponse = authenticator.getAssertion(authOptions); + const token = await createPasswordReset(db, user.id, { + usedAt: new Date(), // Already used + }); - const ctx3 = createAPIContext(db, { - loginRequestToken: assertDefined(loginToken), - deviceFingerprint: fingerprint, + const ctx = createAPIContext(db); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has already been used"); }); - 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, + test("rejects weak password", async () => { + 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 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(); }); }); - test("Scenario: User with no auth methods (no password, no passkey)", async () => { - 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 + // ============================================================================= + // auth.logout tests + // ============================================================================= + + describe("auth.logout", () => { + test("revokes current session", async () => { + 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 fingerprint = "e2e-no-auth-device"; + test("requires authentication", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const ctx = createAPIContext(db); // No session - // 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, + await expect( + call(router.auth.logout, undefined, { context: ctx }), + ).rejects.toThrow(); }); - 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(); }); }); -}); + + // ============================================================================= + // End-to-end login scenarios from docs/initial-app.md + // ============================================================================= + + describe("End-to-end login scenarios", () => { + test("Scenario: Password login with trusted device (immediate completion)", async () => { + 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(); + }); + }); + + test("Scenario: Password login with untrusted device (requires email confirmation)", async () => { + 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 + }); + }); + + test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => { + 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); + + 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(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 + }); + }); + + test("Scenario: Complete password reset flow", async () => { + 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"); + }); + }); + + test("Scenario: Passkey login flow (full e2e)", async () => { + 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(); + }); + }); + + test("Scenario: User with no auth methods (no password, no passkey)", async () => { + 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(); + }); + }); + }); +}); // Close outer describe.skipIf diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index 9ead2e3..c64bebf 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -23,13 +23,18 @@ import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; import { beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; +import { + createTestUser, + describeE2E, + getSharedDb, + initTestDb, + TEST_RP, + withTestTransaction, +} from "@reviq/test-helpers"; 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 { 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; @@ -165,1209 +170,1226 @@ async function createApiToken( return { token, name: "Test API Token" }; } -beforeAll(async () => { - await initTestDb(); -}); +describeE2E("me", () => { + beforeAll(async () => { + await initTestDb(); + }); -describe("me.get", () => { - test("returns user profile with all fields", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "test@example.com", - displayName: "Test User", - fullName: "Test Full Name", - emailVerifiedAt: new Date(), + describe("me.get", () => { + test("returns user profile with all fields", async () => { + 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 db - .updateTable("users") - .set({ phone_number: "+1234567890" }) - .where("id", "=", user.id) - .execute(); + test("returns needsSetup=true when displayName is null", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "newuser@example.com", + displayName: undefined, + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + // Set display_name to null explicitly + await db + .updateTable("users") + .set({ display_name: null }) + .where("id", "=", user.id) + .execute(); - const result = await call(router.me.get, undefined, { context }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - 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); + 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 () => { + 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); + }); + }); + + test("returns isSuperuser=true for superuser", async () => { + 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); + }); }); }); - test("returns needsSetup=true when displayName is null", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "newuser@example.com", - displayName: undefined, + describe("me.authStatus", () => { + test("returns session auth info", async () => { + 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); + } }); + }); - // Set display_name to null explicitly - await db - .updateTable("users") - .set({ display_name: null }) - .where("id", "=", user.id) - .execute(); + test("returns api_token auth info", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "apitoken@example.com", + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + const { token } = await createApiToken(db, user.id); + const context = createAPIContext(db, { apiKey: token }); - const result = await call(router.me.get, undefined, { context }); + const result = await call(router.me.authStatus, undefined, { context }); - expect(result.needsSetup).toBe(true); - expect(result.displayName).toBeNull(); + 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); + } + }); }); }); - test("returns hasPassword=true when user has password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const passwordHash = await hashPassword("securePassword123!"); - const user = await createTestUser(db, { - email: "withpassword@example.com", - passwordHash, - }); + describe("me.setupProfile", () => { + test("sets up profile with required fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "setup@example.com", + displayName: undefined, + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + // Clear display_name + await db + .updateTable("users") + .set({ display_name: null }) + .where("id", "=", user.id) + .execute(); - const result = await call(router.me.get, undefined, { context }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - expect(result.hasPassword).toBe(true); - }); - }); - - test("returns isSuperuser=true for superuser", async () => { - 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); - }); - }); -}); - -describe("me.authStatus", () => { - test("returns session auth info", async () => { - 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); - } - }); - }); - - test("returns api_token auth info", async () => { - 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); - } - }); - }); -}); - -describe("me.setupProfile", () => { - test("sets up profile with required fields", async () => { - 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"); - }); - }); - - test("sets up profile with only required displayName", async () => { - 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(); - }); - }); -}); - -describe("me.updateProfile", () => { - test("updates displayName only", async () => { - 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"); - }); - }); - - test("updates multiple fields at once", async () => { - 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"); - }); - }); - - test("empty strings in optional fields are treated as no-op", async () => { - 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"); - }); - }); - - test("does nothing when no fields provided", async () => { - 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"); - }); - }); -}); - -describe("me.setPassword", () => { - test("sets password for user without password", async () => { - 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(); - }); - }); - - test("changes password with correct current password", async () => { - 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); - }); - }); - - test("fails without current password when user has password", async () => { - 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, + await call( + router.me.setupProfile, { - newPassword: "NewPassword123!@#", + displayName: "New Display Name", + fullName: "John Doe", + phoneNumber: "+12025551234", }, { context }, - ), - ).rejects.toThrow("Current password required"); - }); - }); + ); - test("fails with incorrect current password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const oldHash = await hashPassword("CorrectPassword123!"); - const user = await createTestUser(db, { - email: "wrongpass@example.com", - passwordHash: oldHash, + // 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"); }); + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("sets up profile with only required displayName", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "minimal@example.com", + }); - await expect( - call( - router.me.setPassword, + 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, { - currentPassword: "WrongPassword123!", - newPassword: "NewPassword456!@#", + displayName: "Minimal User", }, { context }, - ), - ).rejects.toThrow("Current password is incorrect"); + ); + + 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(); + }); }); }); - test("fails with weak password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "weak@example.com", - }); + describe("me.updateProfile", () => { + test("updates displayName only", async () => { + 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 }); + 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, + await call( + router.me.updateProfile, { - newPassword: "password", // 8 chars but extremely common + displayName: "Updated Name", }, { context }, - ), - ).rejects.toThrow(/common|top|weak|guess/i); - }); - }); -}); + ); -describe("me.delete", () => { - test("deletes account with correct password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const password = "DeleteMe123!@#"; - const passwordHash = await hashPassword(password); - const user = await createTestUser(db, { - email: "delete@example.com", - passwordHash, + 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(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("updates multiple fields at once", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "multi@example.com", + displayName: "Original", + }); - await call(router.me.delete, { password }, { context }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - // Verify user is deleted - const deleted = await db - .selectFrom("users") - .where("id", "=", user.id) - .selectAll() - .executeTakeFirst(); + await call( + router.me.updateProfile, + { + displayName: "New Display", + fullName: "Full Name Here", + phoneNumber: "+12025551234", + }, + { context }, + ); - expect(deleted).toBeUndefined(); + 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"); + }); + }); + + test("empty strings in optional fields are treated as no-op", async () => { + 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"); + }); + }); + + test("does nothing when no fields provided", async () => { + 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"); + }); }); }); - test("fails without password set", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "nopassdelete@example.com", + describe("me.setPassword", () => { + test("sets password for user without password", async () => { + 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(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("changes password with correct current password", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const oldPassword = "OldPassword123!@#"; + const oldHash = await hashPassword(oldPassword); + const user = await createTestUser(db, { + email: "changepass@example.com", + passwordHash: oldHash, + }); - await expect( - call(router.me.delete, { password: "anything" }, { context }), - ).rejects.toThrow("Cannot delete account without a password"); + 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); + }); + }); + + test("fails without current password when user has password", async () => { + 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"); + }); + }); + + test("fails with incorrect current password", async () => { + 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"); + }); + }); + + test("fails with weak password", async () => { + 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); + }); }); }); - test("fails with incorrect password", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const passwordHash = await hashPassword("CorrectPassword123!"); - const user = await createTestUser(db, { - email: "wrongdelete@example.com", - passwordHash, + describe("me.delete", () => { + test("deletes account with correct password", async () => { + 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(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("fails without password set", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nopassdelete@example.com", + }); - await expect( - call(router.me.delete, { password: "WrongPassword123!" }, { context }), - ).rejects.toThrow("Incorrect password"); + 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"); + }); + }); + + test("fails with incorrect password", async () => { + 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"); + }); + }); + + test("cascades deletion to related records", async () => { + 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); + }); }); }); - test("cascades deletion to related records", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const password = "CascadeDelete123!@#"; - const passwordHash = await hashPassword(password); - const user = await createTestUser(db, { - email: "cascade@example.com", - passwordHash, + // ===== Session Management Tests ===== + + describe("me.sessions.list", () => { + test("returns all sessions for user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "sessions@example.com", + }); + + // 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(db, { sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + 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"]); }); - - // 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); }); - }); -}); -// ===== Session Management Tests ===== + test("marks current session with isCurrent flag", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "current@example.com", + }); -describe("me.sessions.list", () => { - test("returns all sessions for user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "sessions@example.com", - }); + const { token: sessionToken1, sessionId: id1 } = await createSession( + db, + user.id, + ); + const { sessionId: id2 } = await createSession(db, user.id); - // 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(db, { 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(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); }); + }); - 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("returns session metadata correctly", async () => { + 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(); + }); }); }); - test("marks current session with isCurrent flag", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "current@example.com", - }); + describe("me.sessions.revoke", () => { + test("revokes another session successfully", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revoke@example.com", + }); - const { token: sessionToken1, sessionId: id1 } = await createSession( - db, - user.id, - ); - const { sessionId: id2 } = await createSession(db, user.id); + const { token: sessionToken1 } = await createSession(db, user.id); + const { sessionId: sessionId2 } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken: sessionToken1 }); - const sessions = await call(router.me.sessions.list, undefined, { - context, - }); + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await call( + router.me.sessions.revoke, + { sessionId: sessionId2 }, + { 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); - }); - }); + // Verify session is revoked + const session = await db + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId2)) + .executeTakeFirstOrThrow(); - test("returns session metadata correctly", async () => { - 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(); - }); - }); -}); - -describe("me.sessions.revoke", () => { - test("revokes another session successfully", async () => { - 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(); - }); - }); - - test("fails to revoke current session", async () => { - 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"); - }); - }); - - test("fails to revoke non-existent session", async () => { - 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"); - }); - }); - - test("fails to revoke already revoked session", async () => { - 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"); - }); - }); - - test("fails to revoke another user's session", async () => { - 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(db, user1.id); - const { sessionId: sessionId2 } = await createSession(db, user2.id); - - 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 () => { - 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(); - } + }); + }); + + test("fails to revoke current session", async () => { + 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"); + }); + }); + + test("fails to revoke non-existent session", async () => { + 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"); + }); + }); + + test("fails to revoke already revoked session", async () => { + 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"); + }); + }); + + test("fails to revoke another user's session", async () => { + 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(db, user1.id); + const { sessionId: sessionId2 } = await createSession(db, user2.id); + + const context = createAPIContext(db, { sessionToken: sessionToken1 }); + await expect( + call( + router.me.sessions.revoke, + { sessionId: sessionId2 }, + { context }, + ), + ).rejects.toThrow("Session not found"); + }); }); }); - test("does nothing when only current session exists", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "onlyone@example.com", + describe("me.sessions.revokeAll", () => { + test("revokes all sessions except current", async () => { + 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: 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(); }); - }); -}); -// ===== Device Management Tests ===== + test("does nothing when only current session exists", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "onlyone@example.com", + }); -describe("me.devices.getInfo", () => { - test("returns device info for current device", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "deviceinfo@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 { 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); }); }); - test("returns default name from user agent when name is null", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "defaultname@example.com", - }); + // ===== Device Management Tests ===== - const { fingerprint } = await createDevice(db, user.id, { - userAgent: "Mozilla/5.0 (Macintosh)", - }); + describe("me.devices.getInfo", () => { + test("returns device info for current device", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "deviceinfo@example.com", + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { - sessionToken, - deviceFingerprint: fingerprint, - }); + const { fingerprint, deviceId } = await createDevice(db, user.id, { + name: "My MacBook", + isTrusted: true, + userAgent: "Safari/17.0", + }); - const info = await call(router.me.devices.getInfo, undefined, { - context, - }); + // 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(); - expect(info.name).toBe("Mozilla device"); + 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); + }); + }); + + test("returns default name from user agent when name is null", async () => { + 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"); + }); + }); + + test("fails without device fingerprint", async () => { + 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"); + }); + }); + + test("fails when device does not exist", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "nodevice@example.com", + }); + + 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"); + }); }); }); - test("fails without device fingerprint", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "nofingerprint@example.com", + describe("me.devices.trust", () => { + test("trusts current device with name", async () => { + 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 { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("fails without device fingerprint", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustnofp@example.com", + }); - await expect( - call(router.me.devices.getInfo, undefined, { context }), - ).rejects.toThrow("No device fingerprint found"); + 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"); + }); + }); + + test("fails when device does not exist", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "trustnodevice@example.com", + }); + + 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"); + }); }); }); - test("fails when device does not exist", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "nodevice@example.com", - }); + describe("me.devices.listTrusted", () => { + test("returns only trusted devices", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "listtrusted@example.com", + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { - sessionToken, - deviceFingerprint: "nonexistent-fingerprint", - }); + // 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", + }); - await expect( - call(router.me.devices.getInfo, undefined, { context }), - ).rejects.toThrow("Device not found"); + 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); + }); }); - }); -}); -describe("me.devices.trust", () => { - test("trusts current device with name", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "trustdevice@example.com", + test("returns empty list when no trusted devices", async () => { + 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); }); + }); - const { fingerprint, deviceId } = await createDevice(db, user.id, { - isTrusted: false, + test("returns default name when device name is null", async () => { + 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"); }); - - 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"); }); }); - test("fails without device fingerprint", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "trustnofp@example.com", + describe("me.devices.untrust", () => { + test("untrusts device by ID", async () => { + 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 { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("fails to untrust non-existent device", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "untrustnotfound@example.com", + }); - await expect( - call(router.me.devices.trust, { name: "Test" }, { context }), - ).rejects.toThrow("No device fingerprint found"); + 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"); + }); + }); + + test("fails to untrust another user's device", async () => { + 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"); + }); }); }); - test("fails when device does not exist", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "trustnodevice@example.com", - }); + describe("me.devices.revokeAll", () => { + test("untrusts all devices", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "revokealldevices@example.com", + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { - sessionToken, - deviceFingerprint: "nonexistent", - }); + await createDevice(db, user.id, { isTrusted: true }); + await createDevice(db, user.id, { isTrusted: true }); + await createDevice(db, user.id, { isTrusted: false }); - await expect( - call(router.me.devices.trust, { name: "Test" }, { context }), - ).rejects.toThrow("Device not found"); + 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); + }); + }); + + test("works when no devices exist", async () => { + 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 }); + }); }); }); -}); - -describe("me.devices.listTrusted", () => { - test("returns only trusted devices", async () => { - 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); - }); - }); - - test("returns empty list when no trusted devices", async () => { - 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); - }); - }); - - test("returns default name when device name is null", async () => { - 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"); - }); - }); -}); - -describe("me.devices.untrust", () => { - test("untrusts device by ID", async () => { - 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); - }); - }); - - test("fails to untrust non-existent device", async () => { - 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"); - }); - }); - - test("fails to untrust another user's device", async () => { - 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"); - }); - }); -}); - -describe("me.devices.revokeAll", () => { - test("untrusts all devices", async () => { - 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); - }); - }); - - test("works when no devices exist", async () => { - 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 }); - }); - }); -}); +}); // Close outer describe.skipIf diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index 62204ba..faf9e87 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -12,19 +12,21 @@ import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; +import { + createTestUser, + describeE2E, + destroySharedDb, + getSharedDb, + initTestDb, + KNOWN_AAGUIDS, + TEST_RP, + withTestTransaction, +} from "@reviq/test-helpers"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; import { COOKIE_NAMES } from "../../utils/cookies.js"; import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; -import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; -import { - createTestUser, - 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; @@ -198,716 +200,816 @@ async function authenticate( ); } -beforeAll(async () => { - await initTestDb(); -}); - -afterAll(async () => { - await destroySharedDb(); -}); - -describe("registration flow", () => { - test("creates registration options with challenge stored in DB via router", async () => { - 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(); - }); +describeE2E("webauthn", () => { + beforeAll(async () => { + await initTestDb(); }); - test("verifies valid registration and stores passkey via router", async () => { - 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); - }); + afterAll(async () => { + await destroySharedDb(); }); - test("excludes existing passkeys for returning users via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "exclude-test@test.com", + describe("registration flow", () => { + test("creates registration options with challenge stored in DB via router", async () => { + 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 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); }); - }); - test("assigns friendly name from known AAGUID via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "aaguid-test@test.com", - }); + test("verifies valid registration and stores passkey via router", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "reg-verify@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Use iCloud Keychain AAGUID - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, - }); + // Create registration options via router + const apiCtx = createAPIContext(db); + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); - const apiCtx = createAPIContext(db); - const authCtx = await createUserAPIContext(db, user.id); + // Create credential with virtual authenticator + const response = authenticator.createCredential(options); - 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"); - }); - }); - - test("cleans up challenge after verification via router", async () => { - 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(); - }); - }); - - test("rejects expired/missing challenges via router", async () => { - 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 { + // Verify registration via router (requires authenticated session) + const authCtx = await createUserAPIContext(db, user.id); await call( router.auth.webauthn.verifyRegistration, - { challengeId: 999999, response }, + { challengeId, 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"); - } + + // 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); + }); }); - }); -}); -describe("authentication flow", () => { - test("creates authentication options with user's passkeys via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "auth-options@test.com", - }); - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - }); + test("excludes existing passkeys for returning users via router", async () => { + 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 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); - }); - }); - - test("verifies valid authentication and updates counter via router", async () => { - 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); - }); - }); - - test("updates last_used_at timestamp via router", async () => { - 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(db, user.id, user.email, authenticator); - - // 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( - 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(db, user.id); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.lastUsedAt).not.toBeNull(); - }); - }); - - test("cleans up challenge after authentication via router", async () => { - 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(); - }); - }); - - test("rejects unknown credential IDs", async () => { - 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(db, user.id, user.email, authenticator); - - // 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", + // 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 () => { + 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"); + }); + }); + + test("cleans up challenge after verification via router", async () => { + 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(); + }); + }); + + test("rejects expired/missing challenges via router", async () => { + 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 }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Registration timed out"); + } + }); }); }); -}); -describe("security tests", () => { - test("rejects replayed credentials (counter check) via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "counter-replay@test.com", - }); - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - }); + describe("authentication flow", () => { + test("creates authentication options with user's passkeys via router", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "auth-options@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - // Register passkey via router - const regResponse = await registerPasskey( - db, - user.id, - user.email, - authenticator, - ); + // Register a passkey first via router + const regResponse = await registerPasskey( + db, + 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(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); - - // 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 }, - ); - - // 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 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 }, ); - 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); + 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("rejects tampered authentication response", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "tampered-response@test.com", - }); - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, - }); + test("verifies valid authentication and updates counter via router", async () => { + 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); + // Register passkey via router + await registerPasskey(db, user.id, user.email, authenticator); - // 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 }, - ); + // 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); - // 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 }, + { challengeId: authChallengeId, response: authResponse }, { 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", + + // Verify counter was updated + const passkeys = await getUserPasskeys(db, user.id); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); + }); + }); + + test("updates last_used_at timestamp via router", async () => { + 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(db, user.id, user.email, authenticator); + + // 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( + 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 }, ); - } - }); - }); -}); -describe("full passkey lifecycle", () => { - test("register → authenticate → add second passkey → authenticate with either via router", async () => { - 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(db, 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(db, user.id, user.email, authenticator2); - - // 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(db, user.id, user.email, authenticator2); - - // 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 () => { - 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 + // Check last_used_at is now set passkeys = await getUserPasskeys(db, user.id); firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - expect(firstPasskey.counter).toBe(i); - } + expect(firstPasskey.lastUsedAt).not.toBeNull(); + }); }); - }); -}); -describe("passkey management", () => { - test("lists passkeys with correct data via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "list-passkeys@test.com", + test("cleans up challenge after authentication via router", async () => { + 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 authenticator1 = new VirtualAuthenticator({ - origin: TEST_RP.origin, - aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, + }); + + test("rejects unknown credential IDs", async () => { + 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(db, user.id, user.email, authenticator); + + // 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", + ); + } }); - 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"); - } }); }); - test("passkey stores correct device type and backup status", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "device-type@test.com", + describe("security tests", () => { + test("rejects replayed credentials (counter check) via router", async () => { + 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( + db, + 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(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); + + // 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 }, + ); + + // 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); }); - const authenticator = new VirtualAuthenticator({ - origin: TEST_RP.origin, + }); + + test("rejects tampered authentication response", async () => { + 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(db, user.id, user.email, authenticator); + + // 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 }, + ); + + // 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", + ); + } }); - - 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); }); }); - test("renames passkey successfully via router", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + describe("full passkey lifecycle", () => { + test("register → authenticate → add second passkey → authenticate with either via router", async () => { + 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(db, 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(db, user.id, user.email, authenticator2); + + // 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(db, user.id, user.email, authenticator2); + + // 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 () => { + 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); + } + }); + }); + }); + + describe("passkey management", () => { + test("lists passkeys with correct data via router", async () => { + 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"); + } + }); + }); + + test("passkey stores correct device type and backup status", async () => { + 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); + }); + }); + + test("renames passkey successfully via router", async () => { + 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); + }); + }); + + test("rename does not affect other user's passkeys", async () => { + 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(db, user1.id, user1.email, auth1); + await registerPasskey(db, user2.id, user2.email, auth2); + + 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"); + } + + // 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, + }, + ); + 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 db = getSharedDb(); const user = await createTestUser(db, { - email: "rename-test@test.com", + email: "delete-with-password@test.com", + passwordHash: "fake-password-hash", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, @@ -919,35 +1021,108 @@ describe("passkey management", () => { let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(passkeys).toHaveLength(1); + const 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 }, - ); + // Delete the passkey via router (should work because user has password) + await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); - // Verify name changed + // Verify passkey is deleted 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); + expect(passkeys).toHaveLength(0); }); - }); - test("rename does not affect other user's passkeys", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + // 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 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(db, user.id, user.email, auth1); + await registerPasskey(db, user.id, user.email, auth2); + + const ctx = await createUserAPIContext(db, user.id); + let passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(2); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const firstPasskeyId = firstPasskey.id; + + // Delete first passkey via router (should work because user has another) + await call( + router.me.passkeys.delete, + { passkeyId: firstPasskeyId }, + { context: ctx }, + ); + + // Verify only one passkey remains + passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(1); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + 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 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(db, user.id, user.email, authenticator); + + const ctx = await createUserAPIContext(db, user.id); + const passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const passkeyId = firstPasskey.id; + + // Try to delete the only passkey via router (should fail) + try { + await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); + throw new Error("Expected deletion to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Cannot delete the last passkey when you have no password set", + ); + } + + // Verify passkey still exists + const passkeysAfter = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + 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 db = getSharedDb(); const user1 = await createTestUser(db, { - email: "rename-user1@test.com", + email: "delete-user1@test.com", + passwordHash: "fake-hash", }); const user2 = await createTestUser(db, { - email: "rename-user2@test.com", + email: "delete-user2@test.com", + passwordHash: "fake-hash", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); @@ -966,20 +1141,20 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) + // Try to delete user2's passkey using user1's context (should throw NOT_FOUND) try { await call( - router.me.passkeys.rename, - { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + router.me.passkeys.delete, + { passkeyId: user2FirstPasskey.id }, { context: ctx1 }, ); - throw new Error("Expected rename to fail with NOT_FOUND"); + throw new Error("Expected delete 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 + // User2's passkey should still exist const user2PasskeysAfter = await call( router.me.passkeys.list, undefined, @@ -987,210 +1162,56 @@ describe("passkey management", () => { context: ctx2, }, ); - const user2FirstPasskeyAfter = user2PasskeysAfter[0]; - if (!user2FirstPasskeyAfter) { - throw new Error("Expected user2 passkey to exist after"); - } - expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name); + expect(user2PasskeysAfter).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("deletes passkey when user has password via router", async () => { - 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 }); + test("passkey credentialId is unique and stored correctly", async () => { + 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, authenticator); + await registerPasskey(db, user.id, user.email, auth1); + await registerPasskey(db, user.id, user.email, auth2); - const ctx = await createUserAPIContext(db, user.id); - let passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - const passkeyId = firstPasskey.id; + 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"); + } - // Delete the passkey via router (should work because user has password) - await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); + // Credential IDs should be unique + expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId); - // Verify passkey is deleted - passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); - 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 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(db, user.id, user.email, auth1); - await registerPasskey(db, user.id, user.email, auth2); - - const ctx = await createUserAPIContext(db, user.id); - let passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - expect(passkeys).toHaveLength(2); - let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - const firstPasskeyId = firstPasskey.id; - - // Delete first passkey via router (should work because user has another) - await call( - router.me.passkeys.delete, - { passkeyId: firstPasskeyId }, - { context: ctx }, - ); - - // Verify only one passkey remains - passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); - expect(passkeys).toHaveLength(1); - firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - 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 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(db, user.id, user.email, authenticator); - - const ctx = await createUserAPIContext(db, user.id); - const passkeys = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - expect(passkeys).toHaveLength(1); - const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - const passkeyId = firstPasskey.id; - - // Try to delete the only passkey via router (should fail) - try { - await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); - throw new Error("Expected deletion to fail"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "Cannot delete the last passkey when you have no password set", - ); - } - - // Verify passkey still exists - const passkeysAfter = await call(router.me.passkeys.list, undefined, { - context: ctx, - }); - 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 db = getSharedDb(); - const user1 = await createTestUser(db, { - email: "delete-user1@test.com", - passwordHash: "fake-hash", - }); - 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(db, user1.id, user1.email, auth1); - await registerPasskey(db, user2.id, user2.email, auth2); - - 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"); - } - - // Try to delete user2's passkey using user1's context (should throw NOT_FOUND) - try { - await call( - router.me.passkeys.delete, - { passkeyId: user2FirstPasskey.id }, - { context: ctx1 }, - ); - throw new Error("Expected delete to fail with NOT_FOUND"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain("Passkey not found"); - } - - // User2's passkey should still exist - const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { - context: ctx2, - }); - expect(user2PasskeysAfter).toHaveLength(1); - }); - - test("passkey credentialId is unique and stored correctly", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "credential-id@test.com", + // 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(db, user.id, user.email, auth1); - await registerPasskey(db, user.id, user.email, auth2); + test("passkey transports are stored and retrieved correctly", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "transports@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); - 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"); - } + await registerPasskey(db, user.id, user.email, authenticator); - // Credential IDs should be unique - expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId); + const passkeys = await getUserPasskeys(db, user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); - // Credential IDs should be base64url encoded - expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); - expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); + // Virtual authenticator sets transports to ["internal", "hybrid"] + expect(firstPasskey.transports).toContain("internal"); + expect(firstPasskey.transports).toContain("hybrid"); + }); }); }); - - test("passkey transports are stored and retrieved correctly", async () => { - 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"); - }); - }); -}); +}); // Close outer describe.skipIf diff --git a/apps/cli/package.json b/apps/cli/package.json index e331b7d..f4298b0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", - "test": "bun test" + "test": "bun test src/" }, "dependencies": { "@noble/hashes": "^2.0.1", diff --git a/bun.lock b/bun.lock index c30c8f3..9c7262d 100644 --- a/bun.lock +++ b/bun.lock @@ -35,12 +35,11 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", + "@reviq/test-helpers": "workspace:*", "@reviq/virtual-authenticator": "workspace:*", "@types/bun": "catalog:", - "@types/pg": "^8.16.0", "@types/zxcvbn": "^4.4.5", "eslint": "catalog:", - "pg": "^8.16.3", "pino-pretty": "^13.1.3", "typescript": "catalog:", }, @@ -180,6 +179,24 @@ "typescript": "catalog:", }, }, + "packages/testing/test-helpers": { + "name": "@reviq/test-helpers", + "version": "0.0.1", + "dependencies": { + "@reviq/db": "workspace:*", + "@reviq/db-schema": "workspace:*", + "kysely": "^0.28.2", + "pg": "^8.16.3", + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "@types/pg": "^8.16.0", + "eslint": "catalog:", + "typescript": "catalog:", + }, + }, "packages/testing/virtual-authenticator": { "name": "@reviq/virtual-authenticator", "version": "0.0.1", @@ -189,7 +206,7 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", - "@types/bun": "latest", + "@types/bun": "catalog:", "@types/node": "^25.0.3", "eslint": "catalog:", "typescript": "catalog:", @@ -425,6 +442,8 @@ "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], + "@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"], + "@reviq/utils": ["@reviq/utils@workspace:packages/utils"], "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..96a10b0 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +coveragePathIgnorePatterns = [ + "**/dist/**", + "**/node_modules/**", +] diff --git a/db/schema.sql b/db/schema.sql index 6be1a60..0949baf 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap +\restrict 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg -- 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 F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap +\unrestrict 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg -- diff --git a/package.json b/package.json index 29a733d..b69a4c4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "typecheck": "turbo typecheck", "clean": "turbo clean", "test": "turbo test", + "test:unit": "SKIP_DB_TESTS=1 turbo test", + "test:all": "turbo test", + "test:cov": "bun test --coverage", + "test:unit:cov": "SKIP_DB_TESTS=1 bun test --coverage", "db:codegen": "bun run --cwd packages/db-schema generate" }, "devDependencies": { diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json index 965517e..31d05d2 100644 --- a/packages/api-contract/package.json +++ b/packages/api-contract/package.json @@ -12,7 +12,7 @@ }, "scripts": { "build": "tsc", - "test": "bun test", + "test": "bun test src/", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "lint": "eslint . --cache" }, diff --git a/packages/common/package.json b/packages/common/package.json index 862b824..4def4f4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -14,7 +14,7 @@ "build": "tsc", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "lint": "eslint . --cache", - "test": "bun test" + "test": "bun test src/" }, "devDependencies": { "@macalinao/eslint-config": "catalog:", diff --git a/packages/db-schema/tsconfig.json b/packages/db-schema/tsconfig.json index a7c1e4d..681e4f8 100644 --- a/packages/db-schema/tsconfig.json +++ b/packages/db-schema/tsconfig.json @@ -1,15 +1,7 @@ { "extends": "@macalinao/tsconfig/tsconfig.base.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, - "composite": true, "types": ["node"] }, - "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 5ac913f..9fae056 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,14 +1,7 @@ { "extends": "@macalinao/tsconfig/tsconfig.base.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, "types": ["node", "bun"] }, - "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/testing/test-helpers/eslint.config.js b/packages/testing/test-helpers/eslint.config.js new file mode 100644 index 0000000..ee789e3 --- /dev/null +++ b/packages/testing/test-helpers/eslint.config.js @@ -0,0 +1,12 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/testing/test-helpers/package.json b/packages/testing/test-helpers/package.json new file mode 100644 index 0000000..1f5196b --- /dev/null +++ b/packages/testing/test-helpers/package.json @@ -0,0 +1,33 @@ +{ + "name": "@reviq/test-helpers", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", + "lint": "eslint . --cache" + }, + "dependencies": { + "@reviq/db": "workspace:*", + "@reviq/db-schema": "workspace:*", + "kysely": "^0.28.2", + "pg": "^8.16.3" + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "catalog:", + "@types/pg": "^8.16.0", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/testing/test-helpers/src/index.ts b/packages/testing/test-helpers/src/index.ts new file mode 100644 index 0000000..6685378 --- /dev/null +++ b/packages/testing/test-helpers/src/index.ts @@ -0,0 +1,18 @@ +export { describeE2E, SKIP_DB_TESTS } from "./skip-db-tests.js"; +export { + DEFAULT_TEST_AAGUID, + KNOWN_AAGUIDS, + TEST_RP, +} from "./test-constants.js"; +export { + createTestDb, + createTestUser, + destroySharedDb, + destroyTestDb, + getSharedDb, + getTestDatabaseUrl, + initTestDb, + runMigrations, + truncateAllTables, +} from "./test-db.js"; +export { withTestTransaction } from "./test-transaction.js"; diff --git a/packages/testing/test-helpers/src/skip-db-tests.ts b/packages/testing/test-helpers/src/skip-db-tests.ts new file mode 100644 index 0000000..cb836e6 --- /dev/null +++ b/packages/testing/test-helpers/src/skip-db-tests.ts @@ -0,0 +1,18 @@ +import { describe } from "bun:test"; + +/** + * Skip flag for database-dependent tests. + * Set SKIP_DB_TESTS=1 to skip e2e tests that require a database. + */ +export const SKIP_DB_TESTS: boolean = process.env.SKIP_DB_TESTS === "1"; + +const _describeSkipIf = describe.skipIf(SKIP_DB_TESTS); + +/** + * Use for describe blocks that require database access. + * Automatically prefixes name with [e2e]. + * Skips tests when SKIP_DB_TESTS=1 is set. + */ +export function describeE2E(name: string, fn: () => void): void { + _describeSkipIf(`[e2e] ${name}`, fn); +} diff --git a/apps/api-server/src/__tests__/helpers/test-constants.ts b/packages/testing/test-helpers/src/test-constants.ts similarity index 100% rename from apps/api-server/src/__tests__/helpers/test-constants.ts rename to packages/testing/test-helpers/src/test-constants.ts diff --git a/apps/api-server/src/__tests__/helpers/test-db.ts b/packages/testing/test-helpers/src/test-db.ts similarity index 90% rename from apps/api-server/src/__tests__/helpers/test-db.ts rename to packages/testing/test-helpers/src/test-db.ts index 759e2d6..7948bdd 100644 --- a/apps/api-server/src/__tests__/helpers/test-db.ts +++ b/packages/testing/test-helpers/src/test-db.ts @@ -64,20 +64,31 @@ export function getTestDatabaseUrl(): string { } /** - * Parses a postgres URL to extract components + * Parses a postgres URL to extract components. + * Supports both TCP and unix socket connections. + * + * Unix socket URL format: postgresql:///dbname?host=/var/run/postgresql */ function parsePostgresUrl(url: string): { host: string; - port: number; + port: number | undefined; user: string; password: string; database: string; } { const parsed = new URL(url); + + // Unix socket: hostname is empty, socket path in `host` query param + const isUnixSocket = !parsed.hostname; + const socketPath = parsed.searchParams.get("host"); + return { - host: parsed.hostname, - port: Number.parseInt(parsed.port || "5432", 10), - user: parsed.username, + host: isUnixSocket + ? (socketPath ?? "/var/run/postgresql") + : parsed.hostname, + port: isUnixSocket ? undefined : Number.parseInt(parsed.port || "5432", 10), + // eslint-disable-next-line turbo/no-undeclared-env-vars, @typescript-eslint/prefer-nullish-coalescing -- USER is a system env var, and we want empty string to fall back + user: parsed.username || process.env.USER || "postgres", password: parsed.password, database: parsed.pathname.slice(1), // Remove leading / }; diff --git a/apps/api-server/src/__tests__/helpers/test-transaction.ts b/packages/testing/test-helpers/src/test-transaction.ts similarity index 100% rename from apps/api-server/src/__tests__/helpers/test-transaction.ts rename to packages/testing/test-helpers/src/test-transaction.ts diff --git a/packages/testing/test-helpers/tsconfig.json b/packages/testing/test-helpers/tsconfig.json new file mode 100644 index 0000000..4a1e2b2 --- /dev/null +++ b/packages/testing/test-helpers/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@macalinao/tsconfig/tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/packages/testing/virtual-authenticator/package.json b/packages/testing/virtual-authenticator/package.json index 13e3eb4..8f5f616 100644 --- a/packages/testing/virtual-authenticator/package.json +++ b/packages/testing/virtual-authenticator/package.json @@ -3,14 +3,19 @@ "version": "0.0.1", "private": true, "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } }, "scripts": { "build": "tsc", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "lint": "eslint . --cache", - "test": "bun test" + "test": "bun test src/" }, "dependencies": { "@simplewebauthn/types": "^12.0.0" @@ -18,7 +23,7 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", - "@types/bun": "latest", + "@types/bun": "catalog:", "@types/node": "^25.0.3", "eslint": "catalog:", "typescript": "catalog:" diff --git a/packages/testing/virtual-authenticator/tsconfig.json b/packages/testing/virtual-authenticator/tsconfig.json index 03b601c..9fae056 100644 --- a/packages/testing/virtual-authenticator/tsconfig.json +++ b/packages/testing/virtual-authenticator/tsconfig.json @@ -1,15 +1,7 @@ { "extends": "@macalinao/tsconfig/tsconfig.base.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, - "composite": true, "types": ["node", "bun"] }, - "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/utils/package.json b/packages/utils/package.json index 00ef177..ab88207 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -14,7 +14,7 @@ "build": "tsc", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "lint": "eslint . --cache", - "test": "bun test" + "test": "bun test src/" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250529.0", diff --git a/turbo.json b/turbo.json index 4e37ec2..ad803a3 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turbo.build/schema.json", - "globalEnv": ["DATABASE_URL", "PORT"], + "globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"], "tasks": { "build": { "dependsOn": ["^build"], @@ -33,6 +33,7 @@ "test": { "dependsOn": ["^build"], "inputs": ["src/**/*.ts", "src/**/*.test.ts"], + "env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"], "cache": false } }