From 6b439102388b306ca7eb52b4a3129fa2b8e93bb5 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 16:11:46 +0800 Subject: [PATCH] Add test coverage and fix webauthn e2e tests to use real sessions - Add test:e2e:coverage script with Bun's built-in coverage support - Create bunfig.toml with coverage configuration (text + lcov reporters) - Fix webauthn tests to create real database sessions/login requests instead of mock context objects that bypass auth middleware - Add createUserAPIContext helper for cleaner test code - Update security tests to expect NOT_FOUND when accessing other user's passkeys Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + apps/api-server/bunfig.toml | 9 + apps/api-server/package.json | 2 +- .../src/__tests__/e2e/webauthn.test.ts | 215 +++++++++++------- db/schema.sql | 4 +- 5 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 apps/api-server/bunfig.toml diff --git a/.gitignore b/.gitignore index 031c249..5437c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ devenv.local.nix # TypeScript *.tsbuildinfo +# Test coverage +coverage/ + # Debug npm-debug.log* yarn-debug.log* diff --git a/apps/api-server/bunfig.toml b/apps/api-server/bunfig.toml new file mode 100644 index 0000000..0dace52 --- /dev/null +++ b/apps/api-server/bunfig.toml @@ -0,0 +1,9 @@ +[test] +# Coverage reporters: text for console, lcov for CI/tooling integration +coverageReporter = ["text", "lcov"] + +# Output directory for lcov.info file +coverageDir = "coverage" + +# Don't count test files in coverage metrics +coverageSkipTestFiles = true diff --git a/apps/api-server/package.json b/apps/api-server/package.json index b16262f..d456074 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", - "test:e2e": "bun test src/__tests__/e2e --no-parallel", + "test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test:unit": "bun test src/__tests__/unit" }, "dependencies": { diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index 0102999..996a414 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -9,15 +9,13 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; -import type { - APIContext, - AuthenticatedContext, - LoginRequestContext, -} from "../../context.js"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { APIContext } from "../../context.js"; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; import { @@ -28,6 +26,9 @@ import { truncateAllTables, } from "../helpers/test-db.js"; +/** Session expiry duration: 24 hours in milliseconds */ +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + let db: Kysely | undefined; /** @@ -41,67 +42,93 @@ function getDb(): Kysely { } /** - * Create an API context (for public endpoints) + * Create an API context with optional session token */ -function createAPIContext(): APIContext { +function createAPIContext(sessionToken?: string): APIContext { + const reqHeaders = new Headers(); + if (sessionToken) { + reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); + } + return { db: getDb(), origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, - reqHeaders: new Headers(), + reqHeaders, resHeaders: new Headers(), }; } /** - * Create an authenticated context (for protected endpoints) + * Create a real session in the database and return the token */ -function createAuthenticatedContext( - userId: number, - email: string, -): AuthenticatedContext { - const now = new Date(); - return { - ...createAPIContext(), - user: { - id: userId, - email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, - session: { - id: "1", - trustedMode: false, - createdAt: now, - }, - auth: { - method: "session", - sessionId: "1", - expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), - createdAt: now, - }, - }; +async function createSession(userId: number): Promise { + const token = "test-session-" + String(Date.now()) + String(Math.random()); + const tokenHashValue = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + + await getDb() + .insertInto("sessions") + .values({ + user_id: userId, + token_hash: tokenHashValue, + ip_address: "127.0.0.1", + user_agent: "test-agent", + expires_at: expiresAt, + trusted_mode: false, + }) + .execute(); + + return token; } /** - * Create a login request context (for login flow endpoints) + * Create a login request in the database and return ID and token */ -function createLoginRequestContext( +async function createLoginRequest( userId: number, email: string, -): LoginRequestContext { - return { - ...createAPIContext(), - loginRequestId: 1, - user: { - id: userId, +): Promise<{ id: number; token: string }> { + const token = "test-login-" + String(Date.now()) + String(Math.random()); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + const result = await getDb() + .insertInto("login_requests") + .values({ + user_id: userId, email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, + token, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: result.id, token }; +} + +/** + * Create an authenticated API context for a user (creates session + context) + */ +async function createUserAPIContext(userId: number): Promise { + const sessionToken = await createSession(userId); + return createAPIContext(sessionToken); +} + +/** + * Create an API context with login request cookie + */ +function createLoginRequestContext(loginToken: string): APIContext { + const reqHeaders = new Headers(); + reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); + + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), }; } @@ -127,7 +154,7 @@ async function registerPasskey( authenticator: VirtualAuthenticator, ) { const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(userId, email); + const authCtx = await createUserAPIContext(userId); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -152,7 +179,8 @@ async function authenticate( email: string, authenticator: VirtualAuthenticator, ) { - const loginCtx = createLoginRequestContext(userId, email); + const { token: loginToken } = await createLoginRequest(userId, email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, @@ -234,8 +262,8 @@ describe("registration flow", () => { // Create credential with virtual authenticator const response = authenticator.createCredential(options); - // Verify registration via router - const authCtx = createAuthenticatedContext(user.id, user.email); + // Verify registration via router (requires authenticated session) + const authCtx = await createUserAPIContext(user.id); await call( router.auth.webauthn.verifyRegistration, { challengeId, response }, @@ -256,7 +284,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Register first passkey via router const { options: options1, challengeId: challengeId1 } = await call( @@ -299,7 +327,7 @@ describe("registration flow", () => { }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -325,7 +353,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -355,7 +383,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Create options via router const { options } = await call( @@ -399,7 +427,8 @@ describe("authentication flow", () => { ); // Create authentication options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -427,7 +456,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -460,7 +490,8 @@ describe("authentication flow", () => { expect(firstPasskey.lastUsedAt).toBeNull(); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -489,7 +520,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -522,7 +554,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Create auth options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -585,7 +618,8 @@ describe("security tests", () => { authenticator.setSignCount(regResponse.id, 0); // Create a new authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -624,7 +658,8 @@ describe("security tests", () => { await registerPasskey(user.id, user.email, authenticator); // Create authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -755,7 +790,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator2); // List passkeys via router handler - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -806,7 +841,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -842,8 +877,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -853,12 +888,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to rename user2's passkey using user1's context (should not work) - await call( - router.me.passkeys.rename, - { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, - { context: ctx1 }, - ); + // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.rename, + { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + { context: ctx1 }, + ); + throw new Error("Expected rename to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should be unchanged const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { @@ -880,7 +921,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -906,7 +947,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, auth1); await registerPasskey(user.id, user.email, auth2); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -937,7 +978,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -978,8 +1019,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -989,12 +1030,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to delete user2's passkey using user1's context (should not affect user2) - await call( - router.me.passkeys.delete, - { passkeyId: user2FirstPasskey.id }, - { context: ctx1 }, - ); + // Try to delete user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.delete, + { passkeyId: user2FirstPasskey.id }, + { context: ctx1 }, + ); + throw new Error("Expected delete to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should still exist const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { diff --git a/db/schema.sql b/db/schema.sql index 90c915d..b0d5ee9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\restrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\unrestrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu --