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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,6 +34,9 @@ devenv.local.nix
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
9
apps/api-server/bunfig.toml
Normal file
9
apps/api-server/bunfig.toml
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user