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>
This commit is contained in:
222
apps/api-server/src/__tests__/helpers/test-db.ts
Normal file
222
apps/api-server/src/__tests__/helpers/test-db.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user