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:
210
apps/api-server/README.md
Normal file
210
apps/api-server/README.md
Normal 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
|
||||
Reference in New Issue
Block a user