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:
1039
apps/api-server/src/__tests__/e2e/webauthn.test.ts
Normal file
1039
apps/api-server/src/__tests__/e2e/webauthn.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
26
apps/api-server/src/__tests__/helpers/test-constants.ts
Normal file
26
apps/api-server/src/__tests__/helpers/test-constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Test constants for WebAuthn e2e tests
|
||||
*/
|
||||
|
||||
/** Test Relying Party configuration */
|
||||
export const TEST_RP = {
|
||||
rpName: "Test App",
|
||||
rpID: "localhost",
|
||||
origin: "http://localhost:3000",
|
||||
allowedOrigins: ["http://localhost:3000"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Known AAGUIDs for testing passkey name assignment.
|
||||
* These match the AAGUID_MAP in webauthn.ts
|
||||
*/
|
||||
export const KNOWN_AAGUIDS = {
|
||||
ICLOUD_KEYCHAIN: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
|
||||
GOOGLE_PASSWORD_MANAGER: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4",
|
||||
CHROME_ON_MAC: "adce0002-35bc-c60a-648b-0b25f1f05503",
|
||||
WINDOWS_HELLO: "6028b017-b1d4-4c02-b4b3-afcdafc96bb2",
|
||||
ONEPASSWORD: "bada5566-a7aa-401f-bd96-45619a55120d",
|
||||
} as const;
|
||||
|
||||
/** Default AAGUID for virtual authenticator (unknown authenticator) */
|
||||
export const DEFAULT_TEST_AAGUID = "00000000-0000-0000-0000-000000000000";
|
||||
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();
|
||||
}
|
||||
191
apps/api-server/src/__tests__/unit/passkey-helpers.test.ts
Normal file
191
apps/api-server/src/__tests__/unit/passkey-helpers.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Unit tests for passkey-helpers utility functions
|
||||
*/
|
||||
|
||||
import type { PasskeyRow } from "../../utils/passkey-helpers.js";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
base64urlToUint8Array,
|
||||
formatPasskeyDate,
|
||||
parsePasskeyRow,
|
||||
uint8ArrayToBase64url,
|
||||
} from "../../utils/passkey-helpers.js";
|
||||
|
||||
describe("base64urlToUint8Array", () => {
|
||||
test("converts base64url string to Uint8Array", () => {
|
||||
// "Hello" in base64url
|
||||
const base64url = "SGVsbG8";
|
||||
const result = base64urlToUint8Array(base64url);
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(Buffer.from(result).toString()).toBe("Hello");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = base64urlToUint8Array("");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles base64url without padding", () => {
|
||||
// "abc" in base64url (no padding needed)
|
||||
const base64url = "YWJj";
|
||||
const result = base64urlToUint8Array(base64url);
|
||||
expect(Buffer.from(result).toString()).toBe("abc");
|
||||
});
|
||||
|
||||
test("handles binary data with special characters", () => {
|
||||
// Binary data that would have + and / in standard base64
|
||||
const original = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const base64url = Buffer.from(original).toString("base64url");
|
||||
const result = base64urlToUint8Array(base64url);
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uint8ArrayToBase64url", () => {
|
||||
test("converts Uint8Array to base64url string", () => {
|
||||
// "Hello" as bytes
|
||||
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const result = uint8ArrayToBase64url(bytes);
|
||||
|
||||
expect(result).toBe("SGVsbG8");
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
const result = uint8ArrayToBase64url(new Uint8Array([]));
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
test("produces URL-safe output (no +, /, or =)", () => {
|
||||
// Binary data that would produce + and / in standard base64
|
||||
const bytes = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]);
|
||||
const result = uint8ArrayToBase64url(bytes);
|
||||
|
||||
expect(result).not.toContain("+");
|
||||
expect(result).not.toContain("/");
|
||||
expect(result).not.toContain("=");
|
||||
});
|
||||
|
||||
test("roundtrips correctly with base64urlToUint8Array", () => {
|
||||
const original = new Uint8Array([1, 2, 3, 4, 5, 100, 200, 255]);
|
||||
const encoded = uint8ArrayToBase64url(original);
|
||||
const decoded = base64urlToUint8Array(encoded);
|
||||
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePasskeyRow", () => {
|
||||
const createMockRow = (overrides: Partial<PasskeyRow> = {}): PasskeyRow => ({
|
||||
id: 1,
|
||||
user_id: 100,
|
||||
credential_id: new Uint8Array([1, 2, 3, 4, 5]),
|
||||
public_key: new Uint8Array([10, 20, 30, 40, 50]),
|
||||
webauthn_user_id: "webauthn-user-123",
|
||||
counter: "42",
|
||||
device_type: "multiDevice",
|
||||
backup_eligible: true,
|
||||
backup_status: true,
|
||||
transports: ["internal", "hybrid"],
|
||||
rpid: "localhost",
|
||||
name: "Test Passkey",
|
||||
last_used_at: new Date("2024-01-15T10:00:00Z"),
|
||||
created_at: new Date("2024-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("converts DB row to ParsedPasskey format", () => {
|
||||
const row = createMockRow();
|
||||
const result = parsePasskeyRow(row);
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.credentialId).toBe(uint8ArrayToBase64url(row.credential_id));
|
||||
expect(result.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(result.counter).toBe(42);
|
||||
expect(result.deviceType).toBe("multiDevice");
|
||||
expect(result.backupEligible).toBe(true);
|
||||
expect(result.backupStatus).toBe(true);
|
||||
expect(result.transports).toEqual(["internal", "hybrid"]);
|
||||
expect(result.rpid).toBe("localhost");
|
||||
expect(result.name).toBe("Test Passkey");
|
||||
});
|
||||
|
||||
test("handles string counter", () => {
|
||||
const row = createMockRow({ counter: "100" });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.counter).toBe(100);
|
||||
});
|
||||
|
||||
test("handles number counter", () => {
|
||||
const row = createMockRow({ counter: 200 });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.counter).toBe(200);
|
||||
});
|
||||
|
||||
test("handles bigint counter", () => {
|
||||
const row = createMockRow({ counter: BigInt(300) });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.counter).toBe(300);
|
||||
});
|
||||
|
||||
test("handles null transports", () => {
|
||||
const row = createMockRow({ transports: null });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.transports).toBeNull();
|
||||
});
|
||||
|
||||
test("handles null last_used_at", () => {
|
||||
const row = createMockRow({ last_used_at: null });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.lastUsedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves created_at date", () => {
|
||||
const createdAt = new Date("2024-06-15T12:00:00Z");
|
||||
const row = createMockRow({ created_at: createdAt });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.createdAt).toEqual(createdAt);
|
||||
});
|
||||
|
||||
test("handles singleDevice device type", () => {
|
||||
const row = createMockRow({ device_type: "singleDevice" });
|
||||
const result = parsePasskeyRow(row);
|
||||
expect(result.deviceType).toBe("singleDevice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPasskeyDate", () => {
|
||||
test("formats date with month, day, year, and time", () => {
|
||||
const date = new Date("2024-01-15T10:30:00");
|
||||
const result = formatPasskeyDate(date);
|
||||
|
||||
// Should contain month abbreviation
|
||||
expect(result).toContain("Jan");
|
||||
// Should contain day
|
||||
expect(result).toContain("15");
|
||||
// Should contain year
|
||||
expect(result).toContain("2024");
|
||||
});
|
||||
|
||||
test("includes time component", () => {
|
||||
const date = new Date("2024-06-20T14:45:00");
|
||||
const result = formatPasskeyDate(date);
|
||||
|
||||
// Should contain time in some format
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("handles different months", () => {
|
||||
const dates = [
|
||||
{ date: new Date("2024-03-01"), month: "Mar" },
|
||||
{ date: new Date("2024-07-15"), month: "Jul" },
|
||||
{ date: new Date("2024-12-31"), month: "Dec" },
|
||||
];
|
||||
|
||||
for (const { date, month } of dates) {
|
||||
const result = formatPasskeyDate(date);
|
||||
expect(result).toContain(month);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,20 @@ const createRegistrationOptions =
|
||||
const ctx = context as APIContext;
|
||||
const { email } = input;
|
||||
|
||||
// For signup flow, we don't have a user yet
|
||||
// The user will be created when signup is called with the passkeyInfo
|
||||
// Look up existing user by email to exclude their credentials
|
||||
const existingUser = await ctx.db
|
||||
.selectFrom("users")
|
||||
.select(["id", "display_name"])
|
||||
.where("email", "=", email)
|
||||
.executeTakeFirst();
|
||||
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
|
||||
const result = await createRegOptions(ctx.db, rpInfo, { email });
|
||||
const result = await createRegOptions(ctx.db, rpInfo, {
|
||||
id: existingUser?.id,
|
||||
email,
|
||||
displayName: existingUser?.display_name,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ParsedPasskey {
|
||||
* Raw passkey row from database
|
||||
*/
|
||||
export interface PasskeyRow {
|
||||
id: number;
|
||||
id: string | number; // Int8 from DB comes as string
|
||||
user_id: number;
|
||||
credential_id: Uint8Array;
|
||||
public_key: Uint8Array;
|
||||
@@ -64,7 +64,7 @@ export const parsePasskeyRow = (row: PasskeyRow): ParsedPasskey => {
|
||||
const publicKeyBytes = new Uint8Array(row.public_key);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
id: Number(row.id), // Convert Int8 (string) to number
|
||||
credentialId: uint8ArrayToBase64url(row.credential_id),
|
||||
publicKey: publicKeyBytes,
|
||||
counter: Number(row.counter),
|
||||
|
||||
Reference in New Issue
Block a user