From ebc85af62c91f14dd1c43c5b105bf68fae989d4f Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 12:53:19 +0800 Subject: [PATCH 1/3] Add comprehensive e2e tests for API procedures with 100% coverage - Add admin.test.ts: Tests for superuser operations (users, orgs, sites) - Add orgs.test.ts: Tests for org management, members, invites, sites - Expand me.test.ts: Add API tokens, invites, authMiddleware error paths - Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix Bug fixes: - Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number - Fix signup race condition by handling unique constraint violations gracefully All 283 tests pass with 100% function coverage on procedures. Co-Authored-By: Claude Opus 4.5 --- .../src/__tests__/e2e/admin.test.ts | 1898 +++++++++++++++++ .../api-server/src/__tests__/e2e/auth.test.ts | 61 +- apps/api-server/src/__tests__/e2e/me.test.ts | 860 ++++++++ .../api-server/src/__tests__/e2e/orgs.test.ts | 1568 ++++++++++++++ apps/api-server/src/procedures/auth/signup.ts | 124 +- .../api-server/src/procedures/orgs/helpers.ts | 3 +- db/schema.sql | 4 +- 7 files changed, 4461 insertions(+), 57 deletions(-) create mode 100644 apps/api-server/src/__tests__/e2e/admin.test.ts create mode 100644 apps/api-server/src/__tests__/e2e/orgs.test.ts diff --git a/apps/api-server/src/__tests__/e2e/admin.test.ts b/apps/api-server/src/__tests__/e2e/admin.test.ts new file mode 100644 index 0000000..c1470d3 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/admin.test.ts @@ -0,0 +1,1898 @@ +/** + * End-to-end tests for Admin procedures (superuser operations) + * + * These tests use a real PostgreSQL database to test: + * + * Users: + * - admin.users.list - list all users + * - admin.users.get - get user by email + * - admin.users.create - create passwordless user with optional org + * - admin.users.update - update user properties (e.g., isSuperuser) + * - admin.users.confirmEmail - confirm a user's email + * + * Organizations: + * - admin.orgs.list - list all organizations + * - admin.orgs.get - get organization by slug + * - admin.orgs.create - create organization with owner + * - admin.orgs.update - update organization properties + * - admin.orgs.delete - delete organization and related records + * - admin.orgs.listSites - list sites for organization + * - admin.orgs.addSite - add site to organization + * - admin.orgs.removeSite - remove site from organization + * + * Auth: + * - admin.auth.completeLogin - complete pending login request + */ + +import type { Database } from "@reviq/db-schema"; +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 { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; +import { TEST_RP } from "../helpers/test-constants.js"; +import { + createTestUser, + getSharedDb, + initTestDb, + truncateAllTables, +} 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; + +/** Login request expiry: 15 minutes in milliseconds */ +const LOGIN_REQUEST_EXPIRY_MS = 15 * 60 * 1000; + +/** + * Create an API context with optional authentication + */ +function createAPIContext( + db: Kysely, + options?: { + sessionToken?: string; + }, +): APIContext { + const reqHeaders = new Headers(); + const cookies: string[] = []; + + if (options?.sessionToken) { + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); + } + + return { + db, + 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( + db: Kysely, + userId: number, +): Promise<{ token: string; sessionId: number }> { + const token = `test-session-${String(Date.now())}${String(Math.random())}`; + 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: false, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { token, sessionId: Number(result.id) }; +} + +/** + * Create an organization in the database + */ +async function createOrg( + db: Kysely, + options?: { + slug?: string; + displayName?: string; + logoUrl?: string; + }, +): Promise<{ id: number; slug: string }> { + const slug = options?.slug ?? `org-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`; + + const result = await db + .insertInto("orgs") + .values({ + slug, + display_name: options?.displayName ?? "Test Organization", + logo_url: options?.logoUrl ?? null, + }) + .returning(["id", "slug"]) + .executeTakeFirstOrThrow(); + + return result; +} + +/** + * Add a user as a member of an organization + */ +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 a site for an organization + */ +async function createSite( + db: Kysely, + orgId: number, + domain: string, +): Promise<{ id: number; domain: string }> { + const result = await db + .insertInto("org_sites") + .values({ + org_id: orgId, + domain, + }) + .returning(["id", "domain"]) + .executeTakeFirstOrThrow(); + + return result; +} + +/** + * Create a login request in the database + */ +async function createLoginRequest( + db: Kysely, + userId: number, + email: string, + options?: { + completedAt?: Date | null; + expiresAt?: Date; + }, +): Promise<{ id: number; token: string }> { + const token = `login-${String(Date.now())}${String(Math.random())}`; + const expiresAt = options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); + + const result = await db + .insertInto("login_requests") + .values({ + user_id: userId, + email, + token, + device_fingerprint: "test-fingerprint", + expires_at: expiresAt, + completed_at: options?.completedAt ?? null, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: Number(result.id), token }; +} + +/** + * Create an organization invite + */ +async function createOrgInvite( + db: Kysely, + orgId: number, + email: string, + invitedBy: number, +): Promise<{ id: number }> { + const token = `invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const result = await db + .insertInto("org_invites") + .values({ + org_id: orgId, + email, + role: "member", + invited_by: invitedBy, + token, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: Number(result.id) }; +} + +beforeAll(async () => { + await initTestDb(); + // Ensure clean slate in case other test files left data behind + await truncateAllTables(getSharedDb()); +}); + +// ===== Authorization Tests ===== + +describe("admin authorization", () => { + test("rejects non-superuser for admin.users.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "regular@example.com", + isSuperuser: false, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.users.list, undefined, { context }), + ).rejects.toThrow("Superuser access required"); + }); + }); + + test("rejects unauthenticated request for admin.orgs.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call(router.admin.orgs.list, undefined, { context }), + ).rejects.toThrow("No session or API key"); + }); + }); +}); + +// ===== admin.users.list ===== + +describe("admin.users.list", () => { + test("returns all users", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "user1@example.com" }); + await createTestUser(db, { email: "user2@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const users = await call(router.admin.users.list, undefined, { context }); + + expect(users.length).toBe(3); + const emails = users.map((u) => u.email).sort(); + expect(emails).toContain("admin@example.com"); + expect(emails).toContain("user1@example.com"); + expect(emails).toContain("user2@example.com"); + }); + }); + + test("returns users with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + displayName: "Admin User", + fullName: "Admin Full Name", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const users = await call(router.admin.users.list, undefined, { context }); + + const adminUser = users.find((u) => u.email === "admin@example.com"); + expect(adminUser).toBeDefined(); + expect(adminUser?.displayName).toBe("Admin User"); + expect(adminUser?.fullName).toBe("Admin Full Name"); + expect(adminUser?.emailVerified).toBe(true); + expect(adminUser?.isSuperuser).toBe(true); + expect(adminUser?.needsSetup).toBe(false); + }); + }); + + test("returns empty array when no users", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + // Create only the admin user + const admin = await createTestUser(db, { + email: "onlyadmin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const users = await call(router.admin.users.list, undefined, { context }); + + // Only the admin user exists + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("onlyadmin@example.com"); + }); + }); +}); + +// ===== admin.users.get ===== + +describe("admin.users.get", () => { + test("returns user by email", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "target@example.com", + displayName: "Target User", + fullName: "Target Full", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const user = await call( + router.admin.users.get, + { email: "target@example.com" }, + { context }, + ); + + expect(user.email).toBe("target@example.com"); + expect(user.displayName).toBe("Target User"); + expect(user.fullName).toBe("Target Full"); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "test@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const user = await call( + router.admin.users.get, + { email: "TEST@EXAMPLE.COM" }, + { context }, + ); + + expect(user.email).toBe("test@example.com"); + }); + }); + + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.get, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + + test("returns correct hasPassword and needsSetup flags", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + // User without display name (needs setup) + await createTestUser(db, { + email: "nosetup@example.com", + displayName: undefined, + }); + + // Set display_name to null + await db + .updateTable("users") + .set({ display_name: null }) + .where("email", "=", "nosetup@example.com") + .execute(); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const user = await call( + router.admin.users.get, + { email: "nosetup@example.com" }, + { context }, + ); + + expect(user.needsSetup).toBe(true); + expect(user.hasPassword).toBe(false); + }); + }); +}); + +// ===== admin.users.create ===== +// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + +describe("admin.users.create", () => { + afterAll(async () => { + // Clean up all test data + await truncateAllTables(getSharedDb()); + }); + + test("creates passwordless user", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.users.create, + { email: `newuser-${uniqueId}@example.com` }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify user was created + const user = await db + .selectFrom("users") + .where("email", "=", `newuser-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).toBeNull(); + }); + + test("creates user with name", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `named-${uniqueId}@example.com`, name: "Named User" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", `named-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user?.display_name).toBe("Named User"); + }); + + test("creates user and adds to organization as member", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `orguser-${uniqueId}@example.com`, orgSlug: `test-org-${uniqueId}` }, + { context }, + ); + + // Verify org membership + const membership = await db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("users.email", "=", `orguser-${uniqueId}@example.com`) + .where("org_members.org_id", "=", org.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("member"); + }); + + test("creates user and adds to organization with custom role", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `adminuser-${uniqueId}@example.com`, orgSlug: `test-org-${uniqueId}`, orgRole: "admin" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("users.email", "=", `adminuser-${uniqueId}@example.com`) + .where("org_members.org_id", "=", org.id) + .selectAll() + .executeTakeFirst(); + + expect(membership?.role).toBe("admin"); + }); + + test("normalizes email to lowercase", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `UPPERCASE-${uniqueId}@EXAMPLE.COM` }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", `uppercase-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user).toBeDefined(); + }); + + test("throws CONFLICT for duplicate email", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + await createTestUser(db, { email: `existing-${uniqueId}@example.com` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.create, + { email: `existing-${uniqueId}@example.com` }, + { context }, + ), + ).rejects.toThrow("User with this email already exists"); + }); + + test("throws NOT_FOUND for non-existent org", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.create, + { email: `newuser-${uniqueId}@example.com`, orgSlug: "nonexistent-org" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); +}); + +// ===== admin.users.update ===== + +describe("admin.users.update", () => { + test("grants superuser status", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "regular@example.com", + isSuperuser: false, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.update, + { email: "regular@example.com", isSuperuser: true }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "regular@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); + + expect(user.is_superuser).toBe(true); + }); + }); + + test("revokes superuser status from another user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "otheradmin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.update, + { email: "otheradmin@example.com", isSuperuser: false }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "otheradmin@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); + + expect(user.is_superuser).toBe(false); + }); + }); + + test("prevents self-demotion", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "admin@example.com", isSuperuser: false }, + { context }, + ), + ).rejects.toThrow("Cannot remove your own superuser status"); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "target@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.update, + { email: "TARGET@EXAMPLE.COM", isSuperuser: true }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "target@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); + + expect(user.is_superuser).toBe(true); + }); + }); + + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "nonexistent@example.com", isSuperuser: true }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + + test("returns success for no-op update (no fields to update)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "target@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.users.update, + { email: "target@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); + + test("throws NOT_FOUND for no-op update on non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); +}); + +// ===== admin.users.confirmEmail ===== + +describe("admin.users.confirmEmail", () => { + test("confirms user email", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "unverified@example.com", + emailVerifiedAt: undefined, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.confirmEmail, + { email: "unverified@example.com" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "unverified@example.com") + .select(["email_verified_at"]) + .executeTakeFirstOrThrow(); + + expect(user.email_verified_at).not.toBeNull(); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "test@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.confirmEmail, + { email: "TEST@EXAMPLE.COM" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "test@example.com") + .select(["email_verified_at"]) + .executeTakeFirstOrThrow(); + + expect(user.email_verified_at).not.toBeNull(); + }); + }); + + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.confirmEmail, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + + test("succeeds for already verified user (idempotent)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "verified@example.com", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + // Should not throw + const result = await call( + router.admin.users.confirmEmail, + { email: "verified@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); +}); + +// ===== admin.orgs.list ===== + +describe("admin.orgs.list", () => { + test("returns all organizations", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "org-one", displayName: "Org One" }); + await createOrg(db, { slug: "org-two", displayName: "Org Two" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(2); + const slugs = orgs.map((o) => o.slug).sort(); + expect(slugs).toEqual(["org-one", "org-two"]); + }); + }); + + test("returns organizations with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + displayName: "Test Org", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + const org = orgs.find((o) => o.slug === "test-org"); + expect(org).toBeDefined(); + expect(org?.displayName).toBe("Test Org"); + expect(org?.logoUrl).toBe("https://example.com/logo.png"); + expect(org?.createdAt).toBeInstanceOf(Date); + }); + }); + + test("returns empty array when no organizations", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + expect(orgs).toHaveLength(0); + }); + }); +}); + +// ===== admin.orgs.get ===== + +describe("admin.orgs.get", () => { + test("returns organization by slug", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "target-org", + displayName: "Target Organization", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const org = await call( + router.admin.orgs.get, + { slug: "target-org" }, + { context }, + ); + + expect(org.slug).toBe("target-org"); + expect(org.displayName).toBe("Target Organization"); + expect(org.logoUrl).toBe("https://example.com/logo.png"); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.get, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); +}); + +// ===== admin.orgs.create ===== +// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + +describe("admin.orgs.create", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("creates organization with owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.create, + { + slug: `new-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: `owner-${uniqueId}@example.com`, + }, + { context }, + ); + + expect(result.slug).toBe(`new-org-${uniqueId}`); + + // Verify org was created + const org = await db + .selectFrom("orgs") + .where("slug", "=", `new-org-${uniqueId}`) + .selectAll() + .executeTakeFirst(); + + expect(org).toBeDefined(); + expect(org?.display_name).toBe("New Organization"); + + // Verify owner membership + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org!.id) + .where("user_id", "=", owner.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("owner"); + }); + + test("normalizes owner email to lowercase", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.create, + { + slug: `new-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: `OWNER-${uniqueId}@EXAMPLE.COM`, + }, + { context }, + ); + + expect(result.slug).toBe(`new-org-${uniqueId}`); + }); + + test("throws NOT_FOUND for non-existent owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.create, + { + slug: `new-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: "nonexistent@example.com", + }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + + test("throws CONFLICT for duplicate slug", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + await createOrg(db, { slug: `existing-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.create, + { + slug: `existing-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: owner.email, + }, + { context }, + ), + ).rejects.toThrow("Organization with this slug already exists"); + }); +}); + +// ===== admin.orgs.update ===== + +describe("admin.orgs.update", () => { + test("updates display name", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org", displayName: "Old Name" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name"]) + .executeTakeFirstOrThrow(); + + expect(org.display_name).toBe("New Name"); + }); + }); + + test("updates logo URL", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", logoUrl: "https://example.com/newlogo.png" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.logo_url).toBe("https://example.com/newlogo.png"); + }); + }); + + test("clears logo URL with empty string", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", logoUrl: "" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.logo_url).toBeNull(); + }); + }); + + test("updates multiple fields at once", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + displayName: "Old", + logoUrl: null, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { + slug: "test-org", + displayName: "New Name", + logoUrl: "https://example.com/logo.png", + }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name", "logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.display_name).toBe("New Name"); + expect(org.logo_url).toBe("https://example.com/logo.png"); + }); + }); + + test("returns success for no-op update", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.update, + { slug: "test-org" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); + + test("throws NOT_FOUND for no-op on non-existent org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.update, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.update, + { slug: "nonexistent", displayName: "Test" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + }); +}); + +// ===== admin.orgs.delete ===== +// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + +describe("admin.orgs.delete", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("deletes organization and related records", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `delete-me-${uniqueId}` }); + + // Create related records + await addOrgMember(db, org.id, member.id, "owner"); + await createSite(db, org.id, `example-${uniqueId}.com`); + await createOrgInvite(db, org.id, `invite-${uniqueId}@example.com`, admin.id); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.delete, + { slug: `delete-me-${uniqueId}` }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify org is deleted + const deletedOrg = await db + .selectFrom("orgs") + .where("slug", "=", `delete-me-${uniqueId}`) + .selectAll() + .executeTakeFirst(); + expect(deletedOrg).toBeUndefined(); + + // Verify related records are deleted + const members = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + expect(members).toHaveLength(0); + + const sites = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + expect(sites).toHaveLength(0); + + const invites = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + expect(invites).toHaveLength(0); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.delete, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); +}); + +// ===== admin.orgs.listSites ===== + +describe("admin.orgs.listSites", () => { + test("returns sites for organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "example.com"); + await createSite(db, org.id, "test.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "test-org" }, + { context }, + ); + + expect(sites.length).toBe(2); + const domains = sites.map((s) => s.domain).sort(); + expect(domains).toEqual(["example.com", "test.com"]); + }); + }); + + test("returns empty array when no sites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "empty-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "empty-org" }, + { context }, + ); + + expect(sites).toHaveLength(0); + }); + }); + + test("returns sites with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "test-org" }, + { context }, + ); + + expect(sites[0]?.id).toBeDefined(); + expect(sites[0]?.domain).toBe("example.com"); + expect(sites[0]?.createdAt).toBeInstanceOf(Date); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.listSites, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); +}); + +// ===== admin.orgs.addSite ===== +// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + +describe("admin.orgs.addSite", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("adds site to organization", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.addSite, + { slug: `test-org-${uniqueId}`, domain: `newsite-${uniqueId}.com` }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify site was created + const site = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .where("domain", "=", `newsite-${uniqueId}.com`) + .selectAll() + .executeTakeFirst(); + + expect(site).toBeDefined(); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: "nonexistent", domain: `test-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + + test("throws CONFLICT for duplicate domain", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + await createSite(db, org.id, `existing-${uniqueId}.com`); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: `test-org-${uniqueId}`, domain: `existing-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Site with this domain already exists"); + }); + + test("throws CONFLICT for domain in another organization", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org1 = await createOrg(db, { slug: `org-one-${uniqueId}` }); + await createOrg(db, { slug: `org-two-${uniqueId}` }); + await createSite(db, org1.id, `shared-${uniqueId}.com`); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: `org-two-${uniqueId}`, domain: `shared-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Site with this domain already exists"); + }); +}); + +// ===== admin.orgs.removeSite ===== + +describe("admin.orgs.removeSite", () => { + test("removes site from organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "remove-me.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.removeSite, + { slug: "test-org", domain: "remove-me.com" }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify site was removed + const site = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .where("domain", "=", "remove-me.com") + .selectAll() + .executeTakeFirst(); + + expect(site).toBeUndefined(); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "nonexistent", domain: "test.com" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + }); + + test("throws NOT_FOUND for non-existent site", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "test-org", domain: "nonexistent.com" }, + { context }, + ), + ).rejects.toThrow("Site not found"); + }); + }); + + test("throws NOT_FOUND for site in another organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org1 = await createOrg(db, { slug: "org-one" }); + await createOrg(db, { slug: "org-two" }); + await createSite(db, org1.id, "org1-site.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "org-two", domain: "org1-site.com" }, + { context }, + ), + ).rejects.toThrow("Site not found"); + }); + }); +}); + +// ===== admin.auth.completeLogin ===== + +describe("admin.auth.completeLogin", () => { + test("completes pending login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + const loginRequest = await createLoginRequest(db, user.id, "user@example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify login request was completed + const request = await db + .selectFrom("login_requests") + .where("id", "=", String(loginRequest.id)) + .select(["completed_at"]) + .executeTakeFirstOrThrow(); + + expect(request.completed_at).not.toBeNull(); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + const loginRequest = await createLoginRequest(db, user.id, "user@example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.auth.completeLogin, + { email: "USER@EXAMPLE.COM" }, + { context }, + ); + + const request = await db + .selectFrom("login_requests") + .where("id", "=", String(loginRequest.id)) + .select(["completed_at"]) + .executeTakeFirstOrThrow(); + + expect(request.completed_at).not.toBeNull(); + }); + }); + + test("throws NOT_FOUND for no login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "noRequest@example.com" }, + { context }, + ), + ).rejects.toThrow("No login request found"); + }); + }); + + test("throws BAD_REQUEST for already completed request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + await createLoginRequest(db, user.id, "user@example.com", { + completedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ), + ).rejects.toThrow("Login request already completed"); + }); + }); + + test("throws BAD_REQUEST for expired request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + await createLoginRequest(db, user.id, "user@example.com", { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ), + ).rejects.toThrow("Login request expired"); + }); + }); + + test("completes most recent login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + + // Create two login requests + await createLoginRequest(db, user.id, "user@example.com"); + await createLoginRequest(db, user.id, "user@example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ); + + // Check that exactly one login request was completed + // (Note: both requests have the same created_at in transaction, so ORDER BY is non-deterministic) + const allRequests = await db + .selectFrom("login_requests") + .where("email", "=", "user@example.com") + .select(["id", "completed_at"]) + .execute(); + + expect(allRequests.length).toBe(2); + + const completedCount = allRequests.filter((r) => r.completed_at !== null).length; + expect(completedCount).toBe(1); + + const uncompletedCount = allRequests.filter((r) => r.completed_at === null).length; + expect(uncompletedCount).toBe(1); + }); + }); +}); diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts index 3f6f084..64bcc77 100644 --- a/apps/api-server/src/__tests__/e2e/auth.test.ts +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -1595,10 +1595,11 @@ describe("auth.resetPassword", () => { const ctx = createAPIContext(db); + // Password must be >=8 chars (schema) but weak enough to fail zxcvbn (score < 3) await expect( call( router.auth.resetPassword, - { token, newPassword: "weak" }, + { token, newPassword: "password" }, { context: ctx }, ), ).rejects.toThrow(); @@ -2105,3 +2106,61 @@ describe("End-to-end login scenarios", () => { }); }); }); + +// ============================================================================= +// loginRequestMiddleware tests (base.ts) +// ============================================================================= + +describe("loginRequestMiddleware", () => { + test("rejects request with no login request cookie", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + // No login request token in context + const ctx = createAPIContext(db); + + await expect( + call(router.auth.webauthn.createAuthenticationOptions, undefined, { + context: ctx, + }), + ).rejects.toThrow("No login request found"); + }); + }); + + test("rejects request with invalid login request token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + // Invalid token that doesn't exist in DB + const ctx = createAPIContext(db, { + loginRequestToken: "invalid-login-request-token", + }); + + await expect( + call(router.auth.webauthn.createAuthenticationOptions, undefined, { + context: ctx, + }), + ).rejects.toThrow("Login request expired or not found"); + }); + }); + + test("rejects request with expired login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "expiredloginreq@example.com", + }); + + // Create an expired login request + const { token: loginToken } = await createLoginRequest( + db, + user.id, + user.email, + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(db, { loginRequestToken: loginToken }); + + await expect( + call(router.auth.webauthn.createAuthenticationOptions, undefined, { + context: ctx, + }), + ).rejects.toThrow("Login request expired or not found"); + }); + }); +}); diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index 9ead2e3..6780aeb 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -169,6 +169,100 @@ 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-${Date.now()}`; + 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-${Date.now()}`; + 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) => { @@ -1371,3 +1465,769 @@ describe("me.devices.revokeAll", () => { }); }); }); + +// ============================================================================= +// me.apiTokens 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-${String(Date.now())}${String(Math.random())}`; + 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-${String(Date.now())}-${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: Number(result.id) }; +} + +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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + 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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + 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"); + }); + }); +}); diff --git a/apps/api-server/src/__tests__/e2e/orgs.test.ts b/apps/api-server/src/__tests__/e2e/orgs.test.ts new file mode 100644 index 0000000..866c1c4 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/orgs.test.ts @@ -0,0 +1,1568 @@ +/** + * E2E tests for org procedures + * + * Tests all org-related procedures: + * - orgs.list, orgs.create, orgs.get (basic.ts) + * - orgs.update, orgs.delete, orgs.leave (management.ts) + * - orgs.members.list, orgs.members.updateRole, orgs.members.remove (members.ts) + * - orgs.invites.list, orgs.invites.create, orgs.invites.cancel, orgs.invites.accept (invites.ts) + * - orgs.sites.list (sites.ts) + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { call } from "@orpc/server"; +import { router } from "../../router.js"; +import type { APIContext } from "../../context.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; +import { TEST_RP } from "../helpers/test-constants.js"; +import { createTestUser, initTestDb, getSharedDb, truncateAllTables } from "../helpers/test-db.js"; +import { withTestTransaction } from "../helpers/test-transaction.js"; + +/** Session expiry: 30 days in milliseconds */ +const SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; + +// ===== Helper Functions ===== + +/** + * Create an API context for testing + */ +function createAPIContext( + db: Kysely, + options?: { + sessionToken?: string; + }, +): APIContext { + const reqHeaders = new Headers(); + const cookies: string[] = []; + + if (options?.sessionToken) { + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); + } + + return { + db, + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), + }; +} + +/** + * Create a session for a user + */ +async function createSession( + db: Kysely, + userId: number, + options?: { trustedMode?: boolean }, +): Promise<{ token: string; sessionId: number }> { + const token = `test-session-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + 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, + expires_at: expiresAt, + trusted_mode: options?.trustedMode ?? false, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { token, sessionId: Number(result.id) }; +} + +/** + * Create an organization + */ +async function createOrg( + db: Kysely, + data: { + slug: string; + displayName?: string; + logoUrl?: string; + }, +): Promise<{ id: number; slug: string }> { + const result = await db + .insertInto("orgs") + .values({ + slug: data.slug, + display_name: data.displayName ?? data.slug, + logo_url: data.logoUrl ?? null, + }) + .returning(["id", "slug"]) + .executeTakeFirstOrThrow(); + + return { id: result.id, slug: result.slug }; +} + +/** + * Add a member to an organization + */ +async function addOrgMember( + db: Kysely, + orgId: number, + userId: number, + role: "owner" | "admin" | "member" = "member", +): Promise<{ id: number }> { + const result = await db + .insertInto("org_members") + .values({ + org_id: orgId, + user_id: userId, + role, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: result.id }; +} + +/** + * Create a site for an organization + */ +async function createSite( + db: Kysely, + orgId: number, + domain: string, +): Promise<{ id: number }> { + const result = await db + .insertInto("org_sites") + .values({ + org_id: orgId, + domain, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: result.id }; +} + +/** + * Create an organization invite + */ +async function createOrgInvite( + db: Kysely, + orgId: number, + email: string, + invitedBy: number, + options?: { + role?: "owner" | "admin" | "member"; + token?: string; + expiresAt?: Date; + }, +): Promise<{ id: number; token: string }> { + const token = options?.token ?? `invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const expiresAt = options?.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const result = await db + .insertInto("org_invites") + .values({ + org_id: orgId, + email, + role: options?.role ?? "member", + invited_by: invitedBy, + token, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: result.id, token }; +} + +beforeAll(async () => { + await initTestDb(); +}); + +afterAll(async () => { + await truncateAllTables(getSharedDb()); +}); + +// ===== Authorization Tests ===== + +describe("Authorization", () => { + test("rejects unauthenticated request for orgs.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call(router.orgs.list, undefined, { context }), + ).rejects.toThrow("No session or API key"); + }); + }); + + test("rejects unauthenticated request for orgs.create", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call(router.orgs.create, { slug: "test", displayName: "Test" }, { context }), + ).rejects.toThrow("No session or API key"); + }); + }); + + test("rejects unauthenticated request for orgs.get", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call(router.orgs.get, { slug: "test" }, { context }), + ).rejects.toThrow("No session or API key"); + }); + }); +}); + +// ===== orgs.list ===== + +describe("orgs.list", () => { + test("returns empty array when user has no orgs", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs).toHaveLength(0); + }); + }); + + test("returns orgs where user is a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org1 = await createOrg(db, { slug: "org-one", displayName: "Org One" }); + const org2 = await createOrg(db, { slug: "org-two", displayName: "Org Two" }); + await addOrgMember(db, org1.id, user.id, "owner"); + await addOrgMember(db, org2.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(2); + const slugs = orgs.map((o) => o.slug).sort(); + expect(slugs).toEqual(["org-one", "org-two"]); + }); + }); + + test("does not return orgs where user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { email: "other@example.com" }); + const org1 = await createOrg(db, { slug: "my-org" }); + const org2 = await createOrg(db, { slug: "other-org" }); + await addOrgMember(db, org1.id, user.id, "owner"); + await addOrgMember(db, org2.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(1); + expect(orgs[0]?.slug).toBe("my-org"); + }); + }); + + test("returns org details including logoUrl", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { + slug: "test-org", + displayName: "Test Organization", + logoUrl: "https://example.com/logo.png", + }); + await addOrgMember(db, org.id, user.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs[0]?.displayName).toBe("Test Organization"); + expect(orgs[0]?.logoUrl).toBe("https://example.com/logo.png"); + expect(orgs[0]?.createdAt).toBeDefined(); + }); + }); +}); + +// ===== orgs.create ===== + +describe("orgs.create", () => { + test("creates org and makes user owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.orgs.create, + { slug: `new-org-${uniqueId}`, displayName: "New Organization" }, + { context }, + ); + + expect(result.slug).toBe(`new-org-${uniqueId}`); + + // Verify user is owner + const membership = await db + .selectFrom("org_members") + .innerJoin("orgs", "orgs.id", "org_members.org_id") + .where("orgs.slug", "=", `new-org-${uniqueId}`) + .where("org_members.user_id", "=", user.id) + .select(["org_members.role"]) + .executeTakeFirst(); + + expect(membership?.role).toBe("owner"); + }); + + test("rejects duplicate slug", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); + await createOrg(db, { slug: `existing-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.create, + { slug: `existing-${uniqueId}`, displayName: "Duplicate" }, + { context }, + ), + ).rejects.toThrow("Slug already in use"); + }); +}); + +// ===== orgs.get ===== + +describe("orgs.get", () => { + test("returns org when user is a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { + slug: "test-org", + displayName: "Test Org", + logoUrl: "https://example.com/logo.png", + }); + await addOrgMember(db, org.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call(router.orgs.get, { slug: "test-org" }, { context }); + + expect(result.slug).toBe("test-org"); + expect(result.displayName).toBe("Test Org"); + expect(result.logoUrl).toBe("https://example.com/logo.png"); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { email: "other@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.get, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + + test("rejects when org does not exist", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.get, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); +}); + +// ===== orgs.update ===== + +describe("orgs.update", () => { + test("updates display name when user is admin", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { slug: "test-org", displayName: "Old Name" }); + await addOrgMember(db, org.id, user.id, "admin"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ); + + const updated = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name"]) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("New Name"); + }); + }); + + test("updates logo URL when user is owner", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, user.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.update, + { slug: "test-org", logoUrl: "https://example.com/new-logo.png" }, + { context }, + ); + + const updated = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(updated.logo_url).toBe("https://example.com/new-logo.png"); + }); + }); + + test("rejects when user is only a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { email: "other@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); +}); + +// ===== orgs.delete ===== + +describe("orgs.delete", () => { + test("deletes org when user is owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `delete-org-${uniqueId}` }); + await addOrgMember(db, org.id, user.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.orgs.delete, { slug: `delete-org-${uniqueId}` }, { context }); + + const deleted = await db + .selectFrom("orgs") + .where("slug", "=", `delete-org-${uniqueId}`) + .selectAll() + .executeTakeFirst(); + + expect(deleted).toBeUndefined(); + }); + + test("rejects when user is admin (not owner)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, user.id, "admin"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.delete, { slug: "test-org" }, { context }), + ).rejects.toThrow("Insufficient permissions"); + }); + }); +}); + +// ===== orgs.leave ===== + +describe("orgs.leave", () => { + test("allows member to leave org", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.orgs.leave, { slug: `leave-org-${uniqueId}` }, { context }); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("allows owner to leave when there are other owners", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); + const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.orgs.leave, { slug: `leave-org-${uniqueId}` }, { context }); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner1.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("prevents only owner from leaving", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `leave-only-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.leave, { slug: `leave-only-owner-${uniqueId}` }, { context }), + ).rejects.toThrow("Cannot leave as the only owner"); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.leave, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); +}); + +// ===== orgs.members.list ===== + +describe("orgs.members.list", () => { + test("returns all members of org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com", displayName: "Owner" }); + const admin = await createTestUser(db, { email: "admin@example.com", displayName: "Admin" }); + const member = await createTestUser(db, { email: "member@example.com", displayName: "Member" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const members = await call(router.orgs.members.list, { slug: "test-org" }, { context }); + + expect(members.length).toBe(3); + expect(members.map((m) => m.role).sort()).toEqual(["admin", "member", "owner"]); + }); + }); + + test("includes user details", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com", displayName: "Test Owner" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + const members = await call(router.orgs.members.list, { slug: "test-org" }, { context }); + + expect(members[0]?.email).toBe("owner@example.com"); + expect(members[0]?.displayName).toBe("Test Owner"); + expect(members[0]?.userId).toBe(owner.id); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.members.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); +}); + +// ===== orgs.members.updateRole ===== + +describe("orgs.members.updateRole", () => { + test("owner can promote member to admin", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: member.id, role: "admin" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("admin"); + }); + + test("owner can promote member to owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: member.id, role: "owner" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("owner"); + }); + + test("owner can demote owner to admin when multiple owners exist", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); + const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: owner2.id, role: "admin" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner2.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("admin"); + }); + + test("prevents demoting the only owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: owner.id, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Cannot demote the only owner"); + }); + + test("admin cannot update roles", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const admin = await createTestUser(db, { email: "admin@example.com" }); + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: "test-org", userId: member.id, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("rejects when target member not found", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: 999999, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Member not found"); + }); +}); + +// ===== orgs.members.remove ===== + +describe("orgs.members.remove", () => { + test("owner can remove member", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `remove-member-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-member-${uniqueId}`, userId: member.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("owner can remove admin", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `remove-admin-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-admin-${uniqueId}`, userId: admin.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", admin.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("owner can remove other owner when multiple owners exist", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); + const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `remove-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-owner-${uniqueId}`, userId: owner2.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner2.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("prevents removing the only owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `remove-only-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `remove-only-owner-${uniqueId}`, userId: owner.id }, + { context }, + ), + ).rejects.toThrow("Cannot remove the only owner"); + }); + + test("admin can remove member", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `admin-remove-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `admin-remove-${uniqueId}`, userId: member.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("admin cannot remove owner", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `admin-no-remove-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `admin-no-remove-owner-${uniqueId}`, userId: owner.id }, + { context }, + ), + ).rejects.toThrow("Admins can only remove members"); + }); + + test("admin cannot remove other admin", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const admin1 = await createTestUser(db, { email: `admin1-${uniqueId}@example.com` }); + const admin2 = await createTestUser(db, { email: `admin2-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `admin-no-remove-admin-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin1.id, "admin"); + await addOrgMember(db, org.id, admin2.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin1.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `admin-no-remove-admin-${uniqueId}`, userId: admin2.id }, + { context }, + ), + ).rejects.toThrow("Admins can only remove members"); + }); + + test("member cannot remove anyone", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member1 = await createTestUser(db, { email: `member1-${uniqueId}@example.com` }); + const member2 = await createTestUser(db, { email: `member2-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `member-no-remove-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member1.id, "member"); + await addOrgMember(db, org.id, member2.id, "member"); + + const { token: sessionToken } = await createSession(db, member1.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `member-no-remove-${uniqueId}`, userId: member2.id }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + + test("rejects when target member not found", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `remove-not-found-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `remove-not-found-${uniqueId}`, userId: 999999 }, + { context }, + ), + ).rejects.toThrow("Member not found"); + }); +}); + +// ===== orgs.invites.list ===== + +describe("orgs.invites.list", () => { + test("returns pending invites for org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com", displayName: "Admin User" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + await createOrgInvite(db, org.id, "invite1@example.com", admin.id, { role: "member" }); + await createOrgInvite(db, org.id, "invite2@example.com", admin.id, { role: "admin" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call(router.orgs.invites.list, { slug: "test-org" }, { context }); + + expect(invites.length).toBe(2); + expect(invites.map((i) => i.email).sort()).toEqual(["invite1@example.com", "invite2@example.com"]); + }); + }); + + test("does not return expired invites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + await createOrgInvite(db, org.id, "active@example.com", admin.id); + await createOrgInvite(db, org.id, "expired@example.com", admin.id, { + expiresAt: new Date(Date.now() - 1000), // expired + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call(router.orgs.invites.list, { slug: "test-org" }, { context }); + + expect(invites.length).toBe(1); + expect(invites[0]?.email).toBe("active@example.com"); + }); + }); + + test("member cannot list invites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("Insufficient permissions"); + }); + }); +}); + +// ===== orgs.invites.create ===== + +describe("orgs.invites.create", () => { + test("admin can create member invite", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "member" }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite).toBeDefined(); + expect(invite?.role).toBe("member"); + }); + + test("admin can create admin invite", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "admin" }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite?.role).toBe("admin"); + }); + + test("admin cannot create owner invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "new@example.com", role: "owner" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("owner can create owner invite", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "owner" }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite?.role).toBe("owner"); + }); + + test("rejects invite for existing member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "member@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("This user is already a member of the organization"); + }); + }); + + test("rejects duplicate pending invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await createOrgInvite(db, org.id, "invited@example.com", owner.id); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "invited@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("An invitation is already pending for this email"); + }); + }); + + test("member cannot create invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "new@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); +}); + +// ===== orgs.invites.cancel ===== + +describe("orgs.invites.cancel", () => { + test("admin can cancel invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + const invite = await createOrgInvite(db, org.id, "invited@example.com", admin.id); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: invite.id }, + { context }, + ); + + const deleted = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deleted).toBeUndefined(); + }); + }); + + test("rejects cancel for nonexistent invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: 999999 }, + { context }, + ), + ).rejects.toThrow("Invitation not found"); + }); + }); + + test("member cannot cancel invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + const invite = await createOrgInvite(db, org.id, "invited@example.com", owner.id); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: invite.id }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); +}); + +// ===== orgs.invites.accept ===== + +describe("orgs.invites.accept", () => { + test("accepts invite and adds user to org", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const invitee = await createTestUser(db, { email: `invitee-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `accept-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite(db, org.id, `invitee-${uniqueId}@example.com`, owner.id, { role: "admin" }); + + const { token: sessionToken } = await createSession(db, invitee.id); + const context = createAPIContext(db, { sessionToken }); + + await call(router.orgs.invites.accept, { token: invite.token }, { context }); + + // Verify membership + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", invitee.id) + .select(["role"]) + .executeTakeFirst(); + + expect(membership?.role).toBe("admin"); + + // Verify invite deleted + const deletedInvite = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deletedInvite).toBeUndefined(); + }); + + test("rejects expired invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const invitee = await createTestUser(db, { email: "invitee@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite(db, org.id, "invitee@example.com", owner.id, { + expiresAt: new Date(Date.now() - 1000), // expired + }); + + const { token: sessionToken } = await createSession(db, invitee.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.accept, { token: invite.token }, { context }), + ).rejects.toThrow("Invalid or expired invitation"); + }); + }); + + test("rejects invalid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.accept, { token: "invalid-token" }, { context }), + ).rejects.toThrow("Invalid or expired invitation"); + }); + }); + + test("rejects when email doesn't match", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const invitee = await createTestUser(db, { email: "invitee@example.com" }); + const wrongUser = await createTestUser(db, { email: "wrong@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite(db, org.id, "invitee@example.com", owner.id); + + const { token: sessionToken } = await createSession(db, wrongUser.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.accept, { token: invite.token }, { context }), + ).rejects.toThrow("This invitation was sent to a different email address"); + }); + }); + + test("handles already a member gracefully", async () => { + const db = getSharedDb(); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + const invite = await createOrgInvite(db, org.id, `member-${uniqueId}@example.com`, owner.id); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.accept, { token: invite.token }, { context }), + ).rejects.toThrow("You are already a member of this organization"); + + // Invite should be cleaned up + const deletedInvite = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deletedInvite).toBeUndefined(); + }); +}); + +// ===== orgs.sites.list ===== + +describe("orgs.sites.list", () => { + test("returns sites for org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); + await createSite(db, org.id, "example.com"); + await createSite(db, org.id, "test.com"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); + + expect(sites.length).toBe(2); + expect(sites.map((s) => s.domain).sort()).toEqual(["example.com", "test.com"]); + }); + }); + + test("returns empty array when no sites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); + + expect(sites).toHaveLength(0); + }); + }); + + test("returns site details including id and createdAt", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { email: "member@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); + await createSite(db, org.id, "example.com"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); + + expect(sites[0]?.id).toBeDefined(); + expect(sites[0]?.domain).toBe("example.com"); + expect(sites[0]?.createdAt).toBeDefined(); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.sites.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + + test("rejects when org not found", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.sites.list, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); +}); diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index b2efabf..10331ac 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -52,17 +52,26 @@ export async function signupWithPassword( // Hash password const passwordHash = await hashPassword(password); - // Create user - const user = await db - .insertInto("users") - .values({ - email, - password_hash: passwordHash, - }) - .returning(["id"]) - .executeTakeFirstOrThrow(); + // Create user (handle race condition if concurrent signup with same email) + try { + const user = await db + .insertInto("users") + .values({ + email, + password_hash: passwordHash, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); - return user.id; + return user.id; + } catch (error) { + // Handle duplicate email (unique constraint violation) + // Use generic error to prevent email enumeration + if (error instanceof Error && error.message.includes("users_email_key")) { + throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" }); + } + throw error; + } } /** @@ -146,55 +155,64 @@ export async function signupWithPasskey( }); } - // Create user and passkey in a transaction - const result = await db.transaction().execute(async (trx) => { - // Create user - const user = await trx - .insertInto("users") - .values({ - email, - password_hash: null, - }) - .returning(["id"]) - .executeTakeFirstOrThrow(); + // Create user and passkey in a transaction (handle race condition if concurrent signup) + try { + const result = await db.transaction().execute(async (trx) => { + // Create user + const user = await trx + .insertInto("users") + .values({ + email, + password_hash: null, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); - const newUserId = user.id; + const newUserId = user.id; - // Get friendly name from AAGUID - const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; - const passkeyName = guidName ?? "Default"; + // Get friendly name from AAGUID + const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; + const passkeyName = guidName ?? "Default"; - // Store the passkey - const { credential, credentialDeviceType, credentialBackedUp } = - registrationInfo; + // Store the passkey + const { credential, credentialDeviceType, credentialBackedUp } = + registrationInfo; - await trx - .insertInto("passkeys") - .values({ - user_id: newUserId, - credential_id: Buffer.from(credential.id, "base64url"), - public_key: Buffer.from(credential.publicKey), - webauthn_user_id: options.user.id, - counter: BigInt(credential.counter), - device_type: credentialDeviceType as "singleDevice" | "multiDevice", - backup_eligible: registrationInfo.credentialBackedUp, - backup_status: credentialBackedUp, - transports: JSON.stringify(response.response.transports ?? []), - rpid: rpInfo.rpID, - name: passkeyName, - }) - .execute(); + await trx + .insertInto("passkeys") + .values({ + user_id: newUserId, + credential_id: Buffer.from(credential.id, "base64url"), + public_key: Buffer.from(credential.publicKey), + webauthn_user_id: options.user.id, + counter: BigInt(credential.counter), + device_type: credentialDeviceType as "singleDevice" | "multiDevice", + backup_eligible: registrationInfo.credentialBackedUp, + backup_status: credentialBackedUp, + transports: JSON.stringify(response.response.transports ?? []), + rpid: rpInfo.rpID, + name: passkeyName, + }) + .execute(); - // Delete the challenge - await trx - .deleteFrom("webauthn_challenges") - .where("id", "=", String(challengeId)) - .execute(); + // Delete the challenge + await trx + .deleteFrom("webauthn_challenges") + .where("id", "=", String(challengeId)) + .execute(); - return { userId: newUserId }; - }); + return { userId: newUserId }; + }); - return result.userId; + return result.userId; + } catch (error) { + // Handle duplicate email (unique constraint violation) + // Use generic error to prevent email enumeration + if (error instanceof Error && error.message.includes("users_email_key")) { + throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" }); + } + throw error; + } } /** @@ -241,7 +259,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { ); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); } else { - // Should never reach here due to schema validation + // Unreachable - schema validation requires password or passkeyInfo throw new ORPCError("BAD_REQUEST", { message: "Either password or passkeyInfo is required", }); diff --git a/apps/api-server/src/procedures/orgs/helpers.ts b/apps/api-server/src/procedures/orgs/helpers.ts index e3f71cd..ef9b54d 100644 --- a/apps/api-server/src/procedures/orgs/helpers.ts +++ b/apps/api-server/src/procedures/orgs/helpers.ts @@ -120,5 +120,6 @@ export async function countOwners( .where("role", "=", "owner") .executeTakeFirstOrThrow(); - return result.count; + // PostgreSQL COUNT returns bigint which may be a string; ensure numeric comparison works + return Number(result.count); } diff --git a/db/schema.sql b/db/schema.sql index 6be1a60..c4b3633 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap +\restrict ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA -- 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 ib0L1tt0kcihJbP9aADEOXJtCsMf5T4lIJeG6jvjTT1gyQCoWtfbB5Qc1NLXCOA -- From b2fba6e150f99ea1158e0a3a92106521828cdb08 Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 13:03:41 +0800 Subject: [PATCH 2/3] Add test infrastructure with coverage and DB test skipping - Create @reviq/test-helpers package with shared test utilities - Add describeE2E helper that auto-prefixes test names with [e2e] - Support SKIP_DB_TESTS=1 to skip database-dependent tests - Add unix socket support for TEST_DATABASE_URL - Add root commands: test:unit, test:all, test:cov, test:unit:cov - Configure bunfig.toml to exclude dist/ from coverage reports - Clean up tsconfig.json files to remove redundant settings Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 7 +- .../api-server/src/__tests__/e2e/auth.test.ts | 3355 +++++++++-------- apps/api-server/src/__tests__/e2e/me.test.ts | 2202 +++++------ .../src/__tests__/e2e/webauthn.test.ts | 1787 ++++----- apps/cli/package.json | 2 +- bun.lock | 25 +- bunfig.toml | 5 + db/schema.sql | 4 +- package.json | 4 + packages/api-contract/package.json | 2 +- packages/common/package.json | 2 +- packages/db-schema/tsconfig.json | 8 - packages/db/tsconfig.json | 7 - .../testing/test-helpers/eslint.config.js | 12 + packages/testing/test-helpers/package.json | 33 + packages/testing/test-helpers/src/index.ts | 18 + .../testing/test-helpers/src/skip-db-tests.ts | 18 + .../test-helpers/src}/test-constants.ts | 0 .../testing/test-helpers/src}/test-db.ts | 21 +- .../test-helpers/src}/test-transaction.ts | 0 packages/testing/test-helpers/tsconfig.json | 6 + .../virtual-authenticator/package.json | 11 +- .../virtual-authenticator/tsconfig.json | 8 - packages/utils/package.json | 2 +- turbo.json | 3 +- 25 files changed, 3854 insertions(+), 3688 deletions(-) create mode 100644 bunfig.toml create mode 100644 packages/testing/test-helpers/eslint.config.js create mode 100644 packages/testing/test-helpers/package.json create mode 100644 packages/testing/test-helpers/src/index.ts create mode 100644 packages/testing/test-helpers/src/skip-db-tests.ts rename {apps/api-server/src/__tests__/helpers => packages/testing/test-helpers/src}/test-constants.ts (100%) rename {apps/api-server/src/__tests__/helpers => packages/testing/test-helpers/src}/test-db.ts (90%) rename {apps/api-server/src/__tests__/helpers => packages/testing/test-helpers/src}/test-transaction.ts (100%) create mode 100644 packages/testing/test-helpers/tsconfig.json 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 } } From 6fa4da1abb3621e71a01d8014fb70b05636015dd Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 13:40:06 +0800 Subject: [PATCH 3/3] Fix lint errors and add ast-grep rule for countAll - Fix template literal expressions: wrap Date.now() in String() - Add missing afterAll import in admin.test.ts - Fix countOwners to use countAll() without misleading type - Add ast-grep rule to prevent countAll() usage - Fix formatting issues from merge conflict resolution Co-Authored-By: Claude Opus 4.5 --- .ast-grep/rules/no-countall-number.yml | 8 + .../src/__tests__/e2e/admin.test.ts | 3156 +++++++++-------- apps/api-server/src/__tests__/e2e/me.test.ts | 1264 ++++--- .../api-server/src/__tests__/e2e/orgs.test.ts | 2858 ++++++++------- apps/api-server/src/procedures/auth/signup.ts | 8 +- .../api-server/src/procedures/orgs/helpers.ts | 4 +- db/schema.sql | 4 +- sgconfig.yml | 6 +- 8 files changed, 3866 insertions(+), 3442 deletions(-) create mode 100644 .ast-grep/rules/no-countall-number.yml diff --git a/.ast-grep/rules/no-countall-number.yml b/.ast-grep/rules/no-countall-number.yml new file mode 100644 index 0000000..3835ee8 --- /dev/null +++ b/.ast-grep/rules/no-countall-number.yml @@ -0,0 +1,8 @@ +id: no-countall-number +language: typescript +severity: error +message: "Don't use countAll() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading." +note: "Use Number() to convert the result if you need a number type." +rule: + pattern: countAll() +fix: countAll() diff --git a/apps/api-server/src/__tests__/e2e/admin.test.ts b/apps/api-server/src/__tests__/e2e/admin.test.ts index 4be9d59..6221c2e 100644 --- a/apps/api-server/src/__tests__/e2e/admin.test.ts +++ b/apps/api-server/src/__tests__/e2e/admin.test.ts @@ -27,7 +27,7 @@ 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 { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { createTestUser, @@ -115,7 +115,9 @@ async function createOrg( logoUrl?: string; }, ): Promise<{ id: number; slug: string }> { - const slug = options?.slug ?? `org-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`; + const slug = + options?.slug ?? + `org-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`; const result = await db .insertInto("orgs") @@ -182,7 +184,8 @@ async function createLoginRequest( }, ): Promise<{ id: number; token: string }> { const token = `login-${String(Date.now())}${String(Math.random())}`; - const expiresAt = options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); const result = await db .insertInto("login_requests") @@ -225,7 +228,7 @@ async function createOrgInvite( .returning("id") .executeTakeFirstOrThrow(); - return { id: Number(result.id) }; + return { id: result.id }; } describeE2E("admin", () => { @@ -238,564 +241,406 @@ describeE2E("admin", () => { // ===== Authorization Tests ===== describe("admin authorization", () => { - test("rejects non-superuser for admin.users.list", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "regular@example.com", - isSuperuser: false, + test("rejects non-superuser for admin.users.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "regular@example.com", + isSuperuser: false, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.users.list, undefined, { context }), + ).rejects.toThrow("Superuser access required"); }); + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + test("rejects unauthenticated request for admin.orgs.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); - await expect( - call(router.admin.users.list, undefined, { context }), - ).rejects.toThrow("Superuser access required"); + await expect( + call(router.admin.orgs.list, undefined, { context }), + ).rejects.toThrow("No session or API key"); + }); }); }); - test("rejects unauthenticated request for admin.orgs.list", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const context = createAPIContext(db); + // ===== admin.users.list ===== - await expect( - call(router.admin.orgs.list, undefined, { context }), - ).rejects.toThrow("No session or API key"); - }); - }); -}); + describe("admin.users.list", () => { + test("returns all users", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "user1@example.com" }); + await createTestUser(db, { email: "user2@example.com" }); -// ===== admin.users.list ===== + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); -describe("admin.users.list", () => { - test("returns all users", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, + const users = await call(router.admin.users.list, undefined, { + context, + }); + + expect(users.length).toBe(3); + const emails = users.map((u) => u.email).sort(); + expect(emails).toContain("admin@example.com"); + expect(emails).toContain("user1@example.com"); + expect(emails).toContain("user2@example.com"); }); - await createTestUser(db, { email: "user1@example.com" }); - await createTestUser(db, { email: "user2@example.com" }); + }); - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + test("returns users with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + displayName: "Admin User", + fullName: "Admin Full Name", + emailVerifiedAt: new Date(), + }); - const users = await call(router.admin.users.list, undefined, { context }); + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); - expect(users.length).toBe(3); - const emails = users.map((u) => u.email).sort(); - expect(emails).toContain("admin@example.com"); - expect(emails).toContain("user1@example.com"); - expect(emails).toContain("user2@example.com"); + const users = await call(router.admin.users.list, undefined, { + context, + }); + + const adminUser = users.find((u) => u.email === "admin@example.com"); + expect(adminUser).toBeDefined(); + expect(adminUser?.displayName).toBe("Admin User"); + expect(adminUser?.fullName).toBe("Admin Full Name"); + expect(adminUser?.emailVerified).toBe(true); + expect(adminUser?.isSuperuser).toBe(true); + expect(adminUser?.needsSetup).toBe(false); + }); + }); + + test("returns empty array when no users", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + // Create only the admin user + const admin = await createTestUser(db, { + email: "onlyadmin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const users = await call(router.admin.users.list, undefined, { + context, + }); + + // Only the admin user exists + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("onlyadmin@example.com"); + }); }); }); - test("returns users with correct fields", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - displayName: "Admin User", - fullName: "Admin Full Name", - emailVerifiedAt: new Date(), - }); + // ===== admin.users.get ===== - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + describe("admin.users.get", () => { + test("returns user by email", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "target@example.com", + displayName: "Target User", + fullName: "Target Full", + }); - const users = await call(router.admin.users.list, undefined, { context }); + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); - const adminUser = users.find((u) => u.email === "admin@example.com"); - expect(adminUser).toBeDefined(); - expect(adminUser?.displayName).toBe("Admin User"); - expect(adminUser?.fullName).toBe("Admin Full Name"); - expect(adminUser?.emailVerified).toBe(true); - expect(adminUser?.isSuperuser).toBe(true); - expect(adminUser?.needsSetup).toBe(false); - }); - }); - - test("returns empty array when no users", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - // Create only the admin user - const admin = await createTestUser(db, { - email: "onlyadmin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const users = await call(router.admin.users.list, undefined, { context }); - - // Only the admin user exists - expect(users.length).toBe(1); - expect(users[0]?.email).toBe("onlyadmin@example.com"); - }); - }); -}); - -// ===== admin.users.get ===== - -describe("admin.users.get", () => { - test("returns user by email", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { - email: "target@example.com", - displayName: "Target User", - fullName: "Target Full", - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const user = await call( - router.admin.users.get, - { email: "target@example.com" }, - { context }, - ); - - expect(user.email).toBe("target@example.com"); - expect(user.displayName).toBe("Target User"); - expect(user.fullName).toBe("Target Full"); - }); - }); - - test("normalizes email to lowercase", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { email: "test@example.com" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const user = await call( - router.admin.users.get, - { email: "TEST@EXAMPLE.COM" }, - { context }, - ); - - expect(user.email).toBe("test@example.com"); - }); - }); - - test("throws NOT_FOUND for non-existent user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( + const user = await call( router.admin.users.get, - { email: "nonexistent@example.com" }, + { email: "target@example.com" }, { context }, - ), - ).rejects.toThrow("User not found"); - }); - }); + ); - test("returns correct hasPassword and needsSetup flags", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, + expect(user.email).toBe("target@example.com"); + expect(user.displayName).toBe("Target User"); + expect(user.fullName).toBe("Target Full"); }); - - // User without display name (needs setup) - await createTestUser(db, { - email: "nosetup@example.com", - displayName: undefined, - }); - - // Set display_name to null - await db - .updateTable("users") - .set({ display_name: null }) - .where("email", "=", "nosetup@example.com") - .execute(); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const user = await call( - router.admin.users.get, - { email: "nosetup@example.com" }, - { context }, - ); - - expect(user.needsSetup).toBe(true); - expect(user.hasPassword).toBe(false); - }); - }); -}); - -// ===== admin.users.create ===== -// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally - -describe("admin.users.create", () => { - afterAll(async () => { - // Clean up all test data - await truncateAllTables(getSharedDb()); - }); - - test("creates passwordless user", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, }); - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "test@example.com" }); - const result = await call( - router.admin.users.create, - { email: `newuser-${uniqueId}@example.com` }, - { context }, - ); + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); - expect(result.success).toBe(true); - - // Verify user was created - const user = await db - .selectFrom("users") - .where("email", "=", `newuser-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(user).toBeDefined(); - expect(user?.password_hash).toBeNull(); - }); - - test("creates user with name", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.create, - { email: `named-${uniqueId}@example.com`, name: "Named User" }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", `named-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(user?.display_name).toBe("Named User"); - }); - - test("creates user and adds to organization as member", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.create, - { email: `orguser-${uniqueId}@example.com`, orgSlug: `test-org-${uniqueId}` }, - { context }, - ); - - // Verify org membership - const membership = await db - .selectFrom("org_members") - .innerJoin("users", "users.id", "org_members.user_id") - .where("users.email", "=", `orguser-${uniqueId}@example.com`) - .where("org_members.org_id", "=", org.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeDefined(); - expect(membership?.role).toBe("member"); - }); - - test("creates user and adds to organization with custom role", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.create, - { email: `adminuser-${uniqueId}@example.com`, orgSlug: `test-org-${uniqueId}`, orgRole: "admin" }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .innerJoin("users", "users.id", "org_members.user_id") - .where("users.email", "=", `adminuser-${uniqueId}@example.com`) - .where("org_members.org_id", "=", org.id) - .selectAll() - .executeTakeFirst(); - - expect(membership?.role).toBe("admin"); - }); - - test("normalizes email to lowercase", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.create, - { email: `UPPERCASE-${uniqueId}@EXAMPLE.COM` }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", `uppercase-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(user).toBeDefined(); - }); - - test("throws CONFLICT for duplicate email", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - await createTestUser(db, { email: `existing-${uniqueId}@example.com` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.users.create, - { email: `existing-${uniqueId}@example.com` }, - { context }, - ), - ).rejects.toThrow("User with this email already exists"); - }); - - test("throws NOT_FOUND for non-existent org", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.users.create, - { email: `newuser-${uniqueId}@example.com`, orgSlug: "nonexistent-org" }, - { context }, - ), - ).rejects.toThrow("Organization not found"); - }); -}); - -// ===== admin.users.update ===== - -describe("admin.users.update", () => { - test("grants superuser status", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { - email: "regular@example.com", - isSuperuser: false, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.update, - { email: "regular@example.com", isSuperuser: true }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", "regular@example.com") - .select(["is_superuser"]) - .executeTakeFirstOrThrow(); - - expect(user.is_superuser).toBe(true); - }); - }); - - test("revokes superuser status from another user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { - email: "otheradmin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.update, - { email: "otheradmin@example.com", isSuperuser: false }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", "otheradmin@example.com") - .select(["is_superuser"]) - .executeTakeFirstOrThrow(); - - expect(user.is_superuser).toBe(false); - }); - }); - - test("prevents self-demotion", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.users.update, - { email: "admin@example.com", isSuperuser: false }, + const user = await call( + router.admin.users.get, + { email: "TEST@EXAMPLE.COM" }, { context }, - ), - ).rejects.toThrow("Cannot remove your own superuser status"); - }); - }); + ); - test("normalizes email to lowercase", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, + expect(user.email).toBe("test@example.com"); }); - await createTestUser(db, { email: "target@example.com" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.update, - { email: "TARGET@EXAMPLE.COM", isSuperuser: true }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", "target@example.com") - .select(["is_superuser"]) - .executeTakeFirstOrThrow(); - - expect(user.is_superuser).toBe(true); }); - }); - test("throws NOT_FOUND for non-existent user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.get, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); }); + }); - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + test("returns correct hasPassword and needsSetup flags", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); - await expect( - call( - router.admin.users.update, - { email: "nonexistent@example.com", isSuperuser: true }, + // User without display name (needs setup) + await createTestUser(db, { + email: "nosetup@example.com", + displayName: undefined, + }); + + // Set display_name to null + await db + .updateTable("users") + .set({ display_name: null }) + .where("email", "=", "nosetup@example.com") + .execute(); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const user = await call( + router.admin.users.get, + { email: "nosetup@example.com" }, { context }, - ), - ).rejects.toThrow("User not found"); + ); + + expect(user.needsSetup).toBe(true); + expect(user.hasPassword).toBe(false); + }); }); }); - test("returns success for no-op update (no fields to update)", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + // ===== admin.users.create ===== + // NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + + describe("admin.users.create", () => { + afterAll(async () => { + // Clean up all test data + await truncateAllTables(getSharedDb()); + }); + + test("creates passwordless user", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const admin = await createTestUser(db, { - email: "admin@example.com", + email: `admin-${uniqueId}@example.com`, isSuperuser: true, }); - await createTestUser(db, { email: "target@example.com" }); const { token: sessionToken } = await createSession(db, admin.id); const context = createAPIContext(db, { sessionToken }); const result = await call( - router.admin.users.update, - { email: "target@example.com" }, + router.admin.users.create, + { email: `newuser-${uniqueId}@example.com` }, { context }, ); expect(result.success).toBe(true); - }); - }); - test("throws NOT_FOUND for no-op update on non-existent user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + // Verify user was created + const user = await db + .selectFrom("users") + .where("email", "=", `newuser-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).toBeNull(); + }); + + test("creates user with name", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const admin = await createTestUser(db, { - email: "admin@example.com", + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `named-${uniqueId}@example.com`, name: "Named User" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", `named-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user?.display_name).toBe("Named User"); + }); + + test("creates user and adds to organization as member", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { + email: `orguser-${uniqueId}@example.com`, + orgSlug: `test-org-${uniqueId}`, + }, + { context }, + ); + + // Verify org membership + const membership = await db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("users.email", "=", `orguser-${uniqueId}@example.com`) + .where("org_members.org_id", "=", org.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("member"); + }); + + test("creates user and adds to organization with custom role", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { + email: `adminuser-${uniqueId}@example.com`, + orgSlug: `test-org-${uniqueId}`, + orgRole: "admin", + }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("users.email", "=", `adminuser-${uniqueId}@example.com`) + .where("org_members.org_id", "=", org.id) + .selectAll() + .executeTakeFirst(); + + expect(membership?.role).toBe("admin"); + }); + + test("normalizes email to lowercase", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.create, + { email: `UPPERCASE-${uniqueId}@EXAMPLE.COM` }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", `uppercase-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(user).toBeDefined(); + }); + + test("throws CONFLICT for duplicate email", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + await createTestUser(db, { email: `existing-${uniqueId}@example.com` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.create, + { email: `existing-${uniqueId}@example.com` }, + { context }, + ), + ).rejects.toThrow("User with this email already exists"); + }); + + test("throws NOT_FOUND for non-existent org", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, isSuperuser: true, }); @@ -804,1098 +649,1299 @@ describe("admin.users.update", () => { await expect( call( - router.admin.users.update, - { email: "nonexistent@example.com" }, + router.admin.users.create, + { + email: `newuser-${uniqueId}@example.com`, + orgSlug: "nonexistent-org", + }, { context }, ), - ).rejects.toThrow("User not found"); - }); - }); -}); - -// ===== admin.users.confirmEmail ===== - -describe("admin.users.confirmEmail", () => { - test("confirms user email", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { - email: "unverified@example.com", - emailVerifiedAt: undefined, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.confirmEmail, - { email: "unverified@example.com" }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", "unverified@example.com") - .select(["email_verified_at"]) - .executeTakeFirstOrThrow(); - - expect(user.email_verified_at).not.toBeNull(); - }); - }); - - test("normalizes email to lowercase", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { email: "test@example.com" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.users.confirmEmail, - { email: "TEST@EXAMPLE.COM" }, - { context }, - ); - - const user = await db - .selectFrom("users") - .where("email", "=", "test@example.com") - .select(["email_verified_at"]) - .executeTakeFirstOrThrow(); - - expect(user.email_verified_at).not.toBeNull(); - }); - }); - - test("throws NOT_FOUND for non-existent user", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.users.confirmEmail, - { email: "nonexistent@example.com" }, - { context }, - ), - ).rejects.toThrow("User not found"); - }); - }); - - test("succeeds for already verified user (idempotent)", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createTestUser(db, { - email: "verified@example.com", - emailVerifiedAt: new Date(), - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - // Should not throw - const result = await call( - router.admin.users.confirmEmail, - { email: "verified@example.com" }, - { context }, - ); - - expect(result.success).toBe(true); - }); - }); -}); - -// ===== admin.orgs.list ===== - -describe("admin.orgs.list", () => { - test("returns all organizations", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "org-one", displayName: "Org One" }); - await createOrg(db, { slug: "org-two", displayName: "Org Two" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.admin.orgs.list, undefined, { context }); - - expect(orgs.length).toBe(2); - const slugs = orgs.map((o) => o.slug).sort(); - expect(slugs).toEqual(["org-one", "org-two"]); - }); - }); - - test("returns organizations with correct fields", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { - slug: "test-org", - displayName: "Test Org", - logoUrl: "https://example.com/logo.png", - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.admin.orgs.list, undefined, { context }); - - const org = orgs.find((o) => o.slug === "test-org"); - expect(org).toBeDefined(); - expect(org?.displayName).toBe("Test Org"); - expect(org?.logoUrl).toBe("https://example.com/logo.png"); - expect(org?.createdAt).toBeInstanceOf(Date); - }); - }); - - test("returns empty array when no organizations", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.admin.orgs.list, undefined, { context }); - - expect(orgs).toHaveLength(0); - }); - }); -}); - -// ===== admin.orgs.get ===== - -describe("admin.orgs.get", () => { - test("returns organization by slug", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { - slug: "target-org", - displayName: "Target Organization", - logoUrl: "https://example.com/logo.png", - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const org = await call( - router.admin.orgs.get, - { slug: "target-org" }, - { context }, - ); - - expect(org.slug).toBe("target-org"); - expect(org.displayName).toBe("Target Organization"); - expect(org.logoUrl).toBe("https://example.com/logo.png"); - }); - }); - - test("throws NOT_FOUND for non-existent organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.admin.orgs.get, { slug: "nonexistent" }, { context }), ).rejects.toThrow("Organization not found"); }); }); -}); -// ===== admin.orgs.create ===== -// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + // ===== admin.users.update ===== -describe("admin.orgs.create", () => { - afterAll(async () => { - await truncateAllTables(getSharedDb()); - }); + describe("admin.users.update", () => { + test("grants superuser status", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "regular@example.com", + isSuperuser: false, + }); - test("creates organization with owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); + await call( + router.admin.users.update, + { email: "regular@example.com", isSuperuser: true }, + { context }, + ); - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + const user = await db + .selectFrom("users") + .where("email", "=", "regular@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); - const result = await call( - router.admin.orgs.create, - { - slug: `new-org-${uniqueId}`, - displayName: "New Organization", - ownerEmail: `owner-${uniqueId}@example.com`, - }, - { context }, - ); - - expect(result.slug).toBe(`new-org-${uniqueId}`); - - // Verify org was created - const org = await db - .selectFrom("orgs") - .where("slug", "=", `new-org-${uniqueId}`) - .selectAll() - .executeTakeFirst(); - - expect(org).toBeDefined(); - expect(org?.display_name).toBe("New Organization"); - - // Verify owner membership - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org!.id) - .where("user_id", "=", owner.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeDefined(); - expect(membership?.role).toBe("owner"); - }); - - test("normalizes owner email to lowercase", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.admin.orgs.create, - { - slug: `new-org-${uniqueId}`, - displayName: "New Organization", - ownerEmail: `OWNER-${uniqueId}@EXAMPLE.COM`, - }, - { context }, - ); - - expect(result.slug).toBe(`new-org-${uniqueId}`); - }); - - test("throws NOT_FOUND for non-existent owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, + expect(user.is_superuser).toBe(true); + }); }); - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); + test("revokes superuser status from another user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "otheradmin@example.com", + isSuperuser: true, + }); - await expect( - call( + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.update, + { email: "otheradmin@example.com", isSuperuser: false }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "otheradmin@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); + + expect(user.is_superuser).toBe(false); + }); + }); + + test("prevents self-demotion", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "admin@example.com", isSuperuser: false }, + { context }, + ), + ).rejects.toThrow("Cannot remove your own superuser status"); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "target@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.update, + { email: "TARGET@EXAMPLE.COM", isSuperuser: true }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "target@example.com") + .select(["is_superuser"]) + .executeTakeFirstOrThrow(); + + expect(user.is_superuser).toBe(true); + }); + }); + + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "nonexistent@example.com", isSuperuser: true }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + + test("returns success for no-op update (no fields to update)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "target@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.users.update, + { email: "target@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); + + test("throws NOT_FOUND for no-op update on non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.update, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + }); + + // ===== admin.users.confirmEmail ===== + + describe("admin.users.confirmEmail", () => { + test("confirms user email", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "unverified@example.com", + emailVerifiedAt: undefined, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.confirmEmail, + { email: "unverified@example.com" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "unverified@example.com") + .select(["email_verified_at"]) + .executeTakeFirstOrThrow(); + + expect(user.email_verified_at).not.toBeNull(); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { email: "test@example.com" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.users.confirmEmail, + { email: "TEST@EXAMPLE.COM" }, + { context }, + ); + + const user = await db + .selectFrom("users") + .where("email", "=", "test@example.com") + .select(["email_verified_at"]) + .executeTakeFirstOrThrow(); + + expect(user.email_verified_at).not.toBeNull(); + }); + }); + + test("throws NOT_FOUND for non-existent user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.users.confirmEmail, + { email: "nonexistent@example.com" }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + }); + + test("succeeds for already verified user (idempotent)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createTestUser(db, { + email: "verified@example.com", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + // Should not throw + const result = await call( + router.admin.users.confirmEmail, + { email: "verified@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); + }); + + // ===== admin.orgs.list ===== + + describe("admin.orgs.list", () => { + test("returns all organizations", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "org-one", displayName: "Org One" }); + await createOrg(db, { slug: "org-two", displayName: "Org Two" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(2); + const slugs = orgs.map((o) => o.slug).sort(); + expect(slugs).toEqual(["org-one", "org-two"]); + }); + }); + + test("returns organizations with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + displayName: "Test Org", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + const org = orgs.find((o) => o.slug === "test-org"); + expect(org).toBeDefined(); + expect(org?.displayName).toBe("Test Org"); + expect(org?.logoUrl).toBe("https://example.com/logo.png"); + expect(org?.createdAt).toBeInstanceOf(Date); + }); + }); + + test("returns empty array when no organizations", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.admin.orgs.list, undefined, { context }); + + expect(orgs).toHaveLength(0); + }); + }); + }); + + // ===== admin.orgs.get ===== + + describe("admin.orgs.get", () => { + test("returns organization by slug", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "target-org", + displayName: "Target Organization", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const org = await call( + router.admin.orgs.get, + { slug: "target-org" }, + { context }, + ); + + expect(org.slug).toBe("target-org"); + expect(org.displayName).toBe("Target Organization"); + expect(org.logoUrl).toBe("https://example.com/logo.png"); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.get, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); + }); + + // ===== admin.orgs.create ===== + // NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + + describe("admin.orgs.create", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("creates organization with owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( router.admin.orgs.create, { slug: `new-org-${uniqueId}`, displayName: "New Organization", - ownerEmail: "nonexistent@example.com", - }, - { context }, - ), - ).rejects.toThrow("User not found"); - }); - - test("throws CONFLICT for duplicate slug", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - await createOrg(db, { slug: `existing-org-${uniqueId}` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.create, - { - slug: `existing-org-${uniqueId}`, - displayName: "New Organization", - ownerEmail: owner.email, - }, - { context }, - ), - ).rejects.toThrow("Organization with this slug already exists"); - }); -}); - -// ===== admin.orgs.update ===== - -describe("admin.orgs.update", () => { - test("updates display name", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "test-org", displayName: "Old Name" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.orgs.update, - { slug: "test-org", displayName: "New Name" }, - { context }, - ); - - const org = await db - .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["display_name"]) - .executeTakeFirstOrThrow(); - - expect(org.display_name).toBe("New Name"); - }); - }); - - test("updates logo URL", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "test-org" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.orgs.update, - { slug: "test-org", logoUrl: "https://example.com/newlogo.png" }, - { context }, - ); - - const org = await db - .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["logo_url"]) - .executeTakeFirstOrThrow(); - - expect(org.logo_url).toBe("https://example.com/newlogo.png"); - }); - }); - - test("clears logo URL with empty string", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { - slug: "test-org", - logoUrl: "https://example.com/logo.png", - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.orgs.update, - { slug: "test-org", logoUrl: "" }, - { context }, - ); - - const org = await db - .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["logo_url"]) - .executeTakeFirstOrThrow(); - - expect(org.logo_url).toBeNull(); - }); - }); - - test("updates multiple fields at once", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { - slug: "test-org", - displayName: "Old", - logoUrl: null, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.orgs.update, - { - slug: "test-org", - displayName: "New Name", - logoUrl: "https://example.com/logo.png", + ownerEmail: `owner-${uniqueId}@example.com`, }, { context }, ); + expect(result.slug).toBe(`new-org-${uniqueId}`); + + // Verify org was created const org = await db .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["display_name", "logo_url"]) - .executeTakeFirstOrThrow(); - - expect(org.display_name).toBe("New Name"); - expect(org.logo_url).toBe("https://example.com/logo.png"); - }); - }); - - test("returns success for no-op update", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "test-org" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.admin.orgs.update, - { slug: "test-org" }, - { context }, - ); - - expect(result.success).toBe(true); - }); - }); - - test("throws NOT_FOUND for no-op on non-existent org", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.admin.orgs.update, { slug: "nonexistent" }, { context }), - ).rejects.toThrow("Organization not found"); - }); - }); - - test("throws NOT_FOUND for non-existent organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.update, - { slug: "nonexistent", displayName: "Test" }, - { context }, - ), - ).rejects.toThrow("Organization not found"); - }); - }); -}); - -// ===== admin.orgs.delete ===== -// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally - -describe("admin.orgs.delete", () => { - afterAll(async () => { - await truncateAllTables(getSharedDb()); - }); - - test("deletes organization and related records", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `delete-me-${uniqueId}` }); - - // Create related records - await addOrgMember(db, org.id, member.id, "owner"); - await createSite(db, org.id, `example-${uniqueId}.com`); - await createOrgInvite(db, org.id, `invite-${uniqueId}@example.com`, admin.id); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.admin.orgs.delete, - { slug: `delete-me-${uniqueId}` }, - { context }, - ); - - expect(result.success).toBe(true); - - // Verify org is deleted - const deletedOrg = await db - .selectFrom("orgs") - .where("slug", "=", `delete-me-${uniqueId}`) - .selectAll() - .executeTakeFirst(); - expect(deletedOrg).toBeUndefined(); - - // Verify related records are deleted - const members = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .selectAll() - .execute(); - expect(members).toHaveLength(0); - - const sites = await db - .selectFrom("org_sites") - .where("org_id", "=", org.id) - .selectAll() - .execute(); - expect(sites).toHaveLength(0); - - const invites = await db - .selectFrom("org_invites") - .where("org_id", "=", org.id) - .selectAll() - .execute(); - expect(invites).toHaveLength(0); - }); - - test("throws NOT_FOUND for non-existent organization", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.admin.orgs.delete, { slug: "nonexistent" }, { context }), - ).rejects.toThrow("Organization not found"); - }); -}); - -// ===== admin.orgs.listSites ===== - -describe("admin.orgs.listSites", () => { - test("returns sites for organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const org = await createOrg(db, { slug: "test-org" }); - await createSite(db, org.id, "example.com"); - await createSite(db, org.id, "test.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const sites = await call( - router.admin.orgs.listSites, - { slug: "test-org" }, - { context }, - ); - - expect(sites.length).toBe(2); - const domains = sites.map((s) => s.domain).sort(); - expect(domains).toEqual(["example.com", "test.com"]); - }); - }); - - test("returns empty array when no sites", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "empty-org" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const sites = await call( - router.admin.orgs.listSites, - { slug: "empty-org" }, - { context }, - ); - - expect(sites).toHaveLength(0); - }); - }); - - test("returns sites with correct fields", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const org = await createOrg(db, { slug: "test-org" }); - await createSite(db, org.id, "example.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const sites = await call( - router.admin.orgs.listSites, - { slug: "test-org" }, - { context }, - ); - - expect(sites[0]?.id).toBeDefined(); - expect(sites[0]?.domain).toBe("example.com"); - expect(sites[0]?.createdAt).toBeInstanceOf(Date); - }); - }); - - test("throws NOT_FOUND for non-existent organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.admin.orgs.listSites, { slug: "nonexistent" }, { context }), - ).rejects.toThrow("Organization not found"); - }); - }); -}); - -// ===== admin.orgs.addSite ===== -// NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally - -describe("admin.orgs.addSite", () => { - afterAll(async () => { - await truncateAllTables(getSharedDb()); - }); - - test("adds site to organization", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.admin.orgs.addSite, - { slug: `test-org-${uniqueId}`, domain: `newsite-${uniqueId}.com` }, - { context }, - ); - - expect(result.success).toBe(true); - - // Verify site was created - const site = await db - .selectFrom("org_sites") - .where("org_id", "=", org.id) - .where("domain", "=", `newsite-${uniqueId}.com`) - .selectAll() - .executeTakeFirst(); - - expect(site).toBeDefined(); - }); - - test("throws NOT_FOUND for non-existent organization", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.addSite, - { slug: "nonexistent", domain: `test-${uniqueId}.com` }, - { context }, - ), - ).rejects.toThrow("Organization not found"); - }); - - test("throws CONFLICT for duplicate domain", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); - await createSite(db, org.id, `existing-${uniqueId}.com`); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.addSite, - { slug: `test-org-${uniqueId}`, domain: `existing-${uniqueId}.com` }, - { context }, - ), - ).rejects.toThrow("Site with this domain already exists"); - }); - - test("throws CONFLICT for domain in another organization", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { - email: `admin-${uniqueId}@example.com`, - isSuperuser: true, - }); - const org1 = await createOrg(db, { slug: `org-one-${uniqueId}` }); - await createOrg(db, { slug: `org-two-${uniqueId}` }); - await createSite(db, org1.id, `shared-${uniqueId}.com`); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.addSite, - { slug: `org-two-${uniqueId}`, domain: `shared-${uniqueId}.com` }, - { context }, - ), - ).rejects.toThrow("Site with this domain already exists"); - }); -}); - -// ===== admin.orgs.removeSite ===== - -describe("admin.orgs.removeSite", () => { - test("removes site from organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const org = await createOrg(db, { slug: "test-org" }); - await createSite(db, org.id, "remove-me.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.admin.orgs.removeSite, - { slug: "test-org", domain: "remove-me.com" }, - { context }, - ); - - expect(result.success).toBe(true); - - // Verify site was removed - const site = await db - .selectFrom("org_sites") - .where("org_id", "=", org.id) - .where("domain", "=", "remove-me.com") + .where("slug", "=", `new-org-${uniqueId}`) .selectAll() .executeTakeFirst(); - expect(site).toBeUndefined(); - }); - }); + expect(org).toBeDefined(); + expect(org?.display_name).toBe("New Organization"); + + // Verify owner membership + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org?.id) + .where("user_id", "=", owner.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeDefined(); + expect(membership?.role).toBe("owner"); + }); + + test("normalizes owner email to lowercase", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; - test("throws NOT_FOUND for non-existent organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { const admin = await createTestUser(db, { - email: "admin@example.com", + email: `admin-${uniqueId}@example.com`, isSuperuser: true, }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.removeSite, - { slug: "nonexistent", domain: "test.com" }, - { context }, - ), - ).rejects.toThrow("Organization not found"); - }); - }); - - test("throws NOT_FOUND for non-existent site", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - await createOrg(db, { slug: "test-org" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.removeSite, - { slug: "test-org", domain: "nonexistent.com" }, - { context }, - ), - ).rejects.toThrow("Site not found"); - }); - }); - - test("throws NOT_FOUND for site in another organization", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const org1 = await createOrg(db, { slug: "org-one" }); - await createOrg(db, { slug: "org-two" }); - await createSite(db, org1.id, "org1-site.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.orgs.removeSite, - { slug: "org-two", domain: "org1-site.com" }, - { context }, - ), - ).rejects.toThrow("Site not found"); - }); - }); -}); - -// ===== admin.auth.completeLogin ===== - -describe("admin.auth.completeLogin", () => { - test("completes pending login request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const user = await createTestUser(db, { email: "user@example.com" }); - const loginRequest = await createLoginRequest(db, user.id, "user@example.com"); + await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); const { token: sessionToken } = await createSession(db, admin.id); const context = createAPIContext(db, { sessionToken }); const result = await call( - router.admin.auth.completeLogin, - { email: "user@example.com" }, + router.admin.orgs.create, + { + slug: `new-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: `OWNER-${uniqueId}@EXAMPLE.COM`, + }, + { context }, + ); + + expect(result.slug).toBe(`new-org-${uniqueId}`); + }); + + test("throws NOT_FOUND for non-existent owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.create, + { + slug: `new-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: "nonexistent@example.com", + }, + { context }, + ), + ).rejects.toThrow("User not found"); + }); + + test("throws CONFLICT for duplicate slug", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + await createOrg(db, { slug: `existing-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.create, + { + slug: `existing-org-${uniqueId}`, + displayName: "New Organization", + ownerEmail: owner.email, + }, + { context }, + ), + ).rejects.toThrow("Organization with this slug already exists"); + }); + }); + + // ===== admin.orgs.update ===== + + describe("admin.orgs.update", () => { + test("updates display name", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org", displayName: "Old Name" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name"]) + .executeTakeFirstOrThrow(); + + expect(org.display_name).toBe("New Name"); + }); + }); + + test("updates logo URL", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", logoUrl: "https://example.com/newlogo.png" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.logo_url).toBe("https://example.com/newlogo.png"); + }); + }); + + test("clears logo URL with empty string", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + logoUrl: "https://example.com/logo.png", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { slug: "test-org", logoUrl: "" }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.logo_url).toBeNull(); + }); + }); + + test("updates multiple fields at once", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { + slug: "test-org", + displayName: "Old", + logoUrl: null, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.orgs.update, + { + slug: "test-org", + displayName: "New Name", + logoUrl: "https://example.com/logo.png", + }, + { context }, + ); + + const org = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name", "logo_url"]) + .executeTakeFirstOrThrow(); + + expect(org.display_name).toBe("New Name"); + expect(org.logo_url).toBe("https://example.com/logo.png"); + }); + }); + + test("returns success for no-op update", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.update, + { slug: "test-org" }, + { context }, + ); + + expect(result.success).toBe(true); + }); + }); + + test("throws NOT_FOUND for no-op on non-existent org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.update, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.update, + { slug: "nonexistent", displayName: "Test" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + }); + }); + + // ===== admin.orgs.delete ===== + // NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + + describe("admin.orgs.delete", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("deletes organization and related records", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `delete-me-${uniqueId}` }); + + // Create related records + await addOrgMember(db, org.id, member.id, "owner"); + await createSite(db, org.id, `example-${uniqueId}.com`); + await createOrgInvite( + db, + org.id, + `invite-${uniqueId}@example.com`, + admin.id, + ); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.delete, + { slug: `delete-me-${uniqueId}` }, { context }, ); expect(result.success).toBe(true); - // Verify login request was completed - const request = await db - .selectFrom("login_requests") - .where("id", "=", String(loginRequest.id)) - .select(["completed_at"]) - .executeTakeFirstOrThrow(); + // Verify org is deleted + const deletedOrg = await db + .selectFrom("orgs") + .where("slug", "=", `delete-me-${uniqueId}`) + .selectAll() + .executeTakeFirst(); + expect(deletedOrg).toBeUndefined(); - expect(request.completed_at).not.toBeNull(); - }); - }); - - test("normalizes email to lowercase", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const user = await createTestUser(db, { email: "user@example.com" }); - const loginRequest = await createLoginRequest(db, user.id, "user@example.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.auth.completeLogin, - { email: "USER@EXAMPLE.COM" }, - { context }, - ); - - const request = await db - .selectFrom("login_requests") - .where("id", "=", String(loginRequest.id)) - .select(["completed_at"]) - .executeTakeFirstOrThrow(); - - expect(request.completed_at).not.toBeNull(); - }); - }); - - test("throws NOT_FOUND for no login request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.auth.completeLogin, - { email: "noRequest@example.com" }, - { context }, - ), - ).rejects.toThrow("No login request found"); - }); - }); - - test("throws BAD_REQUEST for already completed request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const user = await createTestUser(db, { email: "user@example.com" }); - await createLoginRequest(db, user.id, "user@example.com", { - completedAt: new Date(), - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.auth.completeLogin, - { email: "user@example.com" }, - { context }, - ), - ).rejects.toThrow("Login request already completed"); - }); - }); - - test("throws BAD_REQUEST for expired request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const user = await createTestUser(db, { email: "user@example.com" }); - await createLoginRequest(db, user.id, "user@example.com", { - expiresAt: new Date(Date.now() - 1000), // Expired - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.admin.auth.completeLogin, - { email: "user@example.com" }, - { context }, - ), - ).rejects.toThrow("Login request expired"); - }); - }); - - test("completes most recent login request", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { - email: "admin@example.com", - isSuperuser: true, - }); - const user = await createTestUser(db, { email: "user@example.com" }); - - // Create two login requests - await createLoginRequest(db, user.id, "user@example.com"); - await createLoginRequest(db, user.id, "user@example.com"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.admin.auth.completeLogin, - { email: "user@example.com" }, - { context }, - ); - - // Check that exactly one login request was completed - // (Note: both requests have the same created_at in transaction, so ORDER BY is non-deterministic) - const allRequests = await db - .selectFrom("login_requests") - .where("email", "=", "user@example.com") - .select(["id", "completed_at"]) + // Verify related records are deleted + const members = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .selectAll() .execute(); + expect(members).toHaveLength(0); - expect(allRequests.length).toBe(2); + const sites = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + expect(sites).toHaveLength(0); - const completedCount = allRequests.filter((r) => r.completed_at !== null).length; - expect(completedCount).toBe(1); + const invites = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .selectAll() + .execute(); + expect(invites).toHaveLength(0); + }); - const uncompletedCount = allRequests.filter((r) => r.completed_at === null).length; - expect(uncompletedCount).toBe(1); + test("throws NOT_FOUND for non-existent organization", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.admin.orgs.delete, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); + + // ===== admin.orgs.listSites ===== + + describe("admin.orgs.listSites", () => { + test("returns sites for organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "example.com"); + await createSite(db, org.id, "test.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "test-org" }, + { context }, + ); + + expect(sites.length).toBe(2); + const domains = sites.map((s) => s.domain).sort(); + expect(domains).toEqual(["example.com", "test.com"]); + }); + }); + + test("returns empty array when no sites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "empty-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "empty-org" }, + { context }, + ); + + expect(sites).toHaveLength(0); + }); + }); + + test("returns sites with correct fields", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.admin.orgs.listSites, + { slug: "test-org" }, + { context }, + ); + + expect(sites[0]?.id).toBeDefined(); + expect(sites[0]?.domain).toBe("example.com"); + expect(sites[0]?.createdAt).toBeInstanceOf(Date); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.listSites, + { slug: "nonexistent" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + }); + }); + + // ===== admin.orgs.addSite ===== + // NOTE: These tests don't use withTestTransaction because the procedure uses db.transaction() internally + + describe("admin.orgs.addSite", () => { + afterAll(async () => { + await truncateAllTables(getSharedDb()); + }); + + test("adds site to organization", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.addSite, + { slug: `test-org-${uniqueId}`, domain: `newsite-${uniqueId}.com` }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify site was created + const site = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .where("domain", "=", `newsite-${uniqueId}.com`) + .selectAll() + .executeTakeFirst(); + + expect(site).toBeDefined(); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: "nonexistent", domain: `test-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + + test("throws CONFLICT for duplicate domain", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); + await createSite(db, org.id, `existing-${uniqueId}.com`); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: `test-org-${uniqueId}`, domain: `existing-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Site with this domain already exists"); + }); + + test("throws CONFLICT for domain in another organization", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + isSuperuser: true, + }); + const org1 = await createOrg(db, { slug: `org-one-${uniqueId}` }); + await createOrg(db, { slug: `org-two-${uniqueId}` }); + await createSite(db, org1.id, `shared-${uniqueId}.com`); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.addSite, + { slug: `org-two-${uniqueId}`, domain: `shared-${uniqueId}.com` }, + { context }, + ), + ).rejects.toThrow("Site with this domain already exists"); + }); + }); + + // ===== admin.orgs.removeSite ===== + + describe("admin.orgs.removeSite", () => { + test("removes site from organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org = await createOrg(db, { slug: "test-org" }); + await createSite(db, org.id, "remove-me.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.orgs.removeSite, + { slug: "test-org", domain: "remove-me.com" }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify site was removed + const site = await db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .where("domain", "=", "remove-me.com") + .selectAll() + .executeTakeFirst(); + + expect(site).toBeUndefined(); + }); + }); + + test("throws NOT_FOUND for non-existent organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "nonexistent", domain: "test.com" }, + { context }, + ), + ).rejects.toThrow("Organization not found"); + }); + }); + + test("throws NOT_FOUND for non-existent site", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + await createOrg(db, { slug: "test-org" }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "test-org", domain: "nonexistent.com" }, + { context }, + ), + ).rejects.toThrow("Site not found"); + }); + }); + + test("throws NOT_FOUND for site in another organization", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const org1 = await createOrg(db, { slug: "org-one" }); + await createOrg(db, { slug: "org-two" }); + await createSite(db, org1.id, "org1-site.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.orgs.removeSite, + { slug: "org-two", domain: "org1-site.com" }, + { context }, + ), + ).rejects.toThrow("Site not found"); + }); + }); + }); + + // ===== admin.auth.completeLogin ===== + + describe("admin.auth.completeLogin", () => { + test("completes pending login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + const loginRequest = await createLoginRequest( + db, + user.id, + "user@example.com", + ); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify login request was completed + const request = await db + .selectFrom("login_requests") + .where("id", "=", String(loginRequest.id)) + .select(["completed_at"]) + .executeTakeFirstOrThrow(); + + expect(request.completed_at).not.toBeNull(); + }); + }); + + test("normalizes email to lowercase", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + const loginRequest = await createLoginRequest( + db, + user.id, + "user@example.com", + ); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.auth.completeLogin, + { email: "USER@EXAMPLE.COM" }, + { context }, + ); + + const request = await db + .selectFrom("login_requests") + .where("id", "=", String(loginRequest.id)) + .select(["completed_at"]) + .executeTakeFirstOrThrow(); + + expect(request.completed_at).not.toBeNull(); + }); + }); + + test("throws NOT_FOUND for no login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "noRequest@example.com" }, + { context }, + ), + ).rejects.toThrow("No login request found"); + }); + }); + + test("throws BAD_REQUEST for already completed request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + await createLoginRequest(db, user.id, "user@example.com", { + completedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ), + ).rejects.toThrow("Login request already completed"); + }); + }); + + test("throws BAD_REQUEST for expired request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + await createLoginRequest(db, user.id, "user@example.com", { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ), + ).rejects.toThrow("Login request expired"); + }); + }); + + test("completes most recent login request", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + isSuperuser: true, + }); + const user = await createTestUser(db, { email: "user@example.com" }); + + // Create two login requests + await createLoginRequest(db, user.id, "user@example.com"); + await createLoginRequest(db, user.id, "user@example.com"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.admin.auth.completeLogin, + { email: "user@example.com" }, + { context }, + ); + + // Check that exactly one login request was completed + // (Note: both requests have the same created_at in transaction, so ORDER BY is non-deterministic) + const allRequests = await db + .selectFrom("login_requests") + .where("email", "=", "user@example.com") + .select(["id", "completed_at"]) + .execute(); + + expect(allRequests.length).toBe(2); + + const completedCount = allRequests.filter( + (r) => r.completed_at !== null, + ).length; + expect(completedCount).toBe(1); + + const uncompletedCount = allRequests.filter( + (r) => r.completed_at === null, + ).length; + expect(uncompletedCount).toBe(1); + }); }); }); -}); }); // Close describeE2E("admin") diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index aee2266..a1116a5 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -224,7 +224,7 @@ describeE2E("me", () => { const user = await createTestUser(db, { email: "expired@example.com" }); // Create an expired session - const token = `expired-session-${Date.now()}`; + const token = `expired-session-${String(Date.now())}`; const tokenHashValue = await hashToken(token); await db .insertInto("sessions") @@ -249,7 +249,7 @@ describeE2E("me", () => { const user = await createTestUser(db, { email: "revoked@example.com" }); // Create a revoked session - const token = `revoked-session-${Date.now()}`; + const token = `revoked-session-${String(Date.now())}`; const tokenHashValue = await hashToken(token); await db .insertInto("sessions") @@ -1583,677 +1583,771 @@ async function createOrgInvite( .returning("id") .executeTakeFirstOrThrow(); - return { id: Number(result.id) }; + 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, + 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); }); - - 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, + 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"); }); - - // 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 }); + 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); + 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(); + 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 { token: sessionToken } = await createSession(db, user1.id); + const context = createAPIContext(db, { sessionToken }); - const tokens = await call(router.me.apiTokens.list, undefined, { context }); + 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, + expect(tokens).toHaveLength(1); + expect(tokens[0].name).toBe("User1 Token"); }); - - 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, + 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"); }); - - 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, + 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"); }); - - // 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, + 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"); }); - - 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 }); + 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("other-token"); - const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); + 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: user1.id, token_hash: tokenHash, name: "User1 Token", expires_at: expiresAt }) - .returning("id") - .executeTakeFirstOrThrow(); + 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, user2.id); - const context = createAPIContext(db, { sessionToken }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - await expect( - call(router.me.apiTokens.delete, { tokenId: Number(insertResult.id) }, { context }), - ).rejects.toThrow("API token not found"); - }); - }); + const result = await call( + router.me.apiTokens.delete, + { tokenId: Number(insertResult.id) }, + { context }, + ); - test("returns error for non-existent token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { - email: "notoken@example.com", - isSuperuser: true, + 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); }); + }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + 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, + }); - await expect( - call(router.me.apiTokens.delete, { tokenId: 99999 }, { context }), - ).rejects.toThrow("API token not found"); + 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 -// ============================================================================= + // ============================================================================= + // 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 = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; -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", + email: `inviter-accept-${uniqueId}@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" }); + const org = await createOrg(db, { slug: `accept-org-${uniqueId}` }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { - email: "verified@example.com", + email: `accepter-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); - await createOrgInvite(db, { + const invite = 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 }); + try { + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - const invites = await call(router.me.invites.list, undefined, { context }); + const result = await call( + router.me.invites.accept, + { inviteId: invite.id }, + { 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"); + 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("does not return expired invites", async () => { - await withTestTransaction(getSharedDb(), async (db) => { + 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 = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const inviter = await createTestUser(db, { - email: "inviter3@example.com", + email: `inviter-already-${uniqueId}@example.com`, emailVerifiedAt: new Date(), }); - const org = await createOrg(db, { slug: "expired-org" }); + const org = await createOrg(db, { + slug: `already-member-org-${uniqueId}`, + }); await addOrgMember(db, org.id, inviter.id, "owner"); const user = await createTestUser(db, { - email: "verified2@example.com", + email: `already-member-${uniqueId}@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(), - }); + // 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: "member", + role: "admin", }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + try { + 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 }, - ); + 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(); + } + }); - 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("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"); + }); }); }); - test("rejects if email not verified", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const inviter = await createTestUser(db, { - email: "inviter5@example.com", - emailVerifiedAt: new Date(), + 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(); }); - 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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - 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", + 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"); + }); }); - try { - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + 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 result = await call( - router.me.invites.accept, - { inviteId: invite.id }, - { context }, - ); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); - 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(), + await expect( + call(router.me.invites.decline, { inviteId: 99999 }, { context }), + ).rejects.toThrow("Invitation not found"); }); - 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 = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - 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 diff --git a/apps/api-server/src/__tests__/e2e/orgs.test.ts b/apps/api-server/src/__tests__/e2e/orgs.test.ts index ab82835..a05ac81 100644 --- a/apps/api-server/src/__tests__/e2e/orgs.test.ts +++ b/apps/api-server/src/__tests__/e2e/orgs.test.ts @@ -11,6 +11,7 @@ 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 { @@ -19,10 +20,8 @@ import { getSharedDb, initTestDb, TEST_RP, - truncateAllTables, withTestTransaction, } from "@reviq/test-helpers"; -import type { APIContext } from "../../context.js"; import { router } from "../../router.js"; import { COOKIE_NAMES } from "../../utils/cookies.js"; import { hashToken } from "../../utils/crypto.js"; @@ -167,8 +166,11 @@ async function createOrgInvite( expiresAt?: Date; }, ): Promise<{ id: number; token: string }> { - const token = options?.token ?? `invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; - const expiresAt = options?.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const token = + options?.token ?? + `invite-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const result = await db .insertInto("org_invites") @@ -194,1143 +196,924 @@ describeE2E("orgs", () => { // ===== Authorization Tests ===== describe("Authorization", () => { - test("rejects unauthenticated request for orgs.list", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const context = createAPIContext(db); + test("rejects unauthenticated request for orgs.list", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); - await expect( - call(router.orgs.list, undefined, { context }), - ).rejects.toThrow("No session or API key"); - }); - }); - - test("rejects unauthenticated request for orgs.create", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const context = createAPIContext(db); - - await expect( - call(router.orgs.create, { slug: "test", displayName: "Test" }, { context }), - ).rejects.toThrow("No session or API key"); - }); - }); - - test("rejects unauthenticated request for orgs.get", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const context = createAPIContext(db); - - await expect( - call(router.orgs.get, { slug: "test" }, { context }), - ).rejects.toThrow("No session or API key"); - }); - }); -}); - -// ===== orgs.list ===== - -describe("orgs.list", () => { - test("returns empty array when user has no orgs", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.orgs.list, undefined, { context }); - - expect(orgs).toHaveLength(0); - }); - }); - - test("returns orgs where user is a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org1 = await createOrg(db, { slug: "org-one", displayName: "Org One" }); - const org2 = await createOrg(db, { slug: "org-two", displayName: "Org Two" }); - await addOrgMember(db, org1.id, user.id, "owner"); - await addOrgMember(db, org2.id, user.id, "member"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.orgs.list, undefined, { context }); - - expect(orgs.length).toBe(2); - const slugs = orgs.map((o) => o.slug).sort(); - expect(slugs).toEqual(["org-one", "org-two"]); - }); - }); - - test("does not return orgs where user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const otherUser = await createTestUser(db, { email: "other@example.com" }); - const org1 = await createOrg(db, { slug: "my-org" }); - const org2 = await createOrg(db, { slug: "other-org" }); - await addOrgMember(db, org1.id, user.id, "owner"); - await addOrgMember(db, org2.id, otherUser.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - const orgs = await call(router.orgs.list, undefined, { context }); - - expect(orgs.length).toBe(1); - expect(orgs[0]?.slug).toBe("my-org"); - }); - }); - - test("returns org details including logoUrl", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org = await createOrg(db, { - slug: "test-org", - displayName: "Test Organization", - logoUrl: "https://example.com/logo.png", + await expect( + call(router.orgs.list, undefined, { context }), + ).rejects.toThrow("No session or API key"); }); - await addOrgMember(db, org.id, user.id, "owner"); + }); + test("rejects unauthenticated request for orgs.create", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call( + router.orgs.create, + { slug: "test", displayName: "Test" }, + { context }, + ), + ).rejects.toThrow("No session or API key"); + }); + }); + + test("rejects unauthenticated request for orgs.get", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const context = createAPIContext(db); + + await expect( + call(router.orgs.get, { slug: "test" }, { context }), + ).rejects.toThrow("No session or API key"); + }); + }); + }); + + // ===== orgs.list ===== + + describe("orgs.list", () => { + test("returns empty array when user has no orgs", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs).toHaveLength(0); + }); + }); + + test("returns orgs where user is a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org1 = await createOrg(db, { + slug: "org-one", + displayName: "Org One", + }); + const org2 = await createOrg(db, { + slug: "org-two", + displayName: "Org Two", + }); + await addOrgMember(db, org1.id, user.id, "owner"); + await addOrgMember(db, org2.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(2); + const slugs = orgs.map((o) => o.slug).sort(); + expect(slugs).toEqual(["org-one", "org-two"]); + }); + }); + + test("does not return orgs where user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { + email: "other@example.com", + }); + const org1 = await createOrg(db, { slug: "my-org" }); + const org2 = await createOrg(db, { slug: "other-org" }); + await addOrgMember(db, org1.id, user.id, "owner"); + await addOrgMember(db, org2.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs.length).toBe(1); + expect(orgs[0]?.slug).toBe("my-org"); + }); + }); + + test("returns org details including logoUrl", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { + slug: "test-org", + displayName: "Test Organization", + logoUrl: "https://example.com/logo.png", + }); + await addOrgMember(db, org.id, user.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const orgs = await call(router.orgs.list, undefined, { context }); + + expect(orgs[0]?.displayName).toBe("Test Organization"); + expect(orgs[0]?.logoUrl).toBe("https://example.com/logo.png"); + expect(orgs[0]?.createdAt).toBeDefined(); + }); + }); + }); + + // ===== orgs.create ===== + + describe("orgs.create", () => { + test("creates org and makes user owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { + email: `user-${uniqueId}@example.com`, + }); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); - const orgs = await call(router.orgs.list, undefined, { context }); - - expect(orgs[0]?.displayName).toBe("Test Organization"); - expect(orgs[0]?.logoUrl).toBe("https://example.com/logo.png"); - expect(orgs[0]?.createdAt).toBeDefined(); - }); - }); -}); - -// ===== orgs.create ===== - -describe("orgs.create", () => { - test("creates org and makes user owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call( - router.orgs.create, - { slug: `new-org-${uniqueId}`, displayName: "New Organization" }, - { context }, - ); - - expect(result.slug).toBe(`new-org-${uniqueId}`); - - // Verify user is owner - const membership = await db - .selectFrom("org_members") - .innerJoin("orgs", "orgs.id", "org_members.org_id") - .where("orgs.slug", "=", `new-org-${uniqueId}`) - .where("org_members.user_id", "=", user.id) - .select(["org_members.role"]) - .executeTakeFirst(); - - expect(membership?.role).toBe("owner"); - }); - - test("rejects duplicate slug", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); - await createOrg(db, { slug: `existing-${uniqueId}` }); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( + const result = await call( router.orgs.create, - { slug: `existing-${uniqueId}`, displayName: "Duplicate" }, - { context }, - ), - ).rejects.toThrow("Slug already in use"); - }); -}); - -// ===== orgs.get ===== - -describe("orgs.get", () => { - test("returns org when user is a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org = await createOrg(db, { - slug: "test-org", - displayName: "Test Org", - logoUrl: "https://example.com/logo.png", - }); - await addOrgMember(db, org.id, user.id, "member"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - const result = await call(router.orgs.get, { slug: "test-org" }, { context }); - - expect(result.slug).toBe("test-org"); - expect(result.displayName).toBe("Test Org"); - expect(result.logoUrl).toBe("https://example.com/logo.png"); - }); - }); - - test("rejects when user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const otherUser = await createTestUser(db, { email: "other@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, otherUser.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.get, { slug: "test-org" }, { context }), - ).rejects.toThrow("You are not a member of this organization"); - }); - }); - - test("rejects when org does not exist", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.get, { slug: "nonexistent" }, { context }), - ).rejects.toThrow("Organization not found"); - }); - }); -}); - -// ===== orgs.update ===== - -describe("orgs.update", () => { - test("updates display name when user is admin", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org = await createOrg(db, { slug: "test-org", displayName: "Old Name" }); - await addOrgMember(db, org.id, user.id, "admin"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.update, - { slug: "test-org", displayName: "New Name" }, + { slug: `new-org-${uniqueId}`, displayName: "New Organization" }, { context }, ); - const updated = await db - .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["display_name"]) - .executeTakeFirstOrThrow(); + expect(result.slug).toBe(`new-org-${uniqueId}`); - expect(updated.display_name).toBe("New Name"); + // Verify user is owner + const membership = await db + .selectFrom("org_members") + .innerJoin("orgs", "orgs.id", "org_members.org_id") + .where("orgs.slug", "=", `new-org-${uniqueId}`) + .where("org_members.user_id", "=", user.id) + .select(["org_members.role"]) + .executeTakeFirst(); + + expect(membership?.role).toBe("owner"); + }); + + test("rejects duplicate slug", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { + email: `user-${uniqueId}@example.com`, + }); + await createOrg(db, { slug: `existing-${uniqueId}` }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.create, + { slug: `existing-${uniqueId}`, displayName: "Duplicate" }, + { context }, + ), + ).rejects.toThrow("Slug already in use"); }); }); - test("updates logo URL when user is owner", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); + // ===== orgs.get ===== + + describe("orgs.get", () => { + test("returns org when user is a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { + slug: "test-org", + displayName: "Test Org", + logoUrl: "https://example.com/logo.png", + }); + await addOrgMember(db, org.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.orgs.get, + { slug: "test-org" }, + { context }, + ); + + expect(result.slug).toBe("test-org"); + expect(result.displayName).toBe("Test Org"); + expect(result.logoUrl).toBe("https://example.com/logo.png"); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { + email: "other@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.get, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + + test("rejects when org does not exist", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.get, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); + }); + }); + + // ===== orgs.update ===== + + describe("orgs.update", () => { + test("updates display name when user is admin", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { + slug: "test-org", + displayName: "Old Name", + }); + await addOrgMember(db, org.id, user.id, "admin"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ); + + const updated = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["display_name"]) + .executeTakeFirstOrThrow(); + + expect(updated.display_name).toBe("New Name"); + }); + }); + + test("updates logo URL when user is owner", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, user.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.update, + { slug: "test-org", logoUrl: "https://example.com/new-logo.png" }, + { context }, + ); + + const updated = await db + .selectFrom("orgs") + .where("slug", "=", "test-org") + .select(["logo_url"]) + .executeTakeFirstOrThrow(); + + expect(updated.logo_url).toBe("https://example.com/new-logo.png"); + }); + }); + + test("rejects when user is only a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, user.id, "member"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const otherUser = await createTestUser(db, { + email: "other@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, otherUser.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.update, + { slug: "test-org", displayName: "New Name" }, + { context }, + ), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + }); + + // ===== orgs.delete ===== + + describe("orgs.delete", () => { + test("deletes org when user is owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const user = await createTestUser(db, { + email: `user-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `delete-org-${uniqueId}` }); await addOrgMember(db, org.id, user.id, "owner"); const { token: sessionToken } = await createSession(db, user.id); const context = createAPIContext(db, { sessionToken }); await call( - router.orgs.update, - { slug: "test-org", logoUrl: "https://example.com/new-logo.png" }, - { context }, - ); - - const updated = await db - .selectFrom("orgs") - .where("slug", "=", "test-org") - .select(["logo_url"]) - .executeTakeFirstOrThrow(); - - expect(updated.logo_url).toBe("https://example.com/new-logo.png"); - }); - }); - - test("rejects when user is only a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, user.id, "member"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.update, - { slug: "test-org", displayName: "New Name" }, - { context }, - ), - ).rejects.toThrow("Insufficient permissions"); - }); - }); - - test("rejects when user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const otherUser = await createTestUser(db, { email: "other@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, otherUser.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.update, - { slug: "test-org", displayName: "New Name" }, - { context }, - ), - ).rejects.toThrow("You are not a member of this organization"); - }); - }); -}); - -// ===== orgs.delete ===== - -describe("orgs.delete", () => { - test("deletes org when user is owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const user = await createTestUser(db, { email: `user-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `delete-org-${uniqueId}` }); - await addOrgMember(db, org.id, user.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await call(router.orgs.delete, { slug: `delete-org-${uniqueId}` }, { context }); - - const deleted = await db - .selectFrom("orgs") - .where("slug", "=", `delete-org-${uniqueId}`) - .selectAll() - .executeTakeFirst(); - - expect(deleted).toBeUndefined(); - }); - - test("rejects when user is admin (not owner)", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const owner = await createTestUser(db, { email: "owner@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, user.id, "admin"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.delete, { slug: "test-org" }, { context }), - ).rejects.toThrow("Insufficient permissions"); - }); - }); -}); - -// ===== orgs.leave ===== - -describe("orgs.leave", () => { - test("allows member to leave org", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - await call(router.orgs.leave, { slug: `leave-org-${uniqueId}` }, { context }); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", member.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("allows owner to leave when there are other owners", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); - const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); - await addOrgMember(db, org.id, owner1.id, "owner"); - await addOrgMember(db, org.id, owner2.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner1.id); - const context = createAPIContext(db, { sessionToken }); - - await call(router.orgs.leave, { slug: `leave-org-${uniqueId}` }, { context }); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", owner1.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("prevents only owner from leaving", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `leave-only-owner-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.leave, { slug: `leave-only-owner-${uniqueId}` }, { context }), - ).rejects.toThrow("Cannot leave as the only owner"); - }); - - test("rejects when user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const owner = await createTestUser(db, { email: "owner@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.leave, { slug: "test-org" }, { context }), - ).rejects.toThrow("You are not a member of this organization"); - }); - }); -}); - -// ===== orgs.members.list ===== - -describe("orgs.members.list", () => { - test("returns all members of org", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com", displayName: "Owner" }); - const admin = await createTestUser(db, { email: "admin@example.com", displayName: "Admin" }); - const member = await createTestUser(db, { email: "member@example.com", displayName: "Member" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - const members = await call(router.orgs.members.list, { slug: "test-org" }, { context }); - - expect(members.length).toBe(3); - expect(members.map((m) => m.role).sort()).toEqual(["admin", "member", "owner"]); - }); - }); - - test("includes user details", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com", displayName: "Test Owner" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - const members = await call(router.orgs.members.list, { slug: "test-org" }, { context }); - - expect(members[0]?.email).toBe("owner@example.com"); - expect(members[0]?.displayName).toBe("Test Owner"); - expect(members[0]?.userId).toBe(owner.id); - }); - }); - - test("rejects when user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const owner = await createTestUser(db, { email: "owner@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.members.list, { slug: "test-org" }, { context }), - ).rejects.toThrow("You are not a member of this organization"); - }); - }); -}); - -// ===== orgs.members.updateRole ===== - -describe("orgs.members.updateRole", () => { - test("owner can promote member to admin", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.updateRole, - { slug: `update-role-${uniqueId}`, userId: member.id, role: "admin" }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", member.id) - .select(["role"]) - .executeTakeFirstOrThrow(); - - expect(membership.role).toBe("admin"); - }); - - test("owner can promote member to owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.updateRole, - { slug: `update-role-${uniqueId}`, userId: member.id, role: "owner" }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", member.id) - .select(["role"]) - .executeTakeFirstOrThrow(); - - expect(membership.role).toBe("owner"); - }); - - test("owner can demote owner to admin when multiple owners exist", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); - const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); - await addOrgMember(db, org.id, owner1.id, "owner"); - await addOrgMember(db, org.id, owner2.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner1.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.updateRole, - { slug: `update-role-${uniqueId}`, userId: owner2.id, role: "admin" }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", owner2.id) - .select(["role"]) - .executeTakeFirstOrThrow(); - - expect(membership.role).toBe("admin"); - }); - - test("prevents demoting the only owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.updateRole, - { slug: `update-role-${uniqueId}`, userId: owner.id, role: "admin" }, - { context }, - ), - ).rejects.toThrow("Cannot demote the only owner"); - }); - - test("admin cannot update roles", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const admin = await createTestUser(db, { email: "admin@example.com" }); - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.updateRole, - { slug: "test-org", userId: member.id, role: "admin" }, - { context }, - ), - ).rejects.toThrow("Insufficient permissions"); - }); - }); - - test("rejects when target member not found", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.updateRole, - { slug: `update-role-${uniqueId}`, userId: 999999, role: "admin" }, - { context }, - ), - ).rejects.toThrow("Member not found"); - }); -}); - -// ===== orgs.members.remove ===== - -describe("orgs.members.remove", () => { - test("owner can remove member", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `remove-member-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.remove, - { slug: `remove-member-${uniqueId}`, userId: member.id }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", member.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("owner can remove admin", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `remove-admin-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.remove, - { slug: `remove-admin-${uniqueId}`, userId: admin.id }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", admin.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("owner can remove other owner when multiple owners exist", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner1 = await createTestUser(db, { email: `owner1-${uniqueId}@example.com` }); - const owner2 = await createTestUser(db, { email: `owner2-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `remove-owner-${uniqueId}` }); - await addOrgMember(db, org.id, owner1.id, "owner"); - await addOrgMember(db, org.id, owner2.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner1.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.remove, - { slug: `remove-owner-${uniqueId}`, userId: owner2.id }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", owner2.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("prevents removing the only owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `remove-only-owner-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.remove, - { slug: `remove-only-owner-${uniqueId}`, userId: owner.id }, - { context }, - ), - ).rejects.toThrow("Cannot remove the only owner"); - }); - - test("admin can remove member", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `admin-remove-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.members.remove, - { slug: `admin-remove-${uniqueId}`, userId: member.id }, - { context }, - ); - - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", member.id) - .selectAll() - .executeTakeFirst(); - - expect(membership).toBeUndefined(); - }); - - test("admin cannot remove owner", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `admin-no-remove-owner-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.remove, - { slug: `admin-no-remove-owner-${uniqueId}`, userId: owner.id }, - { context }, - ), - ).rejects.toThrow("Admins can only remove members"); - }); - - test("admin cannot remove other admin", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const admin1 = await createTestUser(db, { email: `admin1-${uniqueId}@example.com` }); - const admin2 = await createTestUser(db, { email: `admin2-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `admin-no-remove-admin-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin1.id, "admin"); - await addOrgMember(db, org.id, admin2.id, "admin"); - - const { token: sessionToken } = await createSession(db, admin1.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.remove, - { slug: `admin-no-remove-admin-${uniqueId}`, userId: admin2.id }, - { context }, - ), - ).rejects.toThrow("Admins can only remove members"); - }); - - test("member cannot remove anyone", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member1 = await createTestUser(db, { email: `member1-${uniqueId}@example.com` }); - const member2 = await createTestUser(db, { email: `member2-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `member-no-remove-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member1.id, "member"); - await addOrgMember(db, org.id, member2.id, "member"); - - const { token: sessionToken } = await createSession(db, member1.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.remove, - { slug: `member-no-remove-${uniqueId}`, userId: member2.id }, - { context }, - ), - ).rejects.toThrow("Insufficient permissions"); - }); - - test("rejects when target member not found", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `remove-not-found-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.members.remove, - { slug: `remove-not-found-${uniqueId}`, userId: 999999 }, - { context }, - ), - ).rejects.toThrow("Member not found"); - }); -}); - -// ===== orgs.invites.list ===== - -describe("orgs.invites.list", () => { - test("returns pending invites for org", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { email: "admin@example.com", displayName: "Admin User" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, admin.id, "admin"); - await createOrgInvite(db, org.id, "invite1@example.com", admin.id, { role: "member" }); - await createOrgInvite(db, org.id, "invite2@example.com", admin.id, { role: "admin" }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const invites = await call(router.orgs.invites.list, { slug: "test-org" }, { context }); - - expect(invites.length).toBe(2); - expect(invites.map((i) => i.email).sort()).toEqual(["invite1@example.com", "invite2@example.com"]); - }); - }); - - test("does not return expired invites", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { email: "admin@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, admin.id, "admin"); - await createOrgInvite(db, org.id, "active@example.com", admin.id); - await createOrgInvite(db, org.id, "expired@example.com", admin.id, { - expiresAt: new Date(Date.now() - 1000), // expired - }); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - const invites = await call(router.orgs.invites.list, { slug: "test-org" }, { context }); - - expect(invites.length).toBe(1); - expect(invites[0]?.email).toBe("active@example.com"); - }); - }); - - test("member cannot list invites", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.invites.list, { slug: "test-org" }, { context }), - ).rejects.toThrow("Insufficient permissions"); - }); - }); -}); - -// ===== orgs.invites.create ===== - -describe("orgs.invites.create", () => { - test("admin can create member invite", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); - await addOrgMember(db, org.id, admin.id, "admin"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.invites.create, - { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "member" }, - { context }, - ); - - const invite = await db - .selectFrom("org_invites") - .where("org_id", "=", org.id) - .where("email", "=", `new-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(invite).toBeDefined(); - expect(invite?.role).toBe("member"); - }); - - test("admin can create admin invite", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const admin = await createTestUser(db, { email: `admin-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); - await addOrgMember(db, org.id, admin.id, "admin"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.invites.create, - { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "admin" }, - { context }, - ); - - const invite = await db - .selectFrom("org_invites") - .where("org_id", "=", org.id) - .where("email", "=", `new-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(invite?.role).toBe("admin"); - }); - - test("admin cannot create owner invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const admin = await createTestUser(db, { email: "admin@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, admin.id, "admin"); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.invites.create, - { slug: "test-org", email: "new@example.com", role: "owner" }, - { context }, - ), - ).rejects.toThrow("Insufficient permissions"); - }); - }); - - test("owner can create owner invite", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.invites.create, - { slug: `invite-org-${uniqueId}`, email: `new-${uniqueId}@example.com`, role: "owner" }, - { context }, - ); - - const invite = await db - .selectFrom("org_invites") - .where("org_id", "=", org.id) - .where("email", "=", `new-${uniqueId}@example.com`) - .selectAll() - .executeTakeFirst(); - - expect(invite?.role).toBe("owner"); - }); - - test("rejects invite for existing member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.invites.create, - { slug: "test-org", email: "member@example.com", role: "member" }, - { context }, - ), - ).rejects.toThrow("This user is already a member of the organization"); - }); - }); - - test("rejects duplicate pending invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await createOrgInvite(db, org.id, "invited@example.com", owner.id); - - const { token: sessionToken } = await createSession(db, owner.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.invites.create, - { slug: "test-org", email: "invited@example.com", role: "member" }, - { context }, - ), - ).rejects.toThrow("An invitation is already pending for this email"); - }); - }); - - test("member cannot create invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call( - router.orgs.invites.create, - { slug: "test-org", email: "new@example.com", role: "member" }, - { context }, - ), - ).rejects.toThrow("Insufficient permissions"); - }); - }); -}); - -// ===== orgs.invites.cancel ===== - -describe("orgs.invites.cancel", () => { - test("admin can cancel invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { email: "admin@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, admin.id, "admin"); - const invite = await createOrgInvite(db, org.id, "invited@example.com", admin.id); - - const { token: sessionToken } = await createSession(db, admin.id); - const context = createAPIContext(db, { sessionToken }); - - await call( - router.orgs.invites.cancel, - { slug: "test-org", inviteId: invite.id }, + router.orgs.delete, + { slug: `delete-org-${uniqueId}` }, { context }, ); const deleted = await db - .selectFrom("org_invites") - .where("id", "=", invite.id) + .selectFrom("orgs") + .where("slug", "=", `delete-org-${uniqueId}`) .selectAll() .executeTakeFirst(); expect(deleted).toBeUndefined(); }); + + test("rejects when user is admin (not owner)", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, user.id, "admin"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.delete, { slug: "test-org" }, { context }), + ).rejects.toThrow("Insufficient permissions"); + }); + }); }); - test("rejects cancel for nonexistent invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const admin = await createTestUser(db, { email: "admin@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); + // ===== orgs.leave ===== + + describe("orgs.leave", () => { + test("allows member to leave org", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.leave, + { slug: `leave-org-${uniqueId}` }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("allows owner to leave when there are other owners", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { + email: `owner1-${uniqueId}@example.com`, + }); + const owner2 = await createTestUser(db, { + email: `owner2-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `leave-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.leave, + { slug: `leave-org-${uniqueId}` }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner1.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("prevents only owner from leaving", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `leave-only-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.leave, + { slug: `leave-only-owner-${uniqueId}` }, + { context }, + ), + ).rejects.toThrow("Cannot leave as the only owner"); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.leave, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + }); + + // ===== orgs.members.list ===== + + describe("orgs.members.list", () => { + test("returns all members of org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { + email: "owner@example.com", + displayName: "Owner", + }); + const admin = await createTestUser(db, { + email: "admin@example.com", + displayName: "Admin", + }); + const member = await createTestUser(db, { + email: "member@example.com", + displayName: "Member", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const members = await call( + router.orgs.members.list, + { slug: "test-org" }, + { context }, + ); + + expect(members.length).toBe(3); + expect(members.map((m) => m.role).sort()).toEqual([ + "admin", + "member", + "owner", + ]); + }); + }); + + test("includes user details", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { + email: "owner@example.com", + displayName: "Test Owner", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + const members = await call( + router.orgs.members.list, + { slug: "test-org" }, + { context }, + ); + + expect(members[0]?.email).toBe("owner@example.com"); + expect(members[0]?.displayName).toBe("Test Owner"); + expect(members[0]?.userId).toBe(owner.id); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.members.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + }); + + // ===== orgs.members.updateRole ===== + + describe("orgs.members.updateRole", () => { + test("owner can promote member to admin", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: member.id, role: "admin" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("admin"); + }); + + test("owner can promote member to owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: member.id, role: "owner" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("owner"); + }); + + test("owner can demote owner to admin when multiple owners exist", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { + email: `owner1-${uniqueId}@example.com`, + }); + const owner2 = await createTestUser(db, { + email: `owner2-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: owner2.id, role: "admin" }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner2.id) + .select(["role"]) + .executeTakeFirstOrThrow(); + + expect(membership.role).toBe("admin"); + }); + + test("prevents demoting the only owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: owner.id, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Cannot demote the only owner"); + }); + + test("admin cannot update roles", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const admin = await createTestUser(db, { email: "admin@example.com" }); + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: "test-org", userId: member.id, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("rejects when target member not found", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `update-role-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.updateRole, + { slug: `update-role-${uniqueId}`, userId: 999999, role: "admin" }, + { context }, + ), + ).rejects.toThrow("Member not found"); + }); + }); + + // ===== orgs.members.remove ===== + + describe("orgs.members.remove", () => { + test("owner can remove member", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `remove-member-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-member-${uniqueId}`, userId: member.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("owner can remove admin", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `remove-admin-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-admin-${uniqueId}`, userId: admin.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", admin.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("owner can remove other owner when multiple owners exist", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner1 = await createTestUser(db, { + email: `owner1-${uniqueId}@example.com`, + }); + const owner2 = await createTestUser(db, { + email: `owner2-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `remove-owner-${uniqueId}` }); + await addOrgMember(db, org.id, owner1.id, "owner"); + await addOrgMember(db, org.id, owner2.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner1.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `remove-owner-${uniqueId}`, userId: owner2.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", owner2.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("prevents removing the only owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { + slug: `remove-only-owner-${uniqueId}`, + }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `remove-only-owner-${uniqueId}`, userId: owner.id }, + { context }, + ), + ).rejects.toThrow("Cannot remove the only owner"); + }); + + test("admin can remove member", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `admin-remove-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.members.remove, + { slug: `admin-remove-${uniqueId}`, userId: member.id }, + { context }, + ); + + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", member.id) + .selectAll() + .executeTakeFirst(); + + expect(membership).toBeUndefined(); + }); + + test("admin cannot remove owner", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { + slug: `admin-no-remove-owner-${uniqueId}`, + }); + await addOrgMember(db, org.id, owner.id, "owner"); await addOrgMember(db, org.id, admin.id, "admin"); const { token: sessionToken } = await createSession(db, admin.id); @@ -1338,235 +1121,724 @@ describe("orgs.invites.cancel", () => { await expect( call( - router.orgs.invites.cancel, - { slug: "test-org", inviteId: 999999 }, + router.orgs.members.remove, + { slug: `admin-no-remove-owner-${uniqueId}`, userId: owner.id }, { context }, ), - ).rejects.toThrow("Invitation not found"); + ).rejects.toThrow("Admins can only remove members"); }); - }); - test("member cannot cancel invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); + test("admin cannot remove other admin", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const admin1 = await createTestUser(db, { + email: `admin1-${uniqueId}@example.com`, + }); + const admin2 = await createTestUser(db, { + email: `admin2-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { + slug: `admin-no-remove-admin-${uniqueId}`, + }); await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - const invite = await createOrgInvite(db, org.id, "invited@example.com", owner.id); + await addOrgMember(db, org.id, admin1.id, "admin"); + await addOrgMember(db, org.id, admin2.id, "admin"); - const { token: sessionToken } = await createSession(db, member.id); + const { token: sessionToken } = await createSession(db, admin1.id); const context = createAPIContext(db, { sessionToken }); await expect( call( - router.orgs.invites.cancel, - { slug: "test-org", inviteId: invite.id }, + router.orgs.members.remove, + { slug: `admin-no-remove-admin-${uniqueId}`, userId: admin2.id }, + { context }, + ), + ).rejects.toThrow("Admins can only remove members"); + }); + + test("member cannot remove anyone", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member1 = await createTestUser(db, { + email: `member1-${uniqueId}@example.com`, + }); + const member2 = await createTestUser(db, { + email: `member2-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `member-no-remove-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member1.id, "member"); + await addOrgMember(db, org.id, member2.id, "member"); + + const { token: sessionToken } = await createSession(db, member1.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `member-no-remove-${uniqueId}`, userId: member2.id }, { context }, ), ).rejects.toThrow("Insufficient permissions"); }); - }); -}); -// ===== orgs.invites.accept ===== + test("rejects when target member not found", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; -describe("orgs.invites.accept", () => { - test("accepts invite and adds user to org", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const invitee = await createTestUser(db, { email: `invitee-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `accept-org-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - const invite = await createOrgInvite(db, org.id, `invitee-${uniqueId}@example.com`, owner.id, { role: "admin" }); - - const { token: sessionToken } = await createSession(db, invitee.id); - const context = createAPIContext(db, { sessionToken }); - - await call(router.orgs.invites.accept, { token: invite.token }, { context }); - - // Verify membership - const membership = await db - .selectFrom("org_members") - .where("org_id", "=", org.id) - .where("user_id", "=", invitee.id) - .select(["role"]) - .executeTakeFirst(); - - expect(membership?.role).toBe("admin"); - - // Verify invite deleted - const deletedInvite = await db - .selectFrom("org_invites") - .where("id", "=", invite.id) - .selectAll() - .executeTakeFirst(); - - expect(deletedInvite).toBeUndefined(); - }); - - test("rejects expired invite", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const invitee = await createTestUser(db, { email: "invitee@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - const invite = await createOrgInvite(db, org.id, "invitee@example.com", owner.id, { - expiresAt: new Date(Date.now() - 1000), // expired + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, }); + const org = await createOrg(db, { slug: `remove-not-found-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.members.remove, + { slug: `remove-not-found-${uniqueId}`, userId: 999999 }, + { context }, + ), + ).rejects.toThrow("Member not found"); + }); + }); + + // ===== orgs.invites.list ===== + + describe("orgs.invites.list", () => { + test("returns pending invites for org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { + email: "admin@example.com", + displayName: "Admin User", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + await createOrgInvite(db, org.id, "invite1@example.com", admin.id, { + role: "member", + }); + await createOrgInvite(db, org.id, "invite2@example.com", admin.id, { + role: "admin", + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call( + router.orgs.invites.list, + { slug: "test-org" }, + { context }, + ); + + expect(invites.length).toBe(2); + expect(invites.map((i) => i.email).sort()).toEqual([ + "invite1@example.com", + "invite2@example.com", + ]); + }); + }); + + test("does not return expired invites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + await createOrgInvite(db, org.id, "active@example.com", admin.id); + await createOrgInvite(db, org.id, "expired@example.com", admin.id, { + expiresAt: new Date(Date.now() - 1000), // expired + }); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call( + router.orgs.invites.list, + { slug: "test-org" }, + { context }, + ); + + expect(invites.length).toBe(1); + expect(invites[0]?.email).toBe("active@example.com"); + }); + }); + + test("member cannot list invites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.invites.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + }); + + // ===== orgs.invites.create ===== + + describe("orgs.invites.create", () => { + test("admin can create member invite", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { + slug: `invite-org-${uniqueId}`, + email: `new-${uniqueId}@example.com`, + role: "member", + }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite).toBeDefined(); + expect(invite?.role).toBe("member"); + }); + + test("admin can create admin invite", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const admin = await createTestUser(db, { + email: `admin-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { + slug: `invite-org-${uniqueId}`, + email: `new-${uniqueId}@example.com`, + role: "admin", + }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite?.role).toBe("admin"); + }); + + test("admin cannot create owner invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "new@example.com", role: "owner" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + + test("owner can create owner invite", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `invite-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.create, + { + slug: `invite-org-${uniqueId}`, + email: `new-${uniqueId}@example.com`, + role: "owner", + }, + { context }, + ); + + const invite = await db + .selectFrom("org_invites") + .where("org_id", "=", org.id) + .where("email", "=", `new-${uniqueId}@example.com`) + .selectAll() + .executeTakeFirst(); + + expect(invite?.role).toBe("owner"); + }); + + test("rejects invite for existing member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "member@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("This user is already a member of the organization"); + }); + }); + + test("rejects duplicate pending invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await createOrgInvite(db, org.id, "invited@example.com", owner.id); + + const { token: sessionToken } = await createSession(db, owner.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "invited@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("An invitation is already pending for this email"); + }); + }); + + test("member cannot create invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.create, + { slug: "test-org", email: "new@example.com", role: "member" }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + }); + + // ===== orgs.invites.cancel ===== + + describe("orgs.invites.cancel", () => { + test("admin can cancel invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + const invite = await createOrgInvite( + db, + org.id, + "invited@example.com", + admin.id, + ); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: invite.id }, + { context }, + ); + + const deleted = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deleted).toBeUndefined(); + }); + }); + + test("rejects cancel for nonexistent invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const admin = await createTestUser(db, { email: "admin@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, admin.id, "admin"); + + const { token: sessionToken } = await createSession(db, admin.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: 999999 }, + { context }, + ), + ).rejects.toThrow("Invitation not found"); + }); + }); + + test("member cannot cancel invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + await addOrgMember(db, org.id, member.id, "member"); + const invite = await createOrgInvite( + db, + org.id, + "invited@example.com", + owner.id, + ); + + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.cancel, + { slug: "test-org", inviteId: invite.id }, + { context }, + ), + ).rejects.toThrow("Insufficient permissions"); + }); + }); + }); + + // ===== orgs.invites.accept ===== + + describe("orgs.invites.accept", () => { + test("accepts invite and adds user to org", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const invitee = await createTestUser(db, { + email: `invitee-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `accept-org-${uniqueId}` }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite( + db, + org.id, + `invitee-${uniqueId}@example.com`, + owner.id, + { role: "admin" }, + ); const { token: sessionToken } = await createSession(db, invitee.id); const context = createAPIContext(db, { sessionToken }); - await expect( - call(router.orgs.invites.accept, { token: invite.token }, { context }), - ).rejects.toThrow("Invalid or expired invitation"); + await call( + router.orgs.invites.accept, + { token: invite.token }, + { context }, + ); + + // Verify membership + const membership = await db + .selectFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", invitee.id) + .select(["role"]) + .executeTakeFirst(); + + expect(membership?.role).toBe("admin"); + + // Verify invite deleted + const deletedInvite = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deletedInvite).toBeUndefined(); }); - }); - test("rejects invalid token", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); + test("rejects expired invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const invitee = await createTestUser(db, { + email: "invitee@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite( + db, + org.id, + "invitee@example.com", + owner.id, + { + expiresAt: new Date(Date.now() - 1000), // expired + }, + ); - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); + const { token: sessionToken } = await createSession(db, invitee.id); + const context = createAPIContext(db, { sessionToken }); - await expect( - call(router.orgs.invites.accept, { token: "invalid-token" }, { context }), - ).rejects.toThrow("Invalid or expired invitation"); + await expect( + call( + router.orgs.invites.accept, + { token: invite.token }, + { context }, + ), + ).rejects.toThrow("Invalid or expired invitation"); + }); }); - }); - test("rejects when email doesn't match", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const owner = await createTestUser(db, { email: "owner@example.com" }); - const invitee = await createTestUser(db, { email: "invitee@example.com" }); - const wrongUser = await createTestUser(db, { email: "wrong@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); + test("rejects invalid token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.accept, + { token: "invalid-token" }, + { context }, + ), + ).rejects.toThrow("Invalid or expired invitation"); + }); + }); + + test("rejects when email doesn't match", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const owner = await createTestUser(db, { email: "owner@example.com" }); + const _invitee = await createTestUser(db, { + email: "invitee@example.com", + }); + const wrongUser = await createTestUser(db, { + email: "wrong@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + const invite = await createOrgInvite( + db, + org.id, + "invitee@example.com", + owner.id, + ); + + const { token: sessionToken } = await createSession(db, wrongUser.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call( + router.orgs.invites.accept, + { token: invite.token }, + { context }, + ), + ).rejects.toThrow( + "This invitation was sent to a different email address", + ); + }); + }); + + test("handles already a member gracefully", async () => { + const db = getSharedDb(); + const uniqueId = `${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`; + + const owner = await createTestUser(db, { + email: `owner-${uniqueId}@example.com`, + }); + const member = await createTestUser(db, { + email: `member-${uniqueId}@example.com`, + }); + const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); await addOrgMember(db, org.id, owner.id, "owner"); - const invite = await createOrgInvite(db, org.id, "invitee@example.com", owner.id); + await addOrgMember(db, org.id, member.id, "member"); + const invite = await createOrgInvite( + db, + org.id, + `member-${uniqueId}@example.com`, + owner.id, + ); - const { token: sessionToken } = await createSession(db, wrongUser.id); + const { token: sessionToken } = await createSession(db, member.id); const context = createAPIContext(db, { sessionToken }); await expect( call(router.orgs.invites.accept, { token: invite.token }, { context }), - ).rejects.toThrow("This invitation was sent to a different email address"); + ).rejects.toThrow("You are already a member of this organization"); + + // Invite should be cleaned up + const deletedInvite = await db + .selectFrom("org_invites") + .where("id", "=", invite.id) + .selectAll() + .executeTakeFirst(); + + expect(deletedInvite).toBeUndefined(); }); }); - test("handles already a member gracefully", async () => { - const db = getSharedDb(); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // ===== orgs.sites.list ===== - const owner = await createTestUser(db, { email: `owner-${uniqueId}@example.com` }); - const member = await createTestUser(db, { email: `member-${uniqueId}@example.com` }); - const org = await createOrg(db, { slug: `test-org-${uniqueId}` }); - await addOrgMember(db, org.id, owner.id, "owner"); - await addOrgMember(db, org.id, member.id, "member"); - const invite = await createOrgInvite(db, org.id, `member-${uniqueId}@example.com`, owner.id); + describe("orgs.sites.list", () => { + test("returns sites for org", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); + await createSite(db, org.id, "example.com"); + await createSite(db, org.id, "test.com"); - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); - await expect( - call(router.orgs.invites.accept, { token: invite.token }, { context }), - ).rejects.toThrow("You are already a member of this organization"); + const sites = await call( + router.orgs.sites.list, + { slug: "test-org" }, + { context }, + ); - // Invite should be cleaned up - const deletedInvite = await db - .selectFrom("org_invites") - .where("id", "=", invite.id) - .selectAll() - .executeTakeFirst(); + expect(sites.length).toBe(2); + expect(sites.map((s) => s.domain).sort()).toEqual([ + "example.com", + "test.com", + ]); + }); + }); - expect(deletedInvite).toBeUndefined(); - }); -}); + test("returns empty array when no sites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); -// ===== orgs.sites.list ===== + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); -describe("orgs.sites.list", () => { - test("returns sites for org", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, member.id, "member"); - await createSite(db, org.id, "example.com"); - await createSite(db, org.id, "test.com"); + const sites = await call( + router.orgs.sites.list, + { slug: "test-org" }, + { context }, + ); - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); + expect(sites).toHaveLength(0); + }); + }); - const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); + test("returns site details including id and createdAt", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const member = await createTestUser(db, { + email: "member@example.com", + }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, member.id, "member"); + await createSite(db, org.id, "example.com"); - expect(sites.length).toBe(2); - expect(sites.map((s) => s.domain).sort()).toEqual(["example.com", "test.com"]); + const { token: sessionToken } = await createSession(db, member.id); + const context = createAPIContext(db, { sessionToken }); + + const sites = await call( + router.orgs.sites.list, + { slug: "test-org" }, + { context }, + ); + + expect(sites[0]?.id).toBeDefined(); + expect(sites[0]?.domain).toBe("example.com"); + expect(sites[0]?.createdAt).toBeDefined(); + }); + }); + + test("rejects when user is not a member", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + const owner = await createTestUser(db, { email: "owner@example.com" }); + const org = await createOrg(db, { slug: "test-org" }); + await addOrgMember(db, org.id, owner.id, "owner"); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.sites.list, { slug: "test-org" }, { context }), + ).rejects.toThrow("You are not a member of this organization"); + }); + }); + + test("rejects when org not found", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { email: "user@example.com" }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.orgs.sites.list, { slug: "nonexistent" }, { context }), + ).rejects.toThrow("Organization not found"); + }); }); }); - - test("returns empty array when no sites", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, member.id, "member"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); - - expect(sites).toHaveLength(0); - }); - }); - - test("returns site details including id and createdAt", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const member = await createTestUser(db, { email: "member@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, member.id, "member"); - await createSite(db, org.id, "example.com"); - - const { token: sessionToken } = await createSession(db, member.id); - const context = createAPIContext(db, { sessionToken }); - - const sites = await call(router.orgs.sites.list, { slug: "test-org" }, { context }); - - expect(sites[0]?.id).toBeDefined(); - expect(sites[0]?.domain).toBe("example.com"); - expect(sites[0]?.createdAt).toBeDefined(); - }); - }); - - test("rejects when user is not a member", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - const owner = await createTestUser(db, { email: "owner@example.com" }); - const org = await createOrg(db, { slug: "test-org" }); - await addOrgMember(db, org.id, owner.id, "owner"); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.sites.list, { slug: "test-org" }, { context }), - ).rejects.toThrow("You are not a member of this organization"); - }); - }); - - test("rejects when org not found", async () => { - await withTestTransaction(getSharedDb(), async (db) => { - const user = await createTestUser(db, { email: "user@example.com" }); - - const { token: sessionToken } = await createSession(db, user.id); - const context = createAPIContext(db, { sessionToken }); - - await expect( - call(router.orgs.sites.list, { slug: "nonexistent" }, { context }), - ).rejects.toThrow("Organization not found"); - }); - }); -}); }); // Close describeE2E("orgs") diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 10331ac..19fcd1c 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -68,7 +68,9 @@ export async function signupWithPassword( // Handle duplicate email (unique constraint violation) // Use generic error to prevent email enumeration if (error instanceof Error && error.message.includes("users_email_key")) { - throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" }); + throw new ORPCError("BAD_REQUEST", { + message: "Unable to create account", + }); } throw error; } @@ -209,7 +211,9 @@ export async function signupWithPasskey( // Handle duplicate email (unique constraint violation) // Use generic error to prevent email enumeration if (error instanceof Error && error.message.includes("users_email_key")) { - throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" }); + throw new ORPCError("BAD_REQUEST", { + message: "Unable to create account", + }); } throw error; } diff --git a/apps/api-server/src/procedures/orgs/helpers.ts b/apps/api-server/src/procedures/orgs/helpers.ts index ef9b54d..3816d74 100644 --- a/apps/api-server/src/procedures/orgs/helpers.ts +++ b/apps/api-server/src/procedures/orgs/helpers.ts @@ -115,11 +115,11 @@ export async function countOwners( ): Promise { const result = await db .selectFrom("org_members") - .select((eb) => eb.fn.countAll().as("count")) + .select((eb) => eb.fn.countAll().as("count")) .where("org_id", "=", orgId) .where("role", "=", "owner") .executeTakeFirstOrThrow(); - // PostgreSQL COUNT returns bigint which may be a string; ensure numeric comparison works + // PostgreSQL COUNT returns bigint (string), convert to number return Number(result.count); } diff --git a/db/schema.sql b/db/schema.sql index 0949baf..5d58192 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg +\restrict NNYnwssF6iMx0TXsk1nTprUEDwxna9uejAmsIiUlMLcPLlQlnnRVCusYtzweHXM -- 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 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg +\unrestrict NNYnwssF6iMx0TXsk1nTprUEDwxna9uejAmsIiUlMLcPLlQlnnRVCusYtzweHXM -- diff --git a/sgconfig.yml b/sgconfig.yml index 9e5563f..d4b5999 100644 --- a/sgconfig.yml +++ b/sgconfig.yml @@ -1,6 +1,6 @@ ruleDirs: -- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/ + - .ast-grep/rules/ testConfigs: -- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/ + - testDir: .ast-grep/rule-tests/ utilDirs: -- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/utils/ + - .ast-grep/utils/