/** * 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 * - me.sessions.list - list all sessions * - me.sessions.revoke - revoke a session * - me.sessions.revokeAll - revoke all sessions except current * - me.devices.getInfo - get current device info * - me.devices.trust - trust current device * - me.devices.listTrusted - list trusted devices * - me.devices.untrust - untrust a device * - me.devices.revokeAll - revoke all trusted devices */ import type { Database } from "@reviq/db-schema"; 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 { createLoggingEmailClient } from "@reviq/emails"; import { createTestUser, describeE2E, getSharedDb, initTestDb, TEST_RP, uniqueTestId, 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"; /** 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; /** * Create an API context with optional authentication */ function createAPIContext( db: Kysely, options?: { sessionToken?: string; apiKey?: string; deviceFingerprint?: string; }, ): APIContext { const reqHeaders = new Headers(); const cookies: string[] = []; if (options?.sessionToken) { cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); } if (options?.deviceFingerprint) { cookies.push( `${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`, ); } if (cookies.length > 0) { reqHeaders.set("cookie", cookies.join("; ")); } if (options?.apiKey) { reqHeaders.set("x-api-key", options.apiKey); } return { db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, reqHeaders, resHeaders: new Headers(), email: { client: createLoggingEmailClient(), fromAddress: "test@example.com", baseUrl: TEST_RP.origin, }, }; } /** * Create a real session in the database and return the token and session ID */ async function createSession( db: Kysely, userId: number, options?: { ipAddress?: string; userAgent?: string }, ): Promise<{ token: string; sessionId: number }> { const token = `test-session-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const result = await db .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, ip_address: options?.ipAddress ?? "127.0.0.1", user_agent: options?.userAgent ?? "test-agent", expires_at: expiresAt, trusted_mode: false, }) .returning("id") .executeTakeFirstOrThrow(); return { token, sessionId: Number(result.id) }; } /** * Create a device in the database and return the fingerprint */ async function createDevice( db: Kysely, userId: number, options?: { fingerprint?: string; isTrusted?: boolean; name?: string; userAgent?: string; }, ): Promise<{ fingerprint: string; deviceId: number }> { const fingerprint = options?.fingerprint ?? `test-fp-${uniqueTestId()}`; const result = await db .insertInto("user_devices") .values({ user_id: userId, device_fingerprint: fingerprint, is_trusted: options?.isTrusted ?? false, name: options?.name ?? null, user_agent: options?.userAgent ?? "Mozilla/5.0 Test Browser", ip_address: "127.0.0.1", last_used_at: new Date(), }) .returning("id") .executeTakeFirstOrThrow(); return { fingerprint, deviceId: Number(result.id) }; } /** * Create an API token in the database and return the token */ async function createApiToken( db: Kysely, userId: number, ): Promise<{ token: string; name: string }> { const token = `test-api-token-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); await db .insertInto("api_tokens") .values({ user_id: userId, token_hash: tokenHashValue, name: "Test API Token", expires_at: expiresAt, }) .execute(); return { token, name: "Test API Token" }; } describeE2E("me", () => { beforeAll(async () => { await initTestDb(); }); // ============================================================================= // authMiddleware tests (base.ts) // ============================================================================= describe("authMiddleware", () => { test("rejects request with no session or API key", async () => { await withTestTransaction(getSharedDb(), async (db) => { const context = createAPIContext(db); // No auth await expect( call(router.me.get, undefined, { context }), ).rejects.toThrow("No session or API key"); }); }); test("rejects request with invalid session token", async () => { await withTestTransaction(getSharedDb(), async (db) => { // Use a token that doesn't exist in the database const context = createAPIContext(db, { sessionToken: "invalid-token-xyz", }); await expect( call(router.me.get, undefined, { context }), ).rejects.toThrow("Invalid or expired token"); }); }); test("rejects request with invalid API key", async () => { await withTestTransaction(getSharedDb(), async (db) => { // Use an API key that doesn't exist in the database const context = createAPIContext(db, { apiKey: "invalid-api-key-xyz" }); await expect( call(router.me.get, undefined, { context }), ).rejects.toThrow("Invalid or expired token"); }); }); // Note: "user not found after session lookup" (lines 100-102, 144-147 in base.ts) // cannot be tested due to FK cascade constraints - deleting a user cascades to // delete their sessions/api_tokens, making orphaned sessions impossible. // This is defensive code that protects against data inconsistencies. test("rejects request with expired session", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "expired@example.com" }); // Create an expired session const token = `expired-session-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); await db .insertInto("sessions") .values({ user_id: user.id, token_hash: tokenHashValue, expires_at: new Date(Date.now() - 1000), // Already expired trusted_mode: false, }) .execute(); const context = createAPIContext(db, { sessionToken: token }); await expect( call(router.me.get, undefined, { context }), ).rejects.toThrow("Invalid or expired token"); }); }); test("rejects request with revoked session", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "revoked@example.com" }); // Create a revoked session const token = `revoked-session-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); await db .insertInto("sessions") .values({ user_id: user.id, token_hash: tokenHashValue, expires_at: new Date(Date.now() + SESSION_EXPIRY_MS), revoked_at: new Date(), // Revoked trusted_mode: false, }) .execute(); const context = createAPIContext(db, { sessionToken: token }); await expect( call(router.me.get, undefined, { context }), ).rejects.toThrow("Invalid or expired token"); }); }); }); 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); }); }); 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, }); // Set display_name to null explicitly await db .updateTable("users") .set({ display_name: null }) .where("id", "=", user.id) .execute(); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call(router.me.get, undefined, { context }); expect(result.needsSetup).toBe(true); expect(result.displayName).toBeNull(); }); }); 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); }); }); }); 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, { 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); }); }); }); 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(); }); }); test("fails without password set", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "nopassdelete@example.com", }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.delete, { password: "anything" }, { context }), ).rejects.toThrow("Cannot delete account without a password"); }); }); 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); }); }); }); // ===== 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"]); }); }); test("marks current session with isCurrent flag", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "current@example.com", }); const { token: sessionToken1, sessionId: id1 } = await createSession( db, user.id, ); const { sessionId: id2 } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken: sessionToken1 }); const sessions = await call(router.me.sessions.list, undefined, { context, }); expect(sessions).toHaveLength(2); const current = sessions.find((s) => s.id === id1); const other = sessions.find((s) => s.id === id2); expect(current?.isCurrent).toBe(true); expect(other?.isCurrent).toBe(false); }); }); 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", "=", sessionId.toString()) .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", "=", sessionId2.toString()) .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", "=", sessionId2.toString()) .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", "=", id1.toString()) .executeTakeFirstOrThrow(); expect(currentSession.revoked_at).toBeNull(); // Verify other sessions ARE revoked const otherSessions = await db .selectFrom("sessions") .select(["id", "revoked_at"]) .where("id", "in", [id2.toString(), id3.toString()]) .execute(); for (const session of otherSessions) { expect(session.revoked_at).not.toBeNull(); } }); }); test("does nothing when only current session exists", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "onlyone@example.com", }); const { token: sessionToken, sessionId } = await createSession( db, user.id, ); const context = createAPIContext(db, { sessionToken }); // Should not throw await call(router.me.sessions.revokeAll, undefined, { context }); // Current session should still be valid const session = await db .selectFrom("sessions") .select(["revoked_at"]) .where("id", "=", sessionId.toString()) .executeTakeFirstOrThrow(); expect(session.revoked_at).toBeNull(); }); }); }); // ===== Device Management Tests ===== 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 { 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", "=", deviceId.toString()) .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", }); 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"); }); }); }); 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", "=", deviceId.toString()) .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", }); 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"); }); }); }); 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", "=", deviceId.toString()) .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 describeE2E // ============================================================================= // me.apiTokens and me.invites tests // ============================================================================= /** * Create a trusted session for testing API token creation */ async function createTrustedSession( db: Kysely, userId: number, ): Promise<{ token: string; sessionId: number }> { const token = `test-session-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const result = await db .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, ip_address: "127.0.0.1", user_agent: "test-agent", expires_at: expiresAt, trusted_mode: true, }) .returning("id") .executeTakeFirstOrThrow(); return { token, sessionId: Number(result.id) }; } /** * Create an organization for testing */ async function createOrg( db: Kysely, data: { slug: string; displayName?: string }, ): Promise<{ id: number; slug: string }> { const result = await db .insertInto("orgs") .values({ slug: data.slug, display_name: data.displayName ?? data.slug, }) .returning(["id", "slug"]) .executeTakeFirstOrThrow(); return { id: result.id, slug: result.slug }; } /** * Add a member to an org */ async function addOrgMember( db: Kysely, orgId: number, userId: number, role: "owner" | "admin" | "member" = "member", ): Promise { await db .insertInto("org_members") .values({ org_id: orgId, user_id: userId, role }) .execute(); } /** * Create an org invite for testing */ async function createOrgInvite( db: Kysely, data: { orgId: number; email: string; invitedBy: number; role?: "owner" | "admin" | "member"; expiresAt?: Date; }, ): Promise<{ id: number }> { const token = `invite-token-${uniqueTestId()}-${Math.random().toString(36).slice(2)}`; const result = await db .insertInto("org_invites") .values({ org_id: data.orgId, email: data.email.toLowerCase(), invited_by: data.invitedBy, role: data.role ?? "member", token, expires_at: data.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }) .returning("id") .executeTakeFirstOrThrow(); return { id: result.id }; } describeE2E("me.apiTokens and me.invites", () => { describe("me.apiTokens.list", () => { test("returns empty list for user without tokens", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "notokens@example.com", isSuperuser: true, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const tokens = await call(router.me.apiTokens.list, undefined, { context, }); expect(tokens).toHaveLength(0); }); }); test("returns tokens for user with tokens", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "hastokens@example.com", isSuperuser: true, }); // Create some API tokens directly in DB const tokenHash1 = await hashToken("token1"); const tokenHash2 = await hashToken("token2"); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); await db .insertInto("api_tokens") .values([ { user_id: user.id, token_hash: tokenHash1, name: "Token One", expires_at: expiresAt, }, { user_id: user.id, token_hash: tokenHash2, name: "Token Two", expires_at: expiresAt, }, ]) .execute(); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const tokens = await call(router.me.apiTokens.list, undefined, { context, }); expect(tokens).toHaveLength(2); const names = tokens.map((t) => t.name).sort(); expect(names).toEqual(["Token One", "Token Two"]); expect(tokens[0]).toHaveProperty("id"); expect(tokens[0]).toHaveProperty("createdAt"); expect(tokens[0]).toHaveProperty("expiresAt"); }); }); test("only returns current user tokens", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user1 = await createTestUser(db, { email: "user1@example.com", isSuperuser: true, }); const user2 = await createTestUser(db, { email: "user2@example.com", isSuperuser: true, }); const tokenHash1 = await hashToken("token1"); const tokenHash2 = await hashToken("token2"); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); await db .insertInto("api_tokens") .values([ { user_id: user1.id, token_hash: tokenHash1, name: "User1 Token", expires_at: expiresAt, }, { user_id: user2.id, token_hash: tokenHash2, name: "User2 Token", expires_at: expiresAt, }, ]) .execute(); const { token: sessionToken } = await createSession(db, user1.id); const context = createAPIContext(db, { sessionToken }); const tokens = await call(router.me.apiTokens.list, undefined, { context, }); expect(tokens).toHaveLength(1); expect(tokens[0]?.name).toBe("User1 Token"); }); }); }); describe("me.apiTokens.create", () => { test("creates token for superuser with trusted session", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "superuser@example.com", isSuperuser: true, }); const { token: sessionToken } = await createTrustedSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call( router.me.apiTokens.create, { name: "My New Token" }, { context }, ); expect(result.token).toBeDefined(); expect(result.token.startsWith("reviq_")).toBe(true); expect(result.expiresAt).toBeDefined(); // Verify token was created in DB const tokens = await db .selectFrom("api_tokens") .selectAll() .where("user_id", "=", user.id) .execute(); expect(tokens).toHaveLength(1); expect(tokens[0]?.name).toBe("My New Token"); }); }); test("rejects non-superuser", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "regular@example.com", isSuperuser: false, }); const { token: sessionToken } = await createTrustedSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.apiTokens.create, { name: "Test Token" }, { context }), ).rejects.toThrow("Only superusers can create API tokens"); }); }); test("rejects untrusted session", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "superuser2@example.com", isSuperuser: true, }); // Use regular session (not trusted) const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.apiTokens.create, { name: "Test Token" }, { context }), ).rejects.toThrow("Creating API tokens requires a trusted session"); }); }); }); describe("me.apiTokens.delete", () => { test("deletes own token", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "deletetoken@example.com", isSuperuser: true, }); const tokenHash = await hashToken("token-to-delete"); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); const insertResult = await db .insertInto("api_tokens") .values({ user_id: user.id, token_hash: tokenHash, name: "To Delete", expires_at: expiresAt, }) .returning("id") .executeTakeFirstOrThrow(); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call( router.me.apiTokens.delete, { tokenId: Number(insertResult.id) }, { context }, ); expect(result.success).toBe(true); // Verify token was deleted const tokens = await db .selectFrom("api_tokens") .selectAll() .where("user_id", "=", user.id) .execute(); expect(tokens).toHaveLength(0); }); }); test("cannot delete other user token", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user1 = await createTestUser(db, { email: "owner@example.com", isSuperuser: true, }); const user2 = await createTestUser(db, { email: "other@example.com", isSuperuser: true, }); const tokenHash = await hashToken("other-token"); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); const insertResult = await db .insertInto("api_tokens") .values({ user_id: user1.id, token_hash: tokenHash, name: "User1 Token", expires_at: expiresAt, }) .returning("id") .executeTakeFirstOrThrow(); const { token: sessionToken } = await createSession(db, user2.id); const context = createAPIContext(db, { sessionToken }); await expect( call( router.me.apiTokens.delete, { tokenId: Number(insertResult.id) }, { context }, ), ).rejects.toThrow("API token not found"); }); }); test("returns error for non-existent token", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "notoken@example.com", isSuperuser: true, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.apiTokens.delete, { tokenId: 99999 }, { context }), ).rejects.toThrow("API token not found"); }); }); }); // ============================================================================= // me.invites tests // ============================================================================= describe("me.invites.list", () => { test("returns empty list when email not verified", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "test-org", displayName: "Test Org", }); await addOrgMember(db, org.id, inviter.id, "owner"); // User without verified email const user = await createTestUser(db, { email: "unverified@example.com", }); // Create an invite for the unverified user await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const invites = await call(router.me.invites.list, undefined, { context, }); expect(invites).toHaveLength(0); }); }); test("returns pending invites for verified user", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter2@example.com", emailVerifiedAt: new Date(), displayName: "Inviter Person", }); const org = await createOrg(db, { slug: "invite-org", displayName: "Invite Org", }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "verified@example.com", emailVerifiedAt: new Date(), }); await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, role: "admin", }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const invites = await call(router.me.invites.list, undefined, { context, }); expect(invites).toHaveLength(1); expect(invites[0]?.org.slug).toBe("invite-org"); expect(invites[0]?.org.displayName).toBe("Invite Org"); expect(invites[0]?.role).toBe("admin"); expect(invites[0]?.invitedBy).toBe("Inviter Person"); }); }); test("does not return expired invites", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter3@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "expired-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "verified2@example.com", emailVerifiedAt: new Date(), }); // Create an expired invite await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, expiresAt: new Date(Date.now() - 1000), // Already expired }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const invites = await call(router.me.invites.list, undefined, { context, }); expect(invites).toHaveLength(0); }); }); }); describe("me.invites.get", () => { test("returns invite details", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter4@example.com", emailVerifiedAt: new Date(), displayName: "The Inviter", }); const org = await createOrg(db, { slug: "get-invite-org", displayName: "Get Invite Org", }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "getinvite@example.com", emailVerifiedAt: new Date(), }); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, role: "member", }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call( router.me.invites.get, { inviteId: invite.id }, { context }, ); expect(result.id).toBe(invite.id); expect(result.org.slug).toBe("get-invite-org"); expect(result.role).toBe("member"); expect(result.invitedBy).toBe("The Inviter"); }); }); test("rejects if email not verified", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter5@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "unverified-get-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "unverified2@example.com", }); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.get, { inviteId: invite.id }, { context }), ).rejects.toThrow("Please verify your email to view invitations"); }); }); test("returns not found for other user invite", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter6@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "other-user-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const otherUser = await createTestUser(db, { email: "other@example.com", emailVerifiedAt: new Date(), }); const user = await createTestUser(db, { email: "requestor@example.com", emailVerifiedAt: new Date(), }); // Invite is for otherUser, not user const invite = await createOrgInvite(db, { orgId: org.id, email: otherUser.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.get, { inviteId: invite.id }, { context }), ).rejects.toThrow("Invitation not found or expired"); }); }); }); describe("me.invites.accept", () => { test("accepts invite and adds user to org", async () => { const db = getSharedDb(); const uniqueId = uniqueTestId(); const inviter = await createTestUser(db, { email: `inviter-accept-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: `accept-org-${uniqueId}` }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: `accepter-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, role: "admin", }); try { const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call( router.me.invites.accept, { inviteId: invite.id }, { context }, ); expect(result.success).toBe(true); // Verify user is now a member const membership = await db .selectFrom("org_members") .selectAll() .where("org_id", "=", org.id) .where("user_id", "=", user.id) .executeTakeFirst(); expect(membership).toBeDefined(); expect(membership?.role).toBe("admin"); // Verify invite was deleted const inviteCheck = await db .selectFrom("org_invites") .selectAll() .where("id", "=", invite.id) .executeTakeFirst(); expect(inviteCheck).toBeUndefined(); } finally { // Cleanup await db .deleteFrom("org_members") .where("org_id", "=", org.id) .execute(); await db .deleteFrom("org_invites") .where("org_id", "=", org.id) .execute(); await db .deleteFrom("sessions") .where("user_id", "=", user.id) .execute(); await db.deleteFrom("orgs").where("id", "=", org.id).execute(); await db.deleteFrom("users").where("id", "=", user.id).execute(); await db.deleteFrom("users").where("id", "=", inviter.id).execute(); } }); test("rejects if email not verified", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter7@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "unverified-accept-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "unverified3@example.com", }); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.accept, { inviteId: invite.id }, { context }), ).rejects.toThrow("Please verify your email to accept invitations"); }); }); test("returns error if already a member", async () => { const db = getSharedDb(); const uniqueId = uniqueTestId(); const inviter = await createTestUser(db, { email: `inviter-already-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: `already-member-org-${uniqueId}`, }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: `already-member-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); // User is already a member await addOrgMember(db, org.id, user.id, "member"); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, role: "admin", }); try { const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.accept, { inviteId: invite.id }, { context }), ).rejects.toThrow("You are already a member of this organization"); } finally { // Cleanup await db .deleteFrom("org_members") .where("org_id", "=", org.id) .execute(); await db .deleteFrom("org_invites") .where("org_id", "=", org.id) .execute(); await db .deleteFrom("sessions") .where("user_id", "=", user.id) .execute(); await db.deleteFrom("orgs").where("id", "=", org.id).execute(); await db.deleteFrom("users").where("id", "=", user.id).execute(); await db.deleteFrom("users").where("id", "=", inviter.id).execute(); } }); test("returns not found for non-existent invite", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "acceptnonexistent@example.com", emailVerifiedAt: new Date(), }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.accept, { inviteId: 99999 }, { context }), ).rejects.toThrow("Invitation not found or expired"); }); }); }); describe("me.invites.decline", () => { test("declines invite and deletes it", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter8@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "decline-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { email: "decliner@example.com", emailVerifiedAt: new Date(), }); const invite = await createOrgInvite(db, { orgId: org.id, email: user.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); const result = await call( router.me.invites.decline, { inviteId: invite.id }, { context }, ); expect(result.success).toBe(true); // Verify invite was deleted const inviteCheck = await db .selectFrom("org_invites") .selectAll() .where("id", "=", invite.id) .executeTakeFirst(); expect(inviteCheck).toBeUndefined(); }); }); test("returns not found for other user invite", async () => { await withTestTransaction(getSharedDb(), async (db) => { const inviter = await createTestUser(db, { email: "inviter9@example.com", emailVerifiedAt: new Date(), }); const org = await createOrg(db, { slug: "other-decline-org" }); await addOrgMember(db, org.id, inviter.id, "owner"); const otherUser = await createTestUser(db, { email: "otherinvited@example.com", emailVerifiedAt: new Date(), }); const user = await createTestUser(db, { email: "wrongdecliner@example.com", emailVerifiedAt: new Date(), }); const invite = await createOrgInvite(db, { orgId: org.id, email: otherUser.email, invitedBy: inviter.id, }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.decline, { inviteId: invite.id }, { context }), ).rejects.toThrow("Invitation not found"); }); }); test("returns not found for non-existent invite", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "noinvite@example.com", emailVerifiedAt: new Date(), }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.me.invites.decline, { inviteId: 99999 }, { context }), ).rejects.toThrow("Invitation not found"); }); }); }); // Close describe for me.invites.decline }); // Close describeE2E for me.apiTokens and me.invites