- 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>
211 lines
5.2 KiB
Markdown
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
|