/** * End-to-end tests for Me procedures (user profile and account management) * * These tests use a real PostgreSQL database to test: * - me.get - get user profile * - me.authStatus - get authentication status * - me.setupProfile - initial profile setup * - me.updateProfile - update profile fields * - me.setPassword - set/change password * - me.delete - delete account */ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; import type { APIContext } from "../../context.js"; import { afterAll, beforeAll, beforeEach, describe, expect, test, } from "bun:test"; import { call } from "@orpc/server"; import { router } from "../../router.js"; import { hashPassword } from "../../utils/password.js"; import { hashToken } from "../../utils/crypto.js"; import { COOKIE_NAMES } from "../../utils/cookies.js"; import { TEST_RP } from "../helpers/test-constants.js"; import { createTestDb, createTestUser, destroyTestDb, runMigrations, truncateAllTables, } from "../helpers/test-db.js"; /** Session expiry duration: 24 hours in milliseconds */ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; /** API token expiry duration: 1 year in milliseconds */ const API_TOKEN_EXPIRY_MS = 365 * 24 * 60 * 60 * 1000; /** API token expiry duration in cascade test: 1 day in milliseconds */ const ONE_DAY_MS = 86400000; let db: Kysely | undefined; function getDb(): Kysely { if (!db) { throw new Error("Database not initialized"); } return db; } /** * Create an API context with optional authentication */ function createAPIContext(options?: { sessionToken?: string; apiKey?: string; }): APIContext { const reqHeaders = new Headers(); if (options?.sessionToken) { reqHeaders.set( "cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`, ); } if (options?.apiKey) { reqHeaders.set("x-api-key", options.apiKey); } return { db: getDb(), origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, reqHeaders, resHeaders: new Headers(), }; } /** * Create a real session in the database and return the token */ async function createSession(userId: number): Promise { const token = "test-session-" + String(Date.now()) + String(Math.random()); const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); await getDb() .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, ip_address: "127.0.0.1", user_agent: "test-agent", expires_at: expiresAt, trusted_mode: false, }) .execute(); return token; } /** * Create an API token in the database and return the token */ async function createApiToken( userId: number, ): Promise<{ token: string; name: string }> { const token = "test-api-token-" + String(Date.now()) + String(Math.random()); const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); await getDb() .insertInto("api_tokens") .values({ user_id: userId, token_hash: tokenHashValue, name: "Test API Token", expires_at: expiresAt, }) .execute(); return { token, name: "Test API Token" }; } beforeAll(async () => { await runMigrations(); db = createTestDb(); }); afterAll(async () => { if (db) { await destroyTestDb(db); } }); beforeEach(async () => { await truncateAllTables(getDb()); }); describe("me.get", () => { test("returns user profile with all fields", async () => { const user = await createTestUser(getDb(), { email: "test@example.com", displayName: "Test User", fullName: "Test Full Name", emailVerifiedAt: new Date(), }); // Update with phone number await getDb() .updateTable("users") .set({ phone_number: "+1234567890" }) .where("id", "=", user.id) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); expect(result.id).toBe(user.id); expect(result.email).toBe("test@example.com"); expect(result.displayName).toBe("Test User"); expect(result.fullName).toBe("Test Full Name"); expect(result.phoneNumber).toBe("+1234567890"); expect(result.emailVerified).toBe(true); expect(result.needsSetup).toBe(false); expect(result.isSuperuser).toBe(false); expect(result.hasPassword).toBe(false); }); test("returns needsSetup=true when displayName is null", async () => { const user = await createTestUser(getDb(), { email: "newuser@example.com", displayName: undefined, }); // Set display_name to null explicitly await getDb() .updateTable("users") .set({ display_name: null }) .where("id", "=", user.id) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); expect(result.needsSetup).toBe(true); expect(result.displayName).toBeNull(); }); test("returns hasPassword=true when user has password", async () => { const passwordHash = await hashPassword("securePassword123!"); const user = await createTestUser(getDb(), { email: "withpassword@example.com", passwordHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); expect(result.hasPassword).toBe(true); }); test("returns isSuperuser=true for superuser", async () => { const user = await createTestUser(getDb(), { email: "admin@example.com", isSuperuser: true, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); expect(result.isSuperuser).toBe(true); }); }); describe("me.authStatus", () => { test("returns session auth info", async () => { const user = await createTestUser(getDb(), { email: "session@example.com", displayName: "Session User", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.authStatus, undefined, { context }); expect(result.user.email).toBe("session@example.com"); expect(result.user.displayName).toBe("Session User"); expect(result.auth.method).toBe("session"); if (result.auth.method === "session") { expect(result.auth.expiresAt).toBeInstanceOf(Date); } }); test("returns api_token auth info", async () => { const user = await createTestUser(getDb(), { email: "apitoken@example.com", }); const { token } = await createApiToken(user.id); const context = createAPIContext({ apiKey: token }); const result = await call(router.me.authStatus, undefined, { context }); expect(result.user.email).toBe("apitoken@example.com"); expect(result.auth.method).toBe("api_token"); if (result.auth.method === "api_token") { expect(result.auth.tokenName).toBe("Test API Token"); expect(result.auth.expiresAt).toBeInstanceOf(Date); } }); }); describe("me.setupProfile", () => { test("sets up profile with required fields", async () => { const user = await createTestUser(getDb(), { email: "setup@example.com", displayName: undefined, }); // Clear display_name await getDb() .updateTable("users") .set({ display_name: null }) .where("id", "=", user.id) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.setupProfile, { displayName: "New Display Name", fullName: "John Doe", phoneNumber: "+12025551234", }, { context }, ); // Verify changes const updated = await getDb() .selectFrom("users") .select(["display_name", "full_name", "phone_number"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.display_name).toBe("New Display Name"); expect(updated.full_name).toBe("John Doe"); expect(updated.phone_number).toBe("+12025551234"); }); test("sets up profile with only required displayName", async () => { const user = await createTestUser(getDb(), { email: "minimal@example.com", }); await getDb() .updateTable("users") .set({ display_name: null }) .where("id", "=", user.id) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.setupProfile, { displayName: "Minimal User", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["display_name", "full_name", "phone_number"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.display_name).toBe("Minimal User"); expect(updated.full_name).toBeNull(); expect(updated.phone_number).toBeNull(); }); }); describe("me.updateProfile", () => { test("updates displayName only", async () => { const user = await createTestUser(getDb(), { email: "update@example.com", displayName: "Original Name", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.updateProfile, { displayName: "Updated Name", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["display_name"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.display_name).toBe("Updated Name"); }); test("updates multiple fields at once", async () => { const user = await createTestUser(getDb(), { email: "multi@example.com", displayName: "Original", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.updateProfile, { displayName: "New Display", fullName: "Full Name Here", phoneNumber: "+12025551234", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["display_name", "full_name", "phone_number"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.display_name).toBe("New Display"); expect(updated.full_name).toBe("Full Name Here"); expect(updated.phone_number).toBe("+12025551234"); }); test("empty strings in optional fields are treated as no-op", async () => { // Empty strings in optionalString fields are transformed to undefined, // which means no update happens - fields keep their existing values const user = await createTestUser(getDb(), { email: "clear@example.com", displayName: "Keep Me", fullName: "Keep This Too", }); await getDb() .updateTable("users") .set({ phone_number: "+12025551234" }) .where("id", "=", user.id) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.updateProfile, { fullName: "", phoneNumber: "", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["display_name", "full_name", "phone_number"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); // Empty strings are transformed to undefined by optionalString, // so no update happens - fields keep their existing values expect(updated.display_name).toBe("Keep Me"); expect(updated.full_name).toBe("Keep This Too"); expect(updated.phone_number).toBe("+12025551234"); }); test("does nothing when no fields provided", async () => { const user = await createTestUser(getDb(), { email: "noop@example.com", displayName: "Stay Same", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.updateProfile, {}, { context }); const updated = await getDb() .selectFrom("users") .select(["display_name"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.display_name).toBe("Stay Same"); }); }); describe("me.setPassword", () => { test("sets password for user without password", async () => { const user = await createTestUser(getDb(), { email: "nopass@example.com", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Use a strong password await call( router.me.setPassword, { newPassword: "SuperSecure123!@#$%", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["password_hash"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.password_hash).not.toBeNull(); }); test("changes password with correct current password", async () => { const oldPassword = "OldPassword123!@#"; const oldHash = await hashPassword(oldPassword); const user = await createTestUser(getDb(), { email: "changepass@example.com", passwordHash: oldHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( router.me.setPassword, { currentPassword: oldPassword, newPassword: "NewSecurePassword456!@#", }, { context }, ); const updated = await getDb() .selectFrom("users") .select(["password_hash"]) .where("id", "=", user.id) .executeTakeFirstOrThrow(); expect(updated.password_hash).not.toBe(oldHash); }); test("fails without current password when user has password", async () => { const oldHash = await hashPassword("ExistingPass123!"); const user = await createTestUser(getDb(), { email: "haspass@example.com", passwordHash: oldHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( call( router.me.setPassword, { newPassword: "NewPassword123!@#", }, { context }, ), ).rejects.toThrow("Current password required"); }); test("fails with incorrect current password", async () => { const oldHash = await hashPassword("CorrectPassword123!"); const user = await createTestUser(getDb(), { email: "wrongpass@example.com", passwordHash: oldHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( call( router.me.setPassword, { currentPassword: "WrongPassword123!", newPassword: "NewPassword456!@#", }, { context }, ), ).rejects.toThrow("Current password is incorrect"); }); test("fails with weak password", async () => { const user = await createTestUser(getDb(), { email: "weak@example.com", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Password must be at least 8 chars to pass schema validation // "password" passes length check but fails zxcvbn strength check // zxcvbn provides feedback like "This is a top-10 common password" await expect( call( router.me.setPassword, { newPassword: "password", // 8 chars but extremely common }, { context }, ), ).rejects.toThrow(/common|top|weak|guess/i); }); }); describe("me.delete", () => { test("deletes account with correct password", async () => { const password = "DeleteMe123!@#"; const passwordHash = await hashPassword(password); const user = await createTestUser(getDb(), { email: "delete@example.com", passwordHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); // Verify user is deleted const deleted = await getDb() .selectFrom("users") .where("id", "=", user.id) .selectAll() .executeTakeFirst(); expect(deleted).toBeUndefined(); }); test("fails without password set", async () => { const user = await createTestUser(getDb(), { email: "nopassdelete@example.com", }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( call(router.me.delete, { password: "anything" }, { context }), ).rejects.toThrow("Cannot delete account without a password"); }); test("fails with incorrect password", async () => { const passwordHash = await hashPassword("CorrectPassword123!"); const user = await createTestUser(getDb(), { email: "wrongdelete@example.com", passwordHash, }); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( call(router.me.delete, { password: "WrongPassword123!" }, { context }), ).rejects.toThrow("Incorrect password"); }); test("cascades deletion to related records", async () => { const password = "CascadeDelete123!@#"; const passwordHash = await hashPassword(password); const user = await createTestUser(getDb(), { email: "cascade@example.com", passwordHash, }); // Create related records await getDb() .insertInto("api_tokens") .values({ user_id: user.id, token_hash: "test-hash", name: "Test Token", expires_at: new Date(Date.now() + ONE_DAY_MS), }) .execute(); const sessionToken = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); // Verify cascaded deletion const tokens = await getDb() .selectFrom("api_tokens") .where("user_id", "=", user.id) .selectAll() .execute(); expect(tokens).toHaveLength(0); }); });