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..4be9d59 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/admin.test.ts @@ -0,0 +1,1901 @@ +/** + * 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 { beforeAll, describe, expect, test } from "bun:test"; +import { call } from "@orpc/server"; +import { + createTestUser, + describeE2E, + getSharedDb, + initTestDb, + TEST_RP, + truncateAllTables, + withTestTransaction, +} from "@reviq/test-helpers"; +import { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.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) }; +} + +describeE2E("admin", () => { + 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); + }); + }); +}); +}); // Close describeE2E("admin") diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts index 2452a2c..99088f2 100644 --- a/apps/api-server/src/__tests__/e2e/auth.test.ts +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -1515,7 +1515,6 @@ describeE2E("auth", () => { // Create some sessions await createSession(db, user.id); - await createSession(db, user.id); const token = await createPasswordReset(db, user.id); @@ -2121,4 +2120,62 @@ describeE2E("auth", () => { }); }); }); -}); // Close outer describe.skipIf + + // ============================================================================= + // 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"); + }); + }); + }); +}); // Close outer describeE2E diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index c64bebf..aee2266 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -175,6 +175,102 @@ describeE2E("me", () => { 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) => { @@ -1392,4 +1488,772 @@ describeE2E("me", () => { }); }); }); -}); // Close outer describe.skipIf +}); // Close outer describeE2E + +// ============================================================================= +// me.apiTokens and me.invites tests +// ============================================================================= + +/** + * Create a trusted session for testing API token creation + */ +async function createTrustedSession( + db: Kysely, + userId: number, +): Promise<{ token: string; sessionId: number }> { + const token = `test-session-${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) }; +} + +describeE2E("me.apiTokens and me.invites", () => { + describe("me.apiTokens.list", () => { + test("returns empty list for user without tokens", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "notokens@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const tokens = await call(router.me.apiTokens.list, undefined, { context }); + + expect(tokens).toHaveLength(0); + }); + }); + + test("returns tokens for user with tokens", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "hastokens@example.com", + isSuperuser: true, + }); + + // Create some API tokens directly in DB + const tokenHash1 = await hashToken("token1"); + const tokenHash2 = await hashToken("token2"); + const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); + + await db + .insertInto("api_tokens") + .values([ + { user_id: user.id, token_hash: tokenHash1, name: "Token One", expires_at: expiresAt }, + { user_id: user.id, token_hash: tokenHash2, name: "Token Two", expires_at: expiresAt }, + ]) + .execute(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const tokens = await call(router.me.apiTokens.list, undefined, { context }); + + expect(tokens).toHaveLength(2); + const names = tokens.map((t) => t.name).sort(); + expect(names).toEqual(["Token One", "Token Two"]); + expect(tokens[0]).toHaveProperty("id"); + expect(tokens[0]).toHaveProperty("createdAt"); + expect(tokens[0]).toHaveProperty("expiresAt"); + }); + }); + + test("only returns current user tokens", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user1 = await createTestUser(db, { email: "user1@example.com", isSuperuser: true }); + const user2 = await createTestUser(db, { email: "user2@example.com", isSuperuser: true }); + + const tokenHash1 = await hashToken("token1"); + const tokenHash2 = await hashToken("token2"); + const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); + + await db + .insertInto("api_tokens") + .values([ + { user_id: user1.id, token_hash: tokenHash1, name: "User1 Token", expires_at: expiresAt }, + { user_id: user2.id, token_hash: tokenHash2, name: "User2 Token", expires_at: expiresAt }, + ]) + .execute(); + + const { token: sessionToken } = await createSession(db, user1.id); + const context = createAPIContext(db, { sessionToken }); + + const tokens = await call(router.me.apiTokens.list, undefined, { context }); + + expect(tokens).toHaveLength(1); + expect(tokens[0].name).toBe("User1 Token"); + }); + }); +}); + +describe("me.apiTokens.create", () => { + test("creates token for superuser with trusted session", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "superuser@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createTrustedSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.me.apiTokens.create, + { name: "My New Token" }, + { context }, + ); + + expect(result.token).toBeDefined(); + expect(result.token.startsWith("reviq_")).toBe(true); + expect(result.expiresAt).toBeDefined(); + + // Verify token was created in DB + const tokens = await db + .selectFrom("api_tokens") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + + expect(tokens).toHaveLength(1); + expect(tokens[0].name).toBe("My New Token"); + }); + }); + + test("rejects non-superuser", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "regular@example.com", + isSuperuser: false, + }); + + const { token: sessionToken } = await createTrustedSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.apiTokens.create, { name: "Test Token" }, { context }), + ).rejects.toThrow("Only superusers can create API tokens"); + }); + }); + + test("rejects untrusted session", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "superuser2@example.com", + isSuperuser: true, + }); + + // Use regular session (not trusted) + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.apiTokens.create, { name: "Test Token" }, { context }), + ).rejects.toThrow("Creating API tokens requires a trusted session"); + }); + }); +}); + +describe("me.apiTokens.delete", () => { + test("deletes own token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "deletetoken@example.com", + isSuperuser: true, + }); + + const tokenHash = await hashToken("token-to-delete"); + const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); + + const insertResult = await db + .insertInto("api_tokens") + .values({ user_id: user.id, token_hash: tokenHash, name: "To Delete", expires_at: expiresAt }) + .returning("id") + .executeTakeFirstOrThrow(); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.me.apiTokens.delete, + { tokenId: Number(insertResult.id) }, + { context }, + ); + + expect(result.success).toBe(true); + + // Verify token was deleted + const tokens = await db + .selectFrom("api_tokens") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + + expect(tokens).toHaveLength(0); + }); + }); + + test("cannot delete other user token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user1 = await createTestUser(db, { email: "owner@example.com", isSuperuser: true }); + const user2 = await createTestUser(db, { email: "other@example.com", isSuperuser: true }); + + const tokenHash = await hashToken("other-token"); + const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); + + const insertResult = await db + .insertInto("api_tokens") + .values({ user_id: user1.id, token_hash: tokenHash, name: "User1 Token", expires_at: expiresAt }) + .returning("id") + .executeTakeFirstOrThrow(); + + const { token: sessionToken } = await createSession(db, user2.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.apiTokens.delete, { tokenId: Number(insertResult.id) }, { context }), + ).rejects.toThrow("API token not found"); + }); + }); + + test("returns error for non-existent token", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const user = await createTestUser(db, { + email: "notoken@example.com", + isSuperuser: true, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.apiTokens.delete, { tokenId: 99999 }, { context }), + ).rejects.toThrow("API token not found"); + }); + }); +}); + +// ============================================================================= +// me.invites tests +// ============================================================================= + +describe("me.invites.list", () => { + test("returns empty list when email not verified", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter@example.com", + emailVerifiedAt: new Date(), + }); + const org = await createOrg(db, { slug: "test-org", displayName: "Test Org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + // User without verified email + const user = await createTestUser(db, { email: "unverified@example.com" }); + + // Create an invite for the unverified user + await createOrgInvite(db, { + orgId: org.id, + email: user.email, + invitedBy: inviter.id, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call(router.me.invites.list, undefined, { context }); + + expect(invites).toHaveLength(0); + }); + }); + + test("returns pending invites for verified user", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter2@example.com", + emailVerifiedAt: new Date(), + displayName: "Inviter Person", + }); + const org = await createOrg(db, { slug: "invite-org", displayName: "Invite Org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + const user = await createTestUser(db, { + email: "verified@example.com", + emailVerifiedAt: new Date(), + }); + + await createOrgInvite(db, { + orgId: org.id, + email: user.email, + invitedBy: inviter.id, + role: "admin", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call(router.me.invites.list, undefined, { context }); + + expect(invites).toHaveLength(1); + expect(invites[0].org.slug).toBe("invite-org"); + expect(invites[0].org.displayName).toBe("Invite Org"); + expect(invites[0].role).toBe("admin"); + expect(invites[0].invitedBy).toBe("Inviter Person"); + }); + }); + + test("does not return expired invites", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter3@example.com", + emailVerifiedAt: new Date(), + }); + const org = await createOrg(db, { slug: "expired-org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + const user = await createTestUser(db, { + email: "verified2@example.com", + emailVerifiedAt: new Date(), + }); + + // Create an expired invite + await createOrgInvite(db, { + orgId: org.id, + email: user.email, + invitedBy: inviter.id, + expiresAt: new Date(Date.now() - 1000), // Already expired + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const invites = await call(router.me.invites.list, undefined, { context }); + + expect(invites).toHaveLength(0); + }); + }); +}); + +describe("me.invites.get", () => { + test("returns invite details", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter4@example.com", + emailVerifiedAt: new Date(), + displayName: "The Inviter", + }); + const org = await createOrg(db, { slug: "get-invite-org", displayName: "Get Invite Org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + const user = await createTestUser(db, { + email: "getinvite@example.com", + emailVerifiedAt: new Date(), + }); + + const invite = await createOrgInvite(db, { + orgId: org.id, + email: user.email, + invitedBy: inviter.id, + role: "member", + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + const result = await call( + router.me.invites.get, + { inviteId: invite.id }, + { context }, + ); + + expect(result.id).toBe(invite.id); + expect(result.org.slug).toBe("get-invite-org"); + expect(result.role).toBe("member"); + expect(result.invitedBy).toBe("The Inviter"); + }); + }); + + test("rejects if email not verified", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter5@example.com", + emailVerifiedAt: new Date(), + }); + const org = await createOrg(db, { slug: "unverified-get-org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + const user = await createTestUser(db, { email: "unverified2@example.com" }); + + const invite = await createOrgInvite(db, { + orgId: org.id, + email: user.email, + invitedBy: inviter.id, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.invites.get, { inviteId: invite.id }, { context }), + ).rejects.toThrow("Please verify your email to view invitations"); + }); + }); + + test("returns not found for other user invite", async () => { + await withTestTransaction(getSharedDb(), async (db) => { + const inviter = await createTestUser(db, { + email: "inviter6@example.com", + emailVerifiedAt: new Date(), + }); + const org = await createOrg(db, { slug: "other-user-org" }); + await addOrgMember(db, org.id, inviter.id, "owner"); + + const otherUser = await createTestUser(db, { + email: "other@example.com", + emailVerifiedAt: new Date(), + }); + const user = await createTestUser(db, { + email: "requestor@example.com", + emailVerifiedAt: new Date(), + }); + + // Invite is for otherUser, not user + const invite = await createOrgInvite(db, { + orgId: org.id, + email: otherUser.email, + invitedBy: inviter.id, + }); + + const { token: sessionToken } = await createSession(db, user.id); + const context = createAPIContext(db, { sessionToken }); + + await expect( + call(router.me.invites.get, { inviteId: invite.id }, { context }), + ).rejects.toThrow("Invitation not found or expired"); + }); + }); +}); + +describe("me.invites.accept", () => { + test("accepts invite and adds user to org", async () => { + const db = getSharedDb(); + const uniqueId = `${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"); + }); + }); +}); // 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 new file mode 100644 index 0000000..ab82835 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/orgs.test.ts @@ -0,0 +1,1572 @@ +/** + * 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 { beforeAll, describe, expect, test } from "bun:test"; +import { call } from "@orpc/server"; +import { + createTestUser, + describeE2E, + 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"; + +/** 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 }; +} + +describeE2E("orgs", () => { + beforeAll(async () => { + await initTestDb(); + }); + + // ===== 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"); + }); + }); +}); +}); // Close describeE2E("orgs") 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); }