Add tests for @reviq/db package

- token.test.ts: Unit tests for generateToken, parseToken, hashToken
- client.test.ts: Tests for createDb validation and e2e connectivity
- execute-bootstrap.test.ts: Comprehensive e2e tests for bootstrap
  operation including overwrite mode and related record cleanup

Coverage: client.ts 100%, token.ts 100%, execute-bootstrap.ts 98.69%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 15:35:02 +08:00
parent 5a2e0297e5
commit 58ffa68f4c
3 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
/**
* Tests for the Kysely database client
*/
import { describe, expect, test } from "bun:test";
import { createDb } from "./client.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);
describe("createDb", () => {
test("throws error for empty connection string", () => {
expect(() => createDb("")).toThrow("Database connection string is required");
});
test("throws error for null-ish connection string", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(null as any)).toThrow(
"Database connection string is required",
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- testing edge case
expect(() => createDb(undefined as any)).toThrow(
"Database connection string is required",
);
});
});
describeE2E("[e2e] createDb with real database", () => {
test("creates working database connection", async () => {
const testUrl = process.env.TEST_DATABASE_URL;
if (!testUrl) {
throw new Error("TEST_DATABASE_URL not set");
}
const db = createDb(testUrl);
try {
// Verify the connection works by executing a simple query
const result = await db
.selectFrom("users")
.select(["id"])
.limit(1)
.execute();
// Should return an array (may be empty)
expect(Array.isArray(result)).toBe(true);
} finally {
await db.destroy();
}
});
});

View File

@@ -0,0 +1,697 @@
/**
* 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}-${testCounter.toString()}`;
};
/** Truncate all tables */
async function truncateAllTables(db: Kysely<Database>): Promise<void> {
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<T>(
db: Kysely<Database>,
testFn: (trx: Kysely<Database>) => Promise<T>,
): Promise<T | undefined> {
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<Database>;
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 () => {
if (db) {
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) => {
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) => {
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) => {
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
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"])
.execute();
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"])
.execute();
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);
});
});
});

View File

@@ -0,0 +1,136 @@
/**
* Tests for token generation and hashing utilities
*/
import { describe, expect, test } from "bun:test";
import { generateToken, hashToken, parseToken, TOKEN_PREFIX } from "./token.js";
describe("token utilities", () => {
describe("TOKEN_PREFIX", () => {
test("has expected value", () => {
expect(TOKEN_PREFIX).toBe("reviq_");
});
});
describe("generateToken", () => {
test("generates token with correct prefix", () => {
const token = generateToken();
expect(token.startsWith(TOKEN_PREFIX)).toBe(true);
});
test("generates unique tokens", () => {
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(generateToken());
}
expect(tokens.size).toBe(100);
});
test("generates token of expected length", () => {
const token = generateToken();
// reviq_ (6 chars) + base58 encoded 32 bytes (~44 chars)
expect(token.length).toBeGreaterThan(40);
expect(token.length).toBeLessThan(60);
});
});
describe("parseToken", () => {
test("parses valid token and returns bytes", () => {
const token = generateToken();
const bytes = parseToken(token);
expect(bytes).not.toBeNull();
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes?.length).toBe(32);
});
test("returns null for token without prefix", () => {
const result = parseToken("invalid_token_without_prefix");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = parseToken("");
expect(result).toBeNull();
});
test("returns null for token with wrong prefix", () => {
const result = parseToken("wrong_prefix_abc123");
expect(result).toBeNull();
});
test("returns null for token with invalid base58", () => {
// Include invalid base58 characters (0, O, I, l)
const result = parseToken(`${TOKEN_PREFIX}invalid0OIl`);
expect(result).toBeNull();
});
test("returns null for token with wrong byte length", () => {
// Create a valid base58 string but with fewer bytes
// base58 encode a 16-byte value (too short)
const result = parseToken(`${TOKEN_PREFIX}2VQr`);
expect(result).toBeNull();
});
test("returns same bytes for same token", () => {
const token = generateToken();
const bytes1 = parseToken(token);
const bytes2 = parseToken(token);
expect(bytes1).toEqual(bytes2);
});
});
describe("hashToken", () => {
test("returns hex string", () => {
const token = generateToken();
const hash = hashToken(token);
// SHA-256 produces 32 bytes = 64 hex chars
expect(hash.length).toBe(64);
expect(/^[0-9a-f]+$/.test(hash)).toBe(true);
});
test("produces deterministic hash", () => {
const token = generateToken();
const hash1 = hashToken(token);
const hash2 = hashToken(token);
expect(hash1).toBe(hash2);
});
test("produces different hashes for different tokens", () => {
const token1 = generateToken();
const token2 = generateToken();
expect(hashToken(token1)).not.toBe(hashToken(token2));
});
test("hashes any string input", () => {
const hash = hashToken("arbitrary string input");
expect(hash.length).toBe(64);
});
test("hashes empty string", () => {
const hash = hashToken("");
// SHA-256 of empty string is a known value
expect(hash).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});
});
describe("round-trip", () => {
test("generated tokens can be parsed and hashed", () => {
for (let i = 0; i < 10; i++) {
const token = generateToken();
const bytes = parseToken(token);
const hash = hashToken(token);
expect(bytes).not.toBeNull();
expect(bytes?.length).toBe(32);
expect(hash.length).toBe(64);
}
});
});
});