Files
publisher-dashboard/apps/api-server/src/__tests__/helpers/test-db.ts
RevIQ bd9be3e441 Add comprehensive WebAuthn e2e/unit tests and virtual authenticator package
- Create @reviq/virtual-authenticator package with cryptographically valid
  WebAuthn credential generation for testing
- Add e2e tests for WebAuthn registration, authentication, passkey management
- Add unit tests for passkey-helpers and VirtualAuthenticator
- Add security tests for counter replay and tampered responses
- Configure test database environment in devenv.nix
- Add turbo.json test tasks and workspace configuration

Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:46:02 +08:00

223 lines
5.6 KiB
TypeScript

/**
* Test database utilities for e2e tests
*/
import type { Database } from "@reviq/db-schema";
import { join } from "node:path";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
const { Pool, Client } = pg;
/** Tables to truncate between tests (in order that respects foreign keys) */
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;
/**
* Creates a test database connection.
* Uses TEST_DATABASE_URL env var, falls back to DATABASE_URL with _test suffix.
*/
export function createTestDb(): Kysely<Database> {
const connectionString = getTestDatabaseUrl();
if (!connectionString) {
throw new Error(
"Test database URL not configured. Set TEST_DATABASE_URL environment variable.",
);
}
const dialect = new PostgresDialect({
pool: new Pool({ connectionString }),
});
return new Kysely<Database>({ dialect });
}
/**
* Gets the test database URL from environment.
* Adds sslmode=disable for local development.
*/
export function getTestDatabaseUrl(): string {
let url: string;
// Prefer explicit TEST_DATABASE_URL
if (Bun.env.TEST_DATABASE_URL) {
url = Bun.env.TEST_DATABASE_URL;
} else if (Bun.env.DATABASE_URL) {
// Fall back to DATABASE_URL with _test suffix
const parsed = new URL(Bun.env.DATABASE_URL);
parsed.pathname = `${parsed.pathname}_test`;
url = parsed.toString();
} else {
return "";
}
// Add sslmode=disable for local postgres
const parsed = new URL(url);
if (!parsed.searchParams.has("sslmode")) {
parsed.searchParams.set("sslmode", "disable");
}
return parsed.toString();
}
/**
* Parses a postgres URL to extract components
*/
function parsePostgresUrl(url: string): {
host: string;
port: number;
user: string;
password: string;
database: string;
} {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: Number.parseInt(parsed.port || "5432", 10),
user: parsed.username,
password: parsed.password,
database: parsed.pathname.slice(1), // Remove leading /
};
}
/**
* Creates the test database if it doesn't exist.
*/
async function ensureTestDatabaseExists(): Promise<void> {
const testDbUrl = getTestDatabaseUrl();
if (!testDbUrl) {
throw new Error("Test database URL not configured");
}
const { host, port, user, password, database } = parsePostgresUrl(testDbUrl);
// Connect to 'postgres' database to create the test database
const client = new Client({
host,
port,
user,
password,
database: "postgres",
});
try {
await client.connect();
// Check if database exists
const result = await client.query(
"SELECT 1 FROM pg_database WHERE datname = $1",
[database],
);
if (result.rows.length === 0) {
// Create the database
// Note: database names can't be parameterized, but we control this value
await client.query(`CREATE DATABASE "${database}"`);
console.log(`Created test database: ${database}`);
}
} finally {
await client.end();
}
}
/**
* Runs database migrations using dbmate CLI.
* Creates the database if it doesn't exist.
* Should be called once before running test suite.
*/
export async function runMigrations(): Promise<void> {
const testDbUrl = getTestDatabaseUrl();
if (!testDbUrl) {
throw new Error("Test database URL not configured");
}
// Ensure the database exists first
await ensureTestDatabaseExists();
// Find the repo root (where db/migrations lives)
// From apps/api-server/src/__tests__/helpers/ -> repo root is 5 levels up
const repoRoot = join(import.meta.dir, "../../../../..");
const proc = Bun.spawn(["dbmate", "up"], {
env: { ...process.env, DATABASE_URL: testDbUrl },
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(
`Migration failed with code ${String(exitCode)}: ${stderr}`,
);
}
}
/**
* Truncates all tables to reset database state.
* Uses TRUNCATE ... CASCADE to handle foreign keys.
*/
export async function truncateAllTables(db: Kysely<Database>): Promise<void> {
// Use a single TRUNCATE statement with CASCADE for efficiency
const tableList = TABLES_TO_TRUNCATE.join(", ");
await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute(
db,
);
}
/**
* Creates a test user for e2e tests.
* Returns the created user with ID.
*/
export async function createTestUser(
db: Kysely<Database>,
overrides: Partial<{
email: string;
displayName: string;
fullName: string;
passwordHash: string;
emailVerifiedAt: Date;
isSuperuser: boolean;
}> = {},
): Promise<{ id: number; email: string }> {
const email = overrides.email ?? `test-${String(Date.now())}@example.com`;
const result = await db
.insertInto("users")
.values({
email,
display_name: overrides.displayName ?? "Test User",
full_name: overrides.fullName ?? null,
password_hash: overrides.passwordHash ?? null,
email_verified_at: overrides.emailVerifiedAt ?? null,
is_superuser: overrides.isSuperuser ?? false,
})
.returning(["id", "email"])
.executeTakeFirstOrThrow();
return result;
}
/**
* Destroys the database connection pool.
* Call this in afterAll() to clean up.
*/
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
await db.destroy();
}