Files
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

211 lines
5.2 KiB
Markdown

# 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