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 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-10 16:11:46 +08:00
parent 72fa560648
commit 6b43910238
5 changed files with 146 additions and 87 deletions

3
.gitignore vendored
View File

@@ -34,6 +34,9 @@ devenv.local.nix
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
# Test coverage
coverage/
# Debug # Debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

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

View File

@@ -9,7 +9,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "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" "test:unit": "bun test src/__tests__/unit"
}, },
"dependencies": { "dependencies": {

View File

@@ -9,15 +9,13 @@
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { import type { APIContext } from "../../context.js";
APIContext, import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
AuthenticatedContext,
LoginRequestContext,
} from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; 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 { getUserPasskeys } from "../../utils/webauthn.js";
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
import { import {
@@ -28,6 +26,9 @@ import {
truncateAllTables, truncateAllTables,
} from "../helpers/test-db.js"; } from "../helpers/test-db.js";
/** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
let db: Kysely<Database> | undefined; let db: Kysely<Database> | undefined;
/** /**
@@ -41,67 +42,93 @@ function getDb(): Kysely<Database> {
} }
/** /**
* 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 { return {
db: getDb(), db: getDb(),
origin: TEST_RP.origin, origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins], allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders: new Headers(), reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
}; };
} }
/** /**
* Create an authenticated context (for protected endpoints) * Create a real session in the database and return the token
*/ */
function createAuthenticatedContext( async function createSession(userId: number): Promise<string> {
userId: number, const token = "test-session-" + String(Date.now()) + String(Math.random());
email: string, const tokenHashValue = await hashToken(token);
): AuthenticatedContext { const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
const now = new Date();
return { await getDb()
...createAPIContext(), .insertInto("sessions")
user: { .values({
id: userId, user_id: userId,
email, token_hash: tokenHashValue,
displayName: null, ip_address: "127.0.0.1",
emailVerifiedAt: null, user_agent: "test-agent",
isSuperuser: false, expires_at: expiresAt,
}, trusted_mode: false,
session: { })
id: "1", .execute();
trustedMode: false,
createdAt: now, return token;
},
auth: {
method: "session",
sessionId: "1",
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
createdAt: now,
},
};
} }
/** /**
* 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, userId: number,
email: string, email: string,
): LoginRequestContext { ): Promise<{ id: number; token: string }> {
return { const token = "test-login-" + String(Date.now()) + String(Math.random());
...createAPIContext(), const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
loginRequestId: 1,
user: { const result = await getDb()
id: userId, .insertInto("login_requests")
.values({
user_id: userId,
email, email,
displayName: null, token,
emailVerifiedAt: null, expires_at: expiresAt,
isSuperuser: false, })
}, .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<APIContext> {
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, authenticator: VirtualAuthenticator,
) { ) {
const apiCtx = createAPIContext(); const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(userId, email); const authCtx = await createUserAPIContext(userId);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -152,7 +179,8 @@ async function authenticate(
email: string, email: string,
authenticator: VirtualAuthenticator, authenticator: VirtualAuthenticator,
) { ) {
const loginCtx = createLoginRequestContext(userId, email); const { token: loginToken } = await createLoginRequest(userId, email);
const loginCtx = createLoginRequestContext(loginToken);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
@@ -234,8 +262,8 @@ describe("registration flow", () => {
// Create credential with virtual authenticator // Create credential with virtual authenticator
const response = authenticator.createCredential(options); const response = authenticator.createCredential(options);
// Verify registration via router // Verify registration via router (requires authenticated session)
const authCtx = createAuthenticatedContext(user.id, user.email); const authCtx = await createUserAPIContext(user.id);
await call( await call(
router.auth.webauthn.verifyRegistration, router.auth.webauthn.verifyRegistration,
{ challengeId, response }, { challengeId, response },
@@ -256,7 +284,7 @@ describe("registration flow", () => {
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext(); const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email); const authCtx = await createUserAPIContext(user.id);
// Register first passkey via router // Register first passkey via router
const { options: options1, challengeId: challengeId1 } = await call( const { options: options1, challengeId: challengeId1 } = await call(
@@ -299,7 +327,7 @@ describe("registration flow", () => {
}); });
const apiCtx = createAPIContext(); const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email); const authCtx = await createUserAPIContext(user.id);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -325,7 +353,7 @@ describe("registration flow", () => {
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext(); const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email); const authCtx = await createUserAPIContext(user.id);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -355,7 +383,7 @@ describe("registration flow", () => {
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
const apiCtx = createAPIContext(); const apiCtx = createAPIContext();
const authCtx = createAuthenticatedContext(user.id, user.email); const authCtx = await createUserAPIContext(user.id);
// Create options via router // Create options via router
const { options } = await call( const { options } = await call(
@@ -399,7 +427,8 @@ describe("authentication flow", () => {
); );
// Create authentication options via router // 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( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -427,7 +456,8 @@ describe("authentication flow", () => {
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(user.id, user.email, authenticator);
// Authenticate via router // 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( const { options: authOptions, challengeId: authChallengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -460,7 +490,8 @@ describe("authentication flow", () => {
expect(firstPasskey.lastUsedAt).toBeNull(); expect(firstPasskey.lastUsedAt).toBeNull();
// Authenticate via router // 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( const { options: authOptions, challengeId: authChallengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -489,7 +520,8 @@ describe("authentication flow", () => {
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(user.id, user.email, authenticator);
// Authenticate via router // 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( const { options: authOptions, challengeId: authChallengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -522,7 +554,8 @@ describe("authentication flow", () => {
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(user.id, user.email, authenticator);
// Create auth options via router // 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( const { options: authOptions } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -585,7 +618,8 @@ describe("security tests", () => {
authenticator.setSignCount(regResponse.id, 0); authenticator.setSignCount(regResponse.id, 0);
// Create a new authentication challenge // 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( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -624,7 +658,8 @@ describe("security tests", () => {
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(user.id, user.email, authenticator);
// Create authentication challenge // 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( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -755,7 +790,7 @@ describe("passkey management", () => {
await registerPasskey(user.id, user.email, authenticator2); await registerPasskey(user.id, user.email, authenticator2);
// List passkeys via router handler // 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, { const passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -806,7 +841,7 @@ describe("passkey management", () => {
await registerPasskey(user.id, user.email, authenticator); 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, { let passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -842,8 +877,8 @@ describe("passkey management", () => {
await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2); await registerPasskey(user2.id, user2.email, auth2);
const ctx1 = createAuthenticatedContext(user1.id, user1.email); const ctx1 = await createUserAPIContext(user1.id);
const ctx2 = createAuthenticatedContext(user2.id, user2.email); const ctx2 = await createUserAPIContext(user2.id);
const user2Passkeys = await call(router.me.passkeys.list, undefined, { const user2Passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx2, context: ctx2,
@@ -853,12 +888,18 @@ describe("passkey management", () => {
throw new Error("Expected user2 passkey to exist"); throw new Error("Expected user2 passkey to exist");
} }
// Try to rename user2's passkey using user1's context (should not work) // Try to rename user2's passkey using user1's context (should throw NOT_FOUND)
try {
await call( await call(
router.me.passkeys.rename, router.me.passkeys.rename,
{ passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, { passkeyId: user2FirstPasskey.id, name: "Hacked Name" },
{ context: ctx1 }, { 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 // User2's passkey should be unchanged
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {
@@ -880,7 +921,7 @@ describe("passkey management", () => {
await registerPasskey(user.id, user.email, authenticator); 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, { let passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -906,7 +947,7 @@ describe("passkey management", () => {
await registerPasskey(user.id, user.email, auth1); await registerPasskey(user.id, user.email, auth1);
await registerPasskey(user.id, user.email, auth2); 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, { let passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -937,7 +978,7 @@ describe("passkey management", () => {
await registerPasskey(user.id, user.email, authenticator); 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, { const passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -978,8 +1019,8 @@ describe("passkey management", () => {
await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2); await registerPasskey(user2.id, user2.email, auth2);
const ctx1 = createAuthenticatedContext(user1.id, user1.email); const ctx1 = await createUserAPIContext(user1.id);
const ctx2 = createAuthenticatedContext(user2.id, user2.email); const ctx2 = await createUserAPIContext(user2.id);
const user2Passkeys = await call(router.me.passkeys.list, undefined, { const user2Passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx2, context: ctx2,
@@ -989,12 +1030,18 @@ describe("passkey management", () => {
throw new Error("Expected user2 passkey to exist"); throw new Error("Expected user2 passkey to exist");
} }
// Try to delete user2's passkey using user1's context (should not affect user2) // Try to delete user2's passkey using user1's context (should throw NOT_FOUND)
try {
await call( await call(
router.me.passkeys.delete, router.me.passkeys.delete,
{ passkeyId: user2FirstPasskey.id }, { passkeyId: user2FirstPasskey.id },
{ context: ctx1 }, { 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 // User2's passkey should still exist
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {

View File

@@ -1,4 +1,4 @@
\restrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf \restrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu
-- Dumped from database version 17.7 -- Dumped from database version 17.7
-- Dumped by pg_dump version 17.7 -- Dumped by pg_dump version 17.7
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf \unrestrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu
-- --