Migrate e2e tests to transaction-based isolation

Replace table truncation with transaction rollback for test isolation.
Each test now runs in a transaction that auto-rolls back, improving
test performance and isolation. Tests that call procedures with internal
transactions use getSharedDb() directly with appropriate comments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-10 19:16:47 +08:00
parent cca901a9b9
commit dcb48a5d5e
5 changed files with 3401 additions and 2992 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,39 +19,30 @@ 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 {
createTestDb,
createTestUser, createTestUser,
destroyTestDb, destroySharedDb,
runMigrations, getSharedDb,
truncateAllTables, initTestDb,
} from "../helpers/test-db.js"; } from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
let db: Kysely<Database> | undefined;
/**
* Get the database connection, throwing if not initialized
*/
function getDb(): Kysely<Database> {
if (!db) {
throw new Error("Database not initialized");
}
return db;
}
/** /**
* Create an API context with optional session token * Create an API context with optional session token
*/ */
function createAPIContext(sessionToken?: string): APIContext { function createAPIContext(
db: Kysely<Database>,
sessionToken?: string,
): APIContext {
const reqHeaders = new Headers(); const reqHeaders = new Headers();
if (sessionToken) { if (sessionToken) {
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
} }
return { return {
db: getDb(), db,
origin: TEST_RP.origin, origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins], allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
@@ -63,12 +54,15 @@ function createAPIContext(sessionToken?: string): APIContext {
/** /**
* Create a real session in the database and return the token * Create a real session in the database and return the token
*/ */
async function createSession(userId: number): Promise<string> { async function createSession(
db: Kysely<Database>,
userId: number,
): Promise<string> {
const token = `test-session-${String(Date.now())}${String(Math.random())}`; const token = `test-session-${String(Date.now())}${String(Math.random())}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
await getDb() await db
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
user_id: userId, user_id: userId,
@@ -87,13 +81,14 @@ async function createSession(userId: number): Promise<string> {
* Create a login request in the database and return ID and token * Create a login request in the database and return ID and token
*/ */
async function createLoginRequest( async function createLoginRequest(
db: Kysely<Database>,
userId: number, userId: number,
email: string, email: string,
): Promise<{ id: number; token: string }> { ): Promise<{ id: number; token: string }> {
const token = `test-login-${String(Date.now())}${String(Math.random())}`; const token = `test-login-${String(Date.now())}${String(Math.random())}`;
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const result = await getDb() const result = await db
.insertInto("login_requests") .insertInto("login_requests")
.values({ .values({
user_id: userId, user_id: userId,
@@ -110,20 +105,26 @@ async function createLoginRequest(
/** /**
* Create an authenticated API context for a user (creates session + context) * Create an authenticated API context for a user (creates session + context)
*/ */
async function createUserAPIContext(userId: number): Promise<APIContext> { async function createUserAPIContext(
const sessionToken = await createSession(userId); db: Kysely<Database>,
return createAPIContext(sessionToken); userId: number,
): Promise<APIContext> {
const sessionToken = await createSession(db, userId);
return createAPIContext(db, sessionToken);
} }
/** /**
* Create an API context with login request cookie * Create an API context with login request cookie
*/ */
function createLoginRequestContext(loginToken: string): APIContext { function createLoginRequestContext(
db: Kysely<Database>,
loginToken: string,
): APIContext {
const reqHeaders = new Headers(); const reqHeaders = new Headers();
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
return { return {
db: getDb(), db,
origin: TEST_RP.origin, origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins], allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
@@ -149,12 +150,13 @@ function expectFirst<T>(arr: T[], message: string): T {
* Shared helper to avoid duplication across test suites. * Shared helper to avoid duplication across test suites.
*/ */
async function registerPasskey( async function registerPasskey(
db: Kysely<Database>,
userId: number, userId: number,
email: string, email: string,
authenticator: VirtualAuthenticator, authenticator: VirtualAuthenticator,
) { ) {
const apiCtx = createAPIContext(); const apiCtx = createAPIContext(db);
const authCtx = await createUserAPIContext(userId); const authCtx = await createUserAPIContext(db, userId);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -175,12 +177,13 @@ async function registerPasskey(
* Shared helper to avoid duplication across test suites. * Shared helper to avoid duplication across test suites.
*/ */
async function authenticate( async function authenticate(
db: Kysely<Database>,
userId: number, userId: number,
email: string, email: string,
authenticator: VirtualAuthenticator, authenticator: VirtualAuthenticator,
) { ) {
const { token: loginToken } = await createLoginRequest(userId, email); const { token: loginToken } = await createLoginRequest(db, userId, email);
const loginCtx = createLoginRequestContext(loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
@@ -196,27 +199,20 @@ async function authenticate(
} }
beforeAll(async () => { beforeAll(async () => {
// Run migrations and create test database connection await initTestDb();
await runMigrations();
db = createTestDb();
}); });
afterAll(async () => { afterAll(async () => {
if (db) { await destroySharedDb();
await destroyTestDb(db);
}
}); });
describe("registration flow", () => { describe("registration flow", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("creates registration options with challenge stored in DB via router", async () => { test("creates registration options with challenge stored in DB via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "reg-options@test.com", email: "reg-options@test.com",
}); });
const ctx = createAPIContext(); const ctx = createAPIContext(db);
// Call router handler directly // Call router handler directly
const { options, challengeId } = await call( const { options, challengeId } = await call(
@@ -235,16 +231,18 @@ describe("registration flow", () => {
// Verify challenge is stored in database // Verify challenge is stored in database
const challengeRow = await db const challengeRow = await db
?.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", String(challengeId))
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeDefined(); expect(challengeRow).toBeDefined();
}); });
});
test("verifies valid registration and stores passkey via router", async () => { test("verifies valid registration and stores passkey via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "reg-verify@test.com", email: "reg-verify@test.com",
}); });
const authenticator = new VirtualAuthenticator({ const authenticator = new VirtualAuthenticator({
@@ -252,7 +250,7 @@ describe("registration flow", () => {
}); });
// Create registration options via router // Create registration options via router
const apiCtx = createAPIContext(); const apiCtx = createAPIContext(db);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
{ email: user.email }, { email: user.email },
@@ -263,7 +261,7 @@ describe("registration flow", () => {
const response = authenticator.createCredential(options); const response = authenticator.createCredential(options);
// Verify registration via router (requires authenticated session) // Verify registration via router (requires authenticated session)
const authCtx = await createUserAPIContext(user.id); const authCtx = await createUserAPIContext(db, user.id);
await call( await call(
router.auth.webauthn.verifyRegistration, router.auth.webauthn.verifyRegistration,
{ challengeId, response }, { challengeId, response },
@@ -271,20 +269,24 @@ describe("registration flow", () => {
); );
// Verify passkey is stored in database // Verify passkey is stored in database
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.rpid).toBe(TEST_RP.rpID); expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
expect(firstPasskey.counter).toBe(0); expect(firstPasskey.counter).toBe(0);
}); });
});
test("excludes existing passkeys for returning users via router", async () => { test("excludes existing passkeys for returning users via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "exclude-test@test.com", email: "exclude-test@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
const apiCtx = createAPIContext(); origin: TEST_RP.origin,
const authCtx = await createUserAPIContext(user.id); });
const apiCtx = createAPIContext(db);
const authCtx = await createUserAPIContext(db, 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(
@@ -314,9 +316,11 @@ describe("registration flow", () => {
); );
expect(excludedCred.id).toBe(response1.id); expect(excludedCred.id).toBe(response1.id);
}); });
});
test("assigns friendly name from known AAGUID via router", async () => { test("assigns friendly name from known AAGUID via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "aaguid-test@test.com", email: "aaguid-test@test.com",
}); });
@@ -326,8 +330,8 @@ describe("registration flow", () => {
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
}); });
const apiCtx = createAPIContext(); const apiCtx = createAPIContext(db);
const authCtx = await createUserAPIContext(user.id); const authCtx = await createUserAPIContext(db, user.id);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -341,19 +345,23 @@ describe("registration flow", () => {
{ context: authCtx }, { context: authCtx },
); );
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.name).toBe("iCloud Keychain"); expect(firstPasskey.name).toBe("iCloud Keychain");
}); });
});
test("cleans up challenge after verification via router", async () => { test("cleans up challenge after verification via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "cleanup-test@test.com", email: "cleanup-test@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
const apiCtx = createAPIContext(); origin: TEST_RP.origin,
const authCtx = await createUserAPIContext(user.id); });
const apiCtx = createAPIContext(db);
const authCtx = await createUserAPIContext(db, user.id);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
@@ -369,21 +377,25 @@ describe("registration flow", () => {
// Challenge should be deleted // Challenge should be deleted
const challengeRow = await db const challengeRow = await db
?.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(challengeId)) .where("id", "=", String(challengeId))
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
}); });
});
test("rejects expired/missing challenges via router", async () => { test("rejects expired/missing challenges via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expired-test@test.com", email: "expired-test@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
const apiCtx = createAPIContext(); origin: TEST_RP.origin,
const authCtx = await createUserAPIContext(user.id); });
const apiCtx = createAPIContext(db);
const authCtx = await createUserAPIContext(db, user.id);
// Create options via router // Create options via router
const { options } = await call( const { options } = await call(
@@ -406,29 +418,34 @@ describe("registration flow", () => {
expect((error as Error).message).toContain("Registration timed out"); expect((error as Error).message).toContain("Registration timed out");
} }
}); });
});
}); });
describe("authentication flow", () => { describe("authentication flow", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("creates authentication options with user's passkeys via router", async () => { test("creates authentication options with user's passkeys via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "auth-options@test.com", email: "auth-options@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register a passkey first via router // Register a passkey first via router
const regResponse = await registerPasskey( const regResponse = await registerPasskey(
db,
user.id, user.id,
user.email, user.email,
authenticator, authenticator,
); );
// Create authentication options via router // Create authentication options via router
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -445,19 +462,27 @@ describe("authentication flow", () => {
expect(allowedCred.id).toBe(regResponse.id); expect(allowedCred.id).toBe(regResponse.id);
expect(challengeId).toBeGreaterThan(0); expect(challengeId).toBeGreaterThan(0);
}); });
});
test("verifies valid authentication and updates counter via router", async () => { test("verifies valid authentication and updates counter via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "auth-verify@test.com", email: "auth-verify@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Authenticate via router // Authenticate via router
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, 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,
@@ -472,26 +497,34 @@ describe("authentication flow", () => {
); );
// Verify counter was updated // Verify counter was updated
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(1); expect(firstPasskey.counter).toBe(1);
}); });
});
test("updates last_used_at timestamp via router", async () => { test("updates last_used_at timestamp via router", async () => {
const user = await createTestUser(getDb(), { email: "last-used@test.com" }); await withTestTransaction(getSharedDb(), async (db) => {
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const user = await createTestUser(db, { email: "last-used@test.com" });
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Check initial state // Check initial state
let passkeys = await getUserPasskeys(getDb(), user.id); let passkeys = await getUserPasskeys(db, user.id);
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.lastUsedAt).toBeNull(); expect(firstPasskey.lastUsedAt).toBeNull();
// Authenticate via router // Authenticate via router
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, 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,
@@ -505,23 +538,31 @@ describe("authentication flow", () => {
); );
// Check last_used_at is now set // Check last_used_at is now set
passkeys = await getUserPasskeys(getDb(), user.id); passkeys = await getUserPasskeys(db, user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.lastUsedAt).not.toBeNull(); expect(firstPasskey.lastUsedAt).not.toBeNull();
}); });
});
test("cleans up challenge after authentication via router", async () => { test("cleans up challenge after authentication via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "auth-cleanup@test.com", email: "auth-cleanup@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Authenticate via router // Authenticate via router
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, 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,
@@ -536,26 +577,34 @@ describe("authentication flow", () => {
// Challenge should be deleted // Challenge should be deleted
const challengeRow = await db const challengeRow = await db
?.selectFrom("webauthn_challenges") .selectFrom("webauthn_challenges")
.select("id") .select("id")
.where("id", "=", String(authChallengeId)) .where("id", "=", String(authChallengeId))
.executeTakeFirst(); .executeTakeFirst();
expect(challengeRow).toBeUndefined(); expect(challengeRow).toBeUndefined();
}); });
});
test("rejects unknown credential IDs", async () => { test("rejects unknown credential IDs", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "unknown-cred@test.com", email: "unknown-cred@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Create auth options via router // Create auth options via router
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions } = await call( const { options: authOptions } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -567,7 +616,7 @@ describe("authentication flow", () => {
origin: TEST_RP.origin, origin: TEST_RP.origin,
}); });
// First create a credential so the authenticator has something (use same registration options) // First create a credential so the authenticator has something (use same registration options)
const apiCtx = createAPIContext(); const apiCtx = createAPIContext(db);
const { options: regOptions } = await call( const { options: regOptions } = await call(
router.auth.webauthn.createRegistrationOptions, router.auth.webauthn.createRegistrationOptions,
{ email: user.email }, { email: user.email },
@@ -586,31 +635,32 @@ describe("authentication flow", () => {
); );
} }
}); });
});
}); });
describe("security tests", () => { describe("security tests", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("rejects replayed credentials (counter check) via router", async () => { test("rejects replayed credentials (counter check) via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "counter-replay@test.com", email: "counter-replay@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
const regResponse = await registerPasskey( const regResponse = await registerPasskey(
db,
user.id, user.id,
user.email, user.email,
authenticator, authenticator,
); );
// First authentication should succeed // First authentication should succeed
await authenticate(user.id, user.email, authenticator); await authenticate(db, user.id, user.email, authenticator);
// Verify counter was updated to 1 // Verify counter was updated to 1
let passkeys = await getUserPasskeys(getDb(), user.id); let passkeys = await getUserPasskeys(db, user.id);
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(1); expect(firstPasskey.counter).toBe(1);
@@ -618,8 +668,12 @@ describe("security tests", () => {
authenticator.setSignCount(regResponse.id, 0); authenticator.setSignCount(regResponse.id, 0);
// Create a new authentication challenge // Create a new authentication challenge
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -643,23 +697,31 @@ describe("security tests", () => {
} }
// Counter should not have changed // Counter should not have changed
passkeys = await getUserPasskeys(getDb(), user.id); passkeys = await getUserPasskeys(db, user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(1); expect(firstPasskey.counter).toBe(1);
}); });
});
test("rejects tampered authentication response", async () => { test("rejects tampered authentication response", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "tampered-response@test.com", email: "tampered-response@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Create authentication challenge // Create authentication challenge
const { token: loginToken } = await createLoginRequest(user.id, user.email); const { token: loginToken } = await createLoginRequest(
const loginCtx = createLoginRequestContext(loginToken); db,
user.id,
user.email,
);
const loginCtx = createLoginRequestContext(db, loginToken);
const { options, challengeId } = await call( const { options, challengeId } = await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
@@ -709,71 +771,75 @@ describe("security tests", () => {
); );
} }
}); });
});
}); });
describe("full passkey lifecycle", () => { describe("full passkey lifecycle", () => {
beforeAll(async () => { test("register → authenticate → add second passkey → authenticate with either via router", async () => {
await truncateAllTables(getDb()); await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "lifecycle@test.com" });
const authenticator1 = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const authenticator2 = new VirtualAuthenticator({
origin: TEST_RP.origin,
}); });
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
const user = await createTestUser(getDb(), { email: "lifecycle@test.com" });
const authenticator1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const authenticator2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
// Register first passkey via router // Register first passkey via router
await registerPasskey(user.id, user.email, authenticator1); await registerPasskey(db, user.id, user.email, authenticator1);
// Authenticate with first passkey via router // Authenticate with first passkey via router
await authenticate(user.id, user.email, authenticator1); await authenticate(db, user.id, user.email, authenticator1);
// Register second passkey via router // Register second passkey via router
await registerPasskey(user.id, user.email, authenticator2); await registerPasskey(db, user.id, user.email, authenticator2);
// Verify user now has 2 passkeys // Verify user now has 2 passkeys
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(2); expect(passkeys).toHaveLength(2);
// Authenticate with second passkey via router // Authenticate with second passkey via router
await authenticate(user.id, user.email, authenticator2); await authenticate(db, user.id, user.email, authenticator2);
// Authenticate with first passkey again via router // Authenticate with first passkey again via router
await authenticate(user.id, user.email, authenticator1); await authenticate(db, user.id, user.email, authenticator1);
});
}); });
test("register → authenticate multiple times → counter increments via router", async () => { test("register → authenticate multiple times → counter increments via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "counter-test@test.com", email: "counter-test@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
// Register passkey via router // Register passkey via router
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
// Verify initial counter // Verify initial counter
let passkeys = await getUserPasskeys(getDb(), user.id); let passkeys = await getUserPasskeys(db, user.id);
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(0); expect(firstPasskey.counter).toBe(0);
// Authenticate 5 times via router // Authenticate 5 times via router
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
await authenticate(user.id, user.email, authenticator); await authenticate(db, user.id, user.email, authenticator);
// Verify counter incremented // Verify counter incremented
passkeys = await getUserPasskeys(getDb(), user.id); passkeys = await getUserPasskeys(db, user.id);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.counter).toBe(i); expect(firstPasskey.counter).toBe(i);
} }
}); });
});
}); });
describe("passkey management", () => { describe("passkey management", () => {
beforeAll(async () => {
await truncateAllTables(getDb());
});
test("lists passkeys with correct data via router", async () => { test("lists passkeys with correct data via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "list-passkeys@test.com", email: "list-passkeys@test.com",
}); });
const authenticator1 = new VirtualAuthenticator({ const authenticator1 = new VirtualAuthenticator({
@@ -786,11 +852,11 @@ describe("passkey management", () => {
}); });
// Register two passkeys // Register two passkeys
await registerPasskey(user.id, user.email, authenticator1); await registerPasskey(db, user.id, user.email, authenticator1);
await registerPasskey(user.id, user.email, authenticator2); await registerPasskey(db, user.id, user.email, authenticator2);
// List passkeys via router handler // List passkeys via router handler
const ctx = await createUserAPIContext(user.id); const ctx = await createUserAPIContext(db, user.id);
const passkeys = await call(router.me.passkeys.list, undefined, { const passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -814,16 +880,20 @@ describe("passkey management", () => {
throw new Error("Expected Google Password Manager passkey to exist"); throw new Error("Expected Google Password Manager passkey to exist");
} }
}); });
});
test("passkey stores correct device type and backup status", async () => { test("passkey stores correct device type and backup status", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "device-type@test.com", email: "device-type@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
@@ -832,16 +902,20 @@ describe("passkey management", () => {
expect(firstPasskey.backupEligible).toBe(false); expect(firstPasskey.backupEligible).toBe(false);
expect(firstPasskey.backupStatus).toBe(false); expect(firstPasskey.backupStatus).toBe(false);
}); });
});
test("renames passkey successfully via router", async () => { test("renames passkey successfully via router", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "rename-test@test.com", email: "rename-test@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
const ctx = await createUserAPIContext(user.id); const ctx = await createUserAPIContext(db, user.id);
let passkeys = await call(router.me.passkeys.list, undefined, { let passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -858,27 +932,31 @@ describe("passkey management", () => {
); );
// Verify name changed // Verify name changed
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.name).toBe(newName); expect(firstPasskey.name).toBe(newName);
expect(firstPasskey.name).not.toBe(originalName); expect(firstPasskey.name).not.toBe(originalName);
}); });
});
test("rename does not affect other user's passkeys", async () => { test("rename does not affect other user's passkeys", async () => {
const user1 = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user1 = await createTestUser(db, {
email: "rename-user1@test.com", email: "rename-user1@test.com",
}); });
const user2 = await createTestUser(getDb(), { const user2 = await createTestUser(db, {
email: "rename-user2@test.com", email: "rename-user2@test.com",
}); });
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(db, user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2); await registerPasskey(db, user2.id, user2.email, auth2);
const ctx1 = await createUserAPIContext(user1.id); const ctx1 = await createUserAPIContext(db, user1.id);
const ctx2 = await createUserAPIContext(user2.id); const ctx2 = await createUserAPIContext(db, user2.id);
const user2Passkeys = await call(router.me.passkeys.list, undefined, { const user2Passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx2, context: ctx2,
@@ -902,26 +980,34 @@ describe("passkey management", () => {
} }
// 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,
{
context: ctx2, context: ctx2,
}); },
);
const user2FirstPasskeyAfter = user2PasskeysAfter[0]; const user2FirstPasskeyAfter = user2PasskeysAfter[0];
if (!user2FirstPasskeyAfter) { if (!user2FirstPasskeyAfter) {
throw new Error("Expected user2 passkey to exist after"); throw new Error("Expected user2 passkey to exist after");
} }
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name); expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
}); });
});
// Note: This test uses getSharedDb() directly because the delete passkey
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
test("deletes passkey when user has password via router", async () => { test("deletes passkey when user has password via router", async () => {
const user = await createTestUser(getDb(), { const db = getSharedDb();
const user = await createTestUser(db, {
email: "delete-with-password@test.com", email: "delete-with-password@test.com",
passwordHash: "fake-password-hash", passwordHash: "fake-password-hash",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
const ctx = await createUserAPIContext(user.id); const ctx = await createUserAPIContext(db, 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,17 +1023,20 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(0); expect(passkeys).toHaveLength(0);
}); });
// Note: This test uses getSharedDb() directly because the delete passkey
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
test("deletes passkey when user has multiple passkeys via router", async () => { test("deletes passkey when user has multiple passkeys via router", async () => {
const user = await createTestUser(getDb(), { const db = getSharedDb();
const user = await createTestUser(db, {
email: "delete-multi@test.com", email: "delete-multi@test.com",
}); });
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, auth1); await registerPasskey(db, user.id, user.email, auth1);
await registerPasskey(user.id, user.email, auth2); await registerPasskey(db, user.id, user.email, auth2);
const ctx = await createUserAPIContext(user.id); const ctx = await createUserAPIContext(db, user.id);
let passkeys = await call(router.me.passkeys.list, undefined, { let passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -969,16 +1058,19 @@ describe("passkey management", () => {
expect(firstPasskey.id).not.toBe(firstPasskeyId); expect(firstPasskey.id).not.toBe(firstPasskeyId);
}); });
// Note: This test uses getSharedDb() directly because the delete passkey
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
test("prevents deleting last passkey without password via router", async () => { test("prevents deleting last passkey without password via router", async () => {
const user = await createTestUser(getDb(), { const db = getSharedDb();
const user = await createTestUser(db, {
email: "delete-last@test.com", email: "delete-last@test.com",
// No password set // No password set
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
const ctx = await createUserAPIContext(user.id); const ctx = await createUserAPIContext(db, user.id);
const passkeys = await call(router.me.passkeys.list, undefined, { const passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx, context: ctx,
}); });
@@ -1004,23 +1096,26 @@ describe("passkey management", () => {
expect(passkeysAfter).toHaveLength(1); expect(passkeysAfter).toHaveLength(1);
}); });
// Note: This test uses getSharedDb() directly because the delete passkey
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
test("delete does not affect other user's passkeys via router", async () => { test("delete does not affect other user's passkeys via router", async () => {
const user1 = await createTestUser(getDb(), { const db = getSharedDb();
const user1 = await createTestUser(db, {
email: "delete-user1@test.com", email: "delete-user1@test.com",
passwordHash: "fake-hash", passwordHash: "fake-hash",
}); });
const user2 = await createTestUser(getDb(), { const user2 = await createTestUser(db, {
email: "delete-user2@test.com", email: "delete-user2@test.com",
passwordHash: "fake-hash", passwordHash: "fake-hash",
}); });
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(db, user1.id, user1.email, auth1);
await registerPasskey(user2.id, user2.email, auth2); await registerPasskey(db, user2.id, user2.email, auth2);
const ctx1 = await createUserAPIContext(user1.id); const ctx1 = await createUserAPIContext(db, user1.id);
const ctx2 = await createUserAPIContext(user2.id); const ctx2 = await createUserAPIContext(db, user2.id);
const user2Passkeys = await call(router.me.passkeys.list, undefined, { const user2Passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx2, context: ctx2,
@@ -1051,16 +1146,17 @@ describe("passkey management", () => {
}); });
test("passkey credentialId is unique and stored correctly", async () => { test("passkey credentialId is unique and stored correctly", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "credential-id@test.com", email: "credential-id@test.com",
}); });
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
await registerPasskey(user.id, user.email, auth1); await registerPasskey(db, user.id, user.email, auth1);
await registerPasskey(user.id, user.email, auth2); await registerPasskey(db, user.id, user.email, auth2);
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(2); expect(passkeys).toHaveLength(2);
const firstPasskey = passkeys[0]; const firstPasskey = passkeys[0];
const secondPasskey = passkeys[1]; const secondPasskey = passkeys[1];
@@ -1075,16 +1171,20 @@ describe("passkey management", () => {
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
}); });
});
test("passkey transports are stored and retrieved correctly", async () => { test("passkey transports are stored and retrieved correctly", async () => {
const user = await createTestUser(getDb(), { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "transports@test.com", email: "transports@test.com",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
const passkeys = await getUserPasskeys(getDb(), user.id); const passkeys = await getUserPasskeys(db, user.id);
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
@@ -1092,4 +1192,5 @@ describe("passkey management", () => {
expect(firstPasskey.transports).toContain("internal"); expect(firstPasskey.transports).toContain("internal");
expect(firstPasskey.transports).toContain("hybrid"); expect(firstPasskey.transports).toContain("hybrid");
}); });
});
}); });

View File

@@ -238,3 +238,64 @@ export async function createTestUser(
export async function destroyTestDb(db: Kysely<Database>): Promise<void> { export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
await db.destroy(); await db.destroy();
} }
// ============================================================================
// Shared Database Singleton (for transaction-based test isolation)
// ============================================================================
let sharedDb: Kysely<Database> | null = null;
/**
* Initialize the shared test database once.
* Runs migrations and truncates all tables to start with a clean slate.
* Subsequent calls return the existing connection.
*
* Use this with `withTestTransaction()` for fast test isolation.
*
* @example
* ```typescript
* beforeAll(async () => {
* await initTestDb();
* });
*
* test("does something", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* // test code using db
* });
* });
* ```
*/
export async function initTestDb(): Promise<Kysely<Database>> {
if (!sharedDb) {
await runMigrations();
sharedDb = createTestDb();
await truncateAllTables(sharedDb); // Clean slate once at start
}
return sharedDb;
}
/**
* Get the shared test database connection.
* Must call `initTestDb()` first.
*
* @throws Error if database not initialized
*/
export function getSharedDb(): Kysely<Database> {
if (!sharedDb) {
throw new Error(
"Test DB not initialized. Call initTestDb() in beforeAll first.",
);
}
return sharedDb;
}
/**
* Destroy the shared test database connection.
* Call this in a global afterAll if needed.
*/
export async function destroySharedDb(): Promise<void> {
if (sharedDb) {
await sharedDb.destroy();
sharedDb = null;
}
}

View File

@@ -0,0 +1,60 @@
/**
* Transaction-based test isolation helper
*
* Wraps test code in a transaction that auto-rollbacks, providing
* fast test isolation without truncating tables between tests.
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
/**
* Signal used to trigger transaction rollback after test completes
*/
class RollbackSignal extends Error {
constructor() {
super("RollbackSignal");
this.name = "RollbackSignal";
}
}
/**
* Runs a test function inside a transaction that auto-rollbacks.
*
* The transaction implements the same interface as Kysely<Database>,
* so it can be passed to context builders and used for all queries.
* After the test completes, the transaction is rolled back, providing
* instant cleanup without truncating tables.
*
* @example
* ```typescript
* test("creates user", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* const user = await createTestUser(db, { email: "test@example.com" });
* const ctx = createAPIContext({ db });
* // ... test code
* }); // Auto-rollback here
* });
* ```
*/
export async function withTestTransaction<T>(
db: Kysely<Database>,
testFn: (trx: Kysely<Database>) => Promise<T>,
): Promise<T | undefined> {
let result: T | undefined;
try {
await db.transaction().execute(async (trx) => {
result = await testFn(trx);
// Force rollback by throwing after test completes successfully
throw new RollbackSignal();
});
} catch (e) {
// Swallow the rollback signal - this is expected behavior
if (!(e instanceof RollbackSignal)) {
throw e;
}
}
return result;
}