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
*.tsbuildinfo
# Test coverage
coverage/
# Debug
npm-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",
"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": {

View File

@@ -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<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 {
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<string> {
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<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,
) {
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)
// 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)
// 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, {

View File

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