Merge branch 'wt3': WebAuthn enhancements and virtual authenticator

- Enhanced createRegistrationOptions to look up existing users
- Added virtual-authenticator testing package
- Added WebAuthn e2e and unit tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 16:55:14 +08:00
19 changed files with 2684 additions and 11 deletions

210
apps/api-server/README.md Normal file
View File

@@ -0,0 +1,210 @@
# API Server
Backend API server for the publisher dashboard.
## Development
```bash
# Start development server
bun run dev
# Type check
bun run typecheck
# Lint
bun run lint
```
## Testing
### Running Tests
```bash
# Run e2e tests (requires PostgreSQL)
bun run test:e2e
# Run unit tests
bun run test:unit
```
### E2E Test Setup
E2E tests use a real PostgreSQL database. The test infrastructure handles:
1. **Database creation** - Creates the test database if it doesn't exist
2. **Migrations** - Runs dbmate migrations before tests
3. **Cleanup** - Truncates tables between test files
#### Environment
Set `TEST_DATABASE_URL` in your environment (devenv.nix sets this automatically):
```bash
TEST_DATABASE_URL=postgres://reviq:reviq@localhost/reviq-dashboard_test
```
### Writing E2E Tests
Create test files in `src/__tests__/e2e/`. E2E tests should call router handlers directly using the `call` function from `@orpc/server`.
#### Basic Setup
```typescript
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import type { Kysely } from "kysely";
import type { Database } from "@reviq/db-schema";
import { call } from "@orpc/server";
import { router } from "../../router.js";
import type { AuthenticatedContext } from "../../context.js";
import {
createTestDb,
createTestUser,
destroyTestDb,
runMigrations,
truncateAllTables,
} from "../helpers/test-db.js";
let db: Kysely<Database>;
beforeAll(async () => {
await runMigrations();
db = createTestDb();
});
afterAll(async () => {
await destroyTestDb(db);
});
describe("my feature", () => {
beforeAll(async () => {
await truncateAllTables(db);
});
test("does something", async () => {
const user = await createTestUser(db, { email: "test@example.com" });
expect(user.id).toBeGreaterThan(0);
});
});
```
#### Calling Router Handlers
Use `call()` from `@orpc/server` to invoke router handlers directly with the appropriate context:
```typescript
import { call } from "@orpc/server";
import { router } from "../../router.js";
import type { AuthenticatedContext } from "../../context.js";
// Create a context object for authenticated endpoints
function createAuthContext(userId: number, email: string): AuthenticatedContext {
return {
db,
origin: "http://localhost:3000",
allowedOrigins: ["http://localhost:3000"],
rpName: "Test App",
user: {
id: userId,
email,
displayName: null,
emailVerifiedAt: null,
isSuperuser: false,
},
session: {
id: 1,
trustedMode: false,
createdAt: new Date(),
},
};
}
test("lists passkeys via router", async () => {
const user = await createTestUser(db, { email: "test@example.com" });
const ctx = createAuthContext(user.id, user.email);
// Call router handler directly
const passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(0);
});
test("renames passkey via router", async () => {
const user = await createTestUser(db, { email: "test@example.com" });
const ctx = createAuthContext(user.id, user.email);
// Call with input
await call(
router.me.passkeys.rename,
{ passkeyId: 1, name: "My Key" },
{ context: ctx }
);
});
test("handles errors from router", async () => {
const user = await createTestUser(db, { email: "test@example.com" });
const ctx = createAuthContext(user.id, user.email);
// Expect router to throw
await expect(
call(router.me.passkeys.delete, { passkeyId: 999 }, { context: ctx })
).rejects.toThrow();
});
```
#### Context Types
Different endpoints require different context types:
| Context Type | Use Case |
|-------------|----------|
| `APIContext` | Public endpoints (no auth required) |
| `AuthenticatedContext` | Protected endpoints (requires user session) |
| `LoginRequestContext` | Login flow endpoints |
See `src/context.ts` for the full interface definitions.
### Test Helpers
#### `test-db.ts`
| Function | Description |
|----------|-------------|
| `createTestDb()` | Creates a Kysely connection to the test database |
| `runMigrations()` | Runs dbmate migrations (creates DB if needed) |
| `truncateAllTables(db)` | Truncates all tables with CASCADE |
| `createTestUser(db, overrides?)` | Creates a test user with optional overrides |
| `destroyTestDb(db)` | Closes the database connection |
#### `test-constants.ts`
Test constants for RP configuration and known values:
```typescript
import { TEST_RP, KNOWN_AAGUIDS } from "../helpers/test-constants.js";
```
#### VirtualAuthenticator
For WebAuthn testing, generates real cryptographic credentials. Available from the `@reviq/virtual-authenticator` package:
```typescript
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
const authenticator = new VirtualAuthenticator({
origin: "http://localhost:3000",
});
// Registration
const regResponse = await authenticator.createCredential(regOptions);
// Authentication
const authResponse = await authenticator.getAssertion(authOptions);
```
### Test Isolation
- Tests run serially (`--no-parallel`) to avoid database conflicts
- Each test file should call `truncateAllTables()` in `beforeAll`
- Use unique emails/identifiers per test to avoid collisions within a file

View File

@@ -8,7 +8,9 @@
"build": "bun build src/index.ts --outdir dist",
"typecheck": "tsc --noEmit",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache"
"clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel",
"test:unit": "bun test src/__tests__/unit"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
@@ -26,9 +28,12 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"typescript": "catalog:"
}
}

File diff suppressed because it is too large Load Diff

View 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";

View 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();
}

View 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);
}
});
});

View File

@@ -76,15 +76,24 @@ const createRegistrationOptions =
async ({ input, context }) => {
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 context.db
.selectFrom("users")
.select(["id", "display_name"])
.where("email", "=", email)
.executeTakeFirst();
const rpInfo = getRPInfo(
context.origin,
context.allowedOrigins,
context.rpName,
);
const result = await createRegOptions(context.db, rpInfo, { email });
const result = await createRegOptions(context.db, rpInfo, {
id: existingUser?.id,
email,
displayName: existingUser?.display_name,
});
return result;
},
);

View File

@@ -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),