- 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>
223 lines
5.6 KiB
TypeScript
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();
|
|
}
|