/** * Tests for the bootstrap operation * * These tests use a real PostgreSQL database to test the executeBootstrap function. */ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { sql } from "kysely"; import { createDb } from "../client.js"; import { executeBootstrap } from "./execute-bootstrap.js"; import { hashToken, parseToken, TOKEN_PREFIX } from "./token.js"; /** * Skip flag for database-dependent tests. * Tests are skipped when TEST_DATABASE_URL is not configured. */ const SKIP_DB_TESTS = !process.env.TEST_DATABASE_URL; const describeE2E = describe.skipIf(SKIP_DB_TESTS); /** Tables to truncate between tests */ const TABLES_TO_TRUNCATE = [ "sessions", "api_tokens", "login_requests", "passkeys", "user_devices", "webauthn_challenges", "email_verifications", "password_resets", "org_invites", "org_sites", "org_members", "orgs", "users", ] as const; /** Generate unique test ID */ let testCounter = 0; const uniqueTestId = (): string => { const timestamp = Date.now(); testCounter++; return `${timestamp.toString()}-${testCounter.toString()}`; }; /** Truncate all tables */ async function truncateAllTables(db: Kysely): Promise { const tableList = TABLES_TO_TRUNCATE.join(", "); await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute( db, ); } /** Signal for transaction rollback */ class RollbackSignal extends Error { constructor() { super("RollbackSignal"); this.name = "RollbackSignal"; } } /** Run test in transaction that auto-rollbacks */ async function withTestTransaction( db: Kysely, testFn: (trx: Kysely) => Promise, ): Promise { let result: T | undefined; try { await db.transaction().execute(async (trx) => { result = await testFn(trx); throw new RollbackSignal(); }); } catch (e) { if (!(e instanceof RollbackSignal)) { throw e; } } return result; } describeE2E("[e2e] executeBootstrap", () => { let db: Kysely; beforeAll(async () => { const testUrl = process.env.TEST_DATABASE_URL; if (!testUrl) { throw new Error("TEST_DATABASE_URL not set"); } db = createDb(testUrl); await truncateAllTables(db); }); afterAll(async () => { await truncateAllTables(db); await db.destroy(); }); test("creates superuser with correct email and password", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", }); expect(result.user.email).toBe("admin@example.com"); // Verify user in database const user = await trx .selectFrom("users") .where("id", "=", result.user.id) .selectAll() .executeTakeFirstOrThrow(); expect(user.email).toBe("admin@example.com"); expect(user.is_superuser).toBe(true); expect(user.email_verified_at).not.toBeNull(); expect(user.password_hash).not.toBeNull(); }); }); test("normalizes email to lowercase", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "ADMIN@EXAMPLE.COM", password: "password123", }); expect(result.user.email).toBe("admin@example.com"); }); }); test("creates organization with default slug and name", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", }); expect(result.org.slug).toBe("reviq"); // Verify org in database const org = await trx .selectFrom("orgs") .where("id", "=", result.org.id) .selectAll() .executeTakeFirstOrThrow(); expect(org.slug).toBe("reviq"); expect(org.display_name).toBe("RevIQ"); }); }); test("creates organization with custom slug and name", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", orgSlug: "custom-org", orgDisplayName: "Custom Organization", }); expect(result.org.slug).toBe("custom-org"); const org = await trx .selectFrom("orgs") .where("id", "=", result.org.id) .selectAll() .executeTakeFirstOrThrow(); expect(org.slug).toBe("custom-org"); expect(org.display_name).toBe("Custom Organization"); }); }); test("adds user as owner of organization", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", }); const membership = await trx .selectFrom("org_members") .where("user_id", "=", result.user.id) .where("org_id", "=", result.org.id) .selectAll() .executeTakeFirstOrThrow(); expect(membership.role).toBe("owner"); }); }); test("creates API token with correct properties", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", }); // Token should be parseable expect(result.token.startsWith(TOKEN_PREFIX)).toBe(true); expect(parseToken(result.token)).not.toBeNull(); // Token should be stored as hash in database const tokenRecord = await trx .selectFrom("api_tokens") .where("user_id", "=", result.user.id) .selectAll() .executeTakeFirstOrThrow(); expect(tokenRecord.token_hash).toBe(hashToken(result.token)); expect(tokenRecord.name).toBe("CLI bootstrap token"); }); }); test("creates API token with custom name", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", tokenName: "Custom Token Name", }); const tokenRecord = await trx .selectFrom("api_tokens") .where("user_id", "=", result.user.id) .selectAll() .executeTakeFirstOrThrow(); expect(tokenRecord.name).toBe("Custom Token Name"); }); }); test("creates API token with custom expiration", async () => { await withTestTransaction(db, async (trx) => { const beforeCreate = Date.now(); const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", tokenExpirationDays: 30, }); const tokenRecord = await trx .selectFrom("api_tokens") .where("user_id", "=", result.user.id) .selectAll() .executeTakeFirstOrThrow(); const expectedMin = beforeCreate + 30 * 24 * 60 * 60 * 1000 - 1000; const expectedMax = beforeCreate + 30 * 24 * 60 * 60 * 1000 + 5000; expect(tokenRecord.expires_at.getTime()).toBeGreaterThan(expectedMin); expect(tokenRecord.expires_at.getTime()).toBeLessThan(expectedMax); }); }); test("throws error for password less than 8 characters", async () => { await withTestTransaction(db, async (trx) => { // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( executeBootstrap(trx, { email: "admin@example.com", password: "short", }), ).rejects.toThrow("Password must be at least 8 characters"); }); }); test("throws error for password exactly 7 characters", async () => { await withTestTransaction(db, async (trx) => { // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( executeBootstrap(trx, { email: "admin@example.com", password: "1234567", }), ).rejects.toThrow("Password must be at least 8 characters"); }); }); test("accepts password exactly 8 characters", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "12345678", }); expect(result.user.email).toBe("admin@example.com"); }); }); test("throws error for invalid email without @", async () => { await withTestTransaction(db, async (trx) => { // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( executeBootstrap(trx, { email: "invalidemail", password: "password123", }), ).rejects.toThrow("Invalid email address"); }); }); test("accepts email with @ symbol", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "valid@email", password: "password123", }); expect(result.user.email).toBe("valid@email"); }); }); test("throws error if user already exists (normal mode)", async () => { await withTestTransaction(db, async (trx) => { // Create the first user await executeBootstrap(trx, { email: "admin@example.com", password: "password123", orgSlug: "org1", }); // Attempt to create the same user again // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( executeBootstrap(trx, { email: "admin@example.com", password: "password123", orgSlug: "org2", }), ).rejects.toThrow("User with email admin@example.com already exists"); }); }); test("overwrites existing user in dangerouslyOverwriteExisting mode", async () => { await withTestTransaction(db, async (trx) => { // Create the first user const result1 = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", orgSlug: "original-org", }); const originalUserId = result1.user.id; // Overwrite the user const result2 = await executeBootstrap(trx, { email: "admin@example.com", password: "newpassword123", orgSlug: "new-org", dangerouslyOverwriteExisting: true, }); // Should be a different user ID expect(result2.user.id).not.toBe(originalUserId); // Original user should be deleted const originalUser = await trx .selectFrom("users") .where("id", "=", originalUserId) .selectAll() .executeTakeFirst(); expect(originalUser).toBeUndefined(); // New user should exist const newUser = await trx .selectFrom("users") .where("id", "=", result2.user.id) .selectAll() .executeTakeFirst(); expect(newUser).toBeDefined(); }); }); test("deletes existing org in dangerouslyOverwriteExisting mode", async () => { await withTestTransaction(db, async (trx) => { // Create the first bootstrap const result1 = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", orgSlug: "test-org", }); const originalOrgId = result1.org.id; // Overwrite with a different email but same org slug const result2 = await executeBootstrap(trx, { email: "newadmin@example.com", password: "password123", orgSlug: "test-org", dangerouslyOverwriteExisting: true, }); // Should be a different org ID expect(result2.org.id).not.toBe(originalOrgId); // Original org should be deleted const originalOrg = await trx .selectFrom("orgs") .where("id", "=", originalOrgId) .selectAll() .executeTakeFirst(); expect(originalOrg).toBeUndefined(); }); }); test("deletes related user records in overwrite mode", async () => { const uniqueId = uniqueTestId(); await withTestTransaction(db, async (trx) => { // Create the first bootstrap const result1 = await executeBootstrap(trx, { email: `admin-${uniqueId}@example.com`, password: "password123", orgSlug: `org-${uniqueId}`, }); // Manually add some related records await trx .insertInto("sessions") .values({ user_id: result1.user.id, token_hash: "test-hash", ip_address: "127.0.0.1", user_agent: "test", expires_at: new Date(Date.now() + 86400000), trusted_mode: false, }) .execute(); await trx .insertInto("email_verifications") .values({ user_id: result1.user.id, token: "test-token", expires_at: new Date(Date.now() + 86400000), }) .execute(); await trx .insertInto("login_requests") .values({ user_id: result1.user.id, email: `admin-${uniqueId}@example.com`, token: "login-token", device_fingerprint: "fingerprint", expires_at: new Date(Date.now() + 86400000), }) .execute(); await trx .insertInto("passkeys") .values({ user_id: result1.user.id, credential_id: Buffer.from("credential"), public_key: Buffer.from("publickey"), counter: 0, backup_eligible: false, backup_status: false, device_type: "singleDevice", name: "Test Passkey", rpid: "localhost", webauthn_user_id: "test-user-id", }) .execute(); await trx .insertInto("password_resets") .values({ user_id: result1.user.id, token: "reset-token", expires_at: new Date(Date.now() + 86400000), }) .execute(); await trx .insertInto("user_devices") .values({ user_id: result1.user.id, device_fingerprint: "device-fingerprint", user_agent: "test-agent", }) .execute(); // Overwrite the user await executeBootstrap(trx, { email: `admin-${uniqueId}@example.com`, password: "newpassword123", orgSlug: `org-${uniqueId}`, dangerouslyOverwriteExisting: true, }); // All related records should be deleted const sessions = await trx .selectFrom("sessions") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(sessions).toHaveLength(0); const emailVerifications = await trx .selectFrom("email_verifications") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(emailVerifications).toHaveLength(0); const loginRequests = await trx .selectFrom("login_requests") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(loginRequests).toHaveLength(0); const passkeys = await trx .selectFrom("passkeys") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(passkeys).toHaveLength(0); const passwordResets = await trx .selectFrom("password_resets") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(passwordResets).toHaveLength(0); const userDevices = await trx .selectFrom("user_devices") .where("user_id", "=", result1.user.id) .selectAll() .execute(); expect(userDevices).toHaveLength(0); }); }); test("deletes org invites created by user in overwrite mode", async () => { const uniqueId = uniqueTestId(); await withTestTransaction(db, async (trx) => { // Create the first bootstrap const result1 = await executeBootstrap(trx, { email: `admin-${uniqueId}@example.com`, password: "password123", orgSlug: `org-${uniqueId}`, }); // Create another org and invite const otherOrg = await trx .insertInto("orgs") .values({ slug: `other-org-${uniqueId}`, display_name: "Other Org", }) .returning(["id"]) .executeTakeFirstOrThrow(); await trx .insertInto("org_invites") .values({ org_id: otherOrg.id, email: "invitee@example.com", role: "member", invited_by: result1.user.id, token: "invite-token", expires_at: new Date(Date.now() + 86400000), }) .execute(); // Overwrite the user await executeBootstrap(trx, { email: `admin-${uniqueId}@example.com`, password: "newpassword123", orgSlug: `new-org-${uniqueId}`, dangerouslyOverwriteExisting: true, }); // Invite created by the user should be deleted const invites = await trx .selectFrom("org_invites") .where("invited_by", "=", result1.user.id) .selectAll() .execute(); expect(invites).toHaveLength(0); }); }); test("deletes org related records in overwrite mode", async () => { const uniqueId = uniqueTestId(); await withTestTransaction(db, async (trx) => { // Create the first bootstrap const result1 = await executeBootstrap(trx, { email: `admin-${uniqueId}@example.com`, password: "password123", orgSlug: `org-${uniqueId}`, }); // Add org sites await trx .insertInto("org_sites") .values({ org_id: result1.org.id, domain: "example.com", }) .execute(); // Add org invites (to the org, not by the user) const anotherUser = await trx .insertInto("users") .values({ email: `other-${uniqueId}@example.com`, display_name: "Other User", }) .returning(["id"]) .executeTakeFirstOrThrow(); await trx .insertInto("org_invites") .values({ org_id: result1.org.id, email: "invitee@example.com", role: "member", invited_by: anotherUser.id, token: "invite-token-2", expires_at: new Date(Date.now() + 86400000), }) .execute(); // Overwrite with the same org slug await executeBootstrap(trx, { email: `newadmin-${uniqueId}@example.com`, password: "password123", orgSlug: `org-${uniqueId}`, dangerouslyOverwriteExisting: true, }); // Org sites should be deleted const sites = await trx .selectFrom("org_sites") .where("org_id", "=", result1.org.id) .selectAll() .execute(); expect(sites).toHaveLength(0); // Org invites should be deleted const invites = await trx .selectFrom("org_invites") .where("org_id", "=", result1.org.id) .selectAll() .execute(); expect(invites).toHaveLength(0); }); }); test("succeeds when no existing user/org in overwrite mode", async () => { const uniqueId = uniqueTestId(); await withTestTransaction(db, async (trx) => { // Should not throw even when nothing exists to overwrite const result = await executeBootstrap(trx, { email: `fresh-${uniqueId}@example.com`, password: "password123", orgSlug: `fresh-org-${uniqueId}`, dangerouslyOverwriteExisting: true, }); expect(result.user.email).toBe(`fresh-${uniqueId}@example.com`); expect(result.org.slug).toBe(`fresh-org-${uniqueId}`); }); }); test("returns all expected fields", async () => { await withTestTransaction(db, async (trx) => { const result = await executeBootstrap(trx, { email: "admin@example.com", password: "password123", }); // Check user fields expect(typeof result.user.id).toBe("number"); expect(typeof result.user.email).toBe("string"); // Check org fields expect(typeof result.org.id).toBe("number"); expect(typeof result.org.slug).toBe("string"); // Check token expect(typeof result.token).toBe("string"); expect(result.token.startsWith(TOKEN_PREFIX)).toBe(true); }); }); });