From cc10edfde8c3cd38505fbe3a13174d25f6b1633c Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 1 Oct 2025 10:08:36 -0500 Subject: [PATCH 1/3] test multitab token cache failure --- .../tests/multitab-token-cache.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 integration/tests/multitab-token-cache.test.ts diff --git a/integration/tests/multitab-token-cache.test.ts b/integration/tests/multitab-token-cache.test.ts new file mode 100644 index 00000000000..ed7af5a3c78 --- /dev/null +++ b/integration/tests/multitab-token-cache.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'MemoryTokenCache Multi-Tab Integration @generic @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('MemoryTokenCache multi-tab token sharing', async ({ context }) => { + const page1 = await context.newPage(); + const page2 = await context.newPage(); + + await page1.goto(app.serverUrl); + await page2.goto(app.serverUrl); + + await page1.waitForFunction(() => (window as any).Clerk?.loaded); + await page2.waitForFunction(() => (window as any).Clerk?.loaded); + + const u1 = createTestUtils({ app, page: page1 }); + await u1.po.signIn.goTo(); + await u1.po.signIn.setIdentifier(fakeUser.email); + await u1.po.signIn.continue(); + await u1.po.signIn.setPassword(fakeUser.password); + await u1.po.signIn.continue(); + await u1.po.expect.toBeSignedIn(); + + await page1.waitForTimeout(1000); + + await page2.reload(); + await page2.waitForFunction(() => (window as any).Clerk?.loaded); + + const u2 = createTestUtils({ app, page: page2 }); + await u2.po.expect.toBeSignedIn(); + + const clerkInfo1 = await page1.evaluate(() => { + const clerk = (window as any).Clerk; + return { + hasBroadcastChannel: !!clerk?._broadcastChannel, + loaded: clerk?.loaded, + }; + }); + + const clerkInfo2 = await page2.evaluate(() => { + const clerk = (window as any).Clerk; + return { + hasBroadcastChannel: !!clerk?._broadcastChannel, + loaded: clerk?.loaded, + }; + }); + + expect(clerkInfo1.loaded).toBe(true); + expect(clerkInfo2.loaded).toBe(true); + + expect(clerkInfo1.hasBroadcastChannel).toBe(true); + expect(clerkInfo2.hasBroadcastChannel).toBe(true); + + const page1SessionInfo = await page1.evaluate(() => { + const clerk = (window as any).Clerk; + return { + sessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + expect(page1SessionInfo.sessionId).toBeDefined(); + expect(page1SessionInfo.userId).toBeDefined(); + + await Promise.all([ + page1.evaluate(() => (window as any).Clerk.session?.clearCache()), + page2.evaluate(() => (window as any).Clerk.session?.clearCache()), + ]); + + // Track token fetch requests to verify only one network call happens + const tokenRequests: string[] = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + tokenRequests.push(route.request().url()); + await route.continue(); + }); + + const page1Token = await page1.evaluate(async () => { + const clerk = (window as any).Clerk; + return await clerk.session?.getToken({ skipCache: true }); + }); + + expect(page1Token).toBeTruthy(); + + await page1.waitForTimeout(1500); + + const page2Result = await page2.evaluate(async () => { + const clerk = (window as any).Clerk; + + const token = await clerk.session?.getToken(); + + return { + sessionId: clerk?.session?.id, + token, + userId: clerk?.user?.id, + }; + }); + + expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId); + expect(page2Result.userId).toBe(page1SessionInfo.userId); + + expect(page2Result.token).toBe(page1Token); + + // Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel + expect(tokenRequests.length).toBe(1); + }); + }, +); From 06083094a44920b9a31824e80e2ddc5737db1bff Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 1 Oct 2025 10:34:38 -0500 Subject: [PATCH 2/3] remove implementation specific assertions --- integration/tests/multitab-token-cache.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/multitab-token-cache.test.ts b/integration/tests/multitab-token-cache.test.ts index ed7af5a3c78..d3121339c97 100644 --- a/integration/tests/multitab-token-cache.test.ts +++ b/integration/tests/multitab-token-cache.test.ts @@ -70,8 +70,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( expect(clerkInfo1.loaded).toBe(true); expect(clerkInfo2.loaded).toBe(true); - expect(clerkInfo1.hasBroadcastChannel).toBe(true); - expect(clerkInfo2.hasBroadcastChannel).toBe(true); + // expect(clerkInfo1.hasBroadcastChannel).toBe(true); + // expect(clerkInfo2.hasBroadcastChannel).toBe(true); const page1SessionInfo = await page1.evaluate(() => { const clerk = (window as any).Clerk; From 58193804b88de741fe0de654b3a8301b150ea8cc Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 2 Oct 2025 20:38:33 -0500 Subject: [PATCH 3/3] port over new tests --- .../session-token-cache/multi-session.test.ts | 230 ++++++++++++++++++ .../single-session.test.ts | 132 ++++++++++ 2 files changed, 362 insertions(+) create mode 100644 integration/tests/session-token-cache/multi-session.test.ts create mode 100644 integration/tests/session-token-cache/single-session.test.ts diff --git a/integration/tests/session-token-cache/multi-session.test.ts b/integration/tests/session-token-cache/multi-session.test.ts new file mode 100644 index 00000000000..2f05eab18c3 --- /dev/null +++ b/integration/tests/session-token-cache/multi-session.test.ts @@ -0,0 +1,230 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +/** + * Tests MemoryTokenCache session isolation in multi-session scenarios + * + * This suite validates that when multiple user sessions exist simultaneously, + * each session maintains its own isolated token cache. Tokens are not shared + * between different sessions, even within the same tab, ensuring proper + * security boundaries between users. + */ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( + 'MemoryTokenCache Multi-Session Integration @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser1: FakeUser; + let fakeUser2: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser1 = u.services.users.createFakeUser(); + fakeUser2 = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser1); + await u.services.users.createBapiUser(fakeUser2); + }); + + test.afterAll(async () => { + await fakeUser1.deleteIfExists(); + await fakeUser2.deleteIfExists(); + await app.teardown(); + }); + + /** + * Test Flow: + * 1. Tab1: Sign in as user1, fetch and cache their token + * 2. Tab2: Opens and inherits user1's session via cookies + * 3. Tab2: Sign in as user2 using programmatic sign-in (preserves both sessions) + * 4. Tab2: Now has two active sessions (user1 and user2) + * 5. Tab2: Switch between sessions and fetch tokens for each + * 6. Verify no network requests occur (tokens served from cache) + * 7. Tab1: Verify it still has user1 as active session (tab independence) + * + * Expected Behavior: + * - Each session has its own isolated token cache + * - Switching sessions in tab2 returns different tokens + * - Both tokens are served from cache (no network requests) + * - Tab1 remains unaffected by tab2's session changes + * - Multi-session state is properly maintained per-tab + */ + test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({ + context, + }) => { + const page1 = await context.newPage(); + await page1.goto(app.serverUrl); + await page1.waitForFunction(() => (window as any).Clerk?.loaded); + + const u1 = createTestUtils({ app, page: page1 }); + await u1.po.signIn.goTo(); + await u1.po.signIn.setIdentifier(fakeUser1.email); + await u1.po.signIn.continue(); + await u1.po.signIn.setPassword(fakeUser1.password); + await u1.po.signIn.continue(); + await u1.po.expect.toBeSignedIn(); + + const user1SessionInfo = await page1.evaluate(() => { + const clerk = (window as any).Clerk; + return { + sessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + expect(user1SessionInfo.sessionId).toBeDefined(); + expect(user1SessionInfo.userId).toBeDefined(); + + const user1Token = await page1.evaluate(async () => { + const clerk = (window as any).Clerk; + return await clerk.session?.getToken({ skipCache: true }); + }); + + expect(user1Token).toBeTruthy(); + + const page2 = await context.newPage(); + await page2.goto(app.serverUrl); + await page2.waitForFunction(() => (window as any).Clerk?.loaded); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page2.waitForTimeout(1000); + + const u2 = createTestUtils({ app, page: page2 }); + await u2.po.expect.toBeSignedIn(); + + const page2User1SessionInfo = await page2.evaluate(() => { + const clerk = (window as any).Clerk; + return { + sessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId); + expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId); + + // Use clerk.client.signIn.create() instead of navigating to /sign-in + // because navigating replaces the session by default (transferable: true) + const signInResult = await page2.evaluate( + async ({ email, password }) => { + const clerk = (window as any).Clerk; + + try { + const signIn = await clerk.client.signIn.create({ + identifier: email, + password: password, + }); + + await clerk.setActive({ + session: signIn.createdSessionId, + }); + + return { + allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], + sessionCount: clerk?.client?.sessions?.length || 0, + success: true, + }; + } catch (error: any) { + return { + error: error.message || String(error), + success: false, + }; + } + }, + { email: fakeUser2.email, password: fakeUser2.password }, + ); + + expect(signInResult.success).toBe(true); + expect(signInResult.sessionCount).toBe(2); + + await u2.po.expect.toBeSignedIn(); + + const user2SessionInfo = await page2.evaluate(() => { + const clerk = (window as any).Clerk; + return { + allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], + sessionCount: clerk?.client?.sessions?.length || 0, + sessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + expect(user2SessionInfo.sessionId).toBeDefined(); + expect(user2SessionInfo.userId).toBeDefined(); + expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId); + expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId); + + const user2Token = await page2.evaluate(async () => { + const clerk = (window as any).Clerk; + return await clerk.session?.getToken({ skipCache: true }); + }); + + expect(user2Token).toBeTruthy(); + expect(user2Token).not.toBe(user1Token); + + const page2MultiSessionInfo = await page2.evaluate(() => { + const clerk = (window as any).Clerk; + return { + activeSessionId: clerk?.session?.id, + allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [], + sessionCount: clerk?.client?.sessions?.length || 0, + }; + }); + + expect(page2MultiSessionInfo.sessionCount).toBe(2); + expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId); + expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId); + expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId); + + const tokenFetchRequests: Array<{ sessionId: string; url: string }> = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + const url = route.request().url(); + const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/); + const sessionId = sessionIdMatch?.[1] || 'unknown'; + tokenFetchRequests.push({ sessionId, url }); + await route.continue(); + }); + + const tokenIsolation = await page2.evaluate( + async ({ user1SessionId, user2SessionId }) => { + const clerk = (window as any).Clerk; + + await clerk.setActive({ session: user1SessionId }); + const user1Token = await clerk.session?.getToken(); + + await clerk.setActive({ session: user2SessionId }); + const user2Token = await clerk.session?.getToken(); + + return { + tokensAreDifferent: user1Token !== user2Token, + user1Token, + user2Token, + }; + }, + { user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId }, + ); + + expect(tokenIsolation.tokensAreDifferent).toBe(true); + expect(tokenIsolation.user1Token).toBeTruthy(); + expect(tokenIsolation.user2Token).toBeTruthy(); + expect(tokenFetchRequests.length).toBe(0); + + await context.unroute('**/v1/client/sessions/*/tokens*'); + + // In multi-session apps, each tab can have a different active session + const tab1FinalInfo = await page1.evaluate(() => { + const clerk = (window as any).Clerk; + return { + activeSessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + // Tab1 should STILL have user1 as the active session (independent per tab) + expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId); + expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId); + }); + }, +); diff --git a/integration/tests/session-token-cache/single-session.test.ts b/integration/tests/session-token-cache/single-session.test.ts new file mode 100644 index 00000000000..03b5bd24953 --- /dev/null +++ b/integration/tests/session-token-cache/single-session.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +/** + * Tests MemoryTokenCache cross-tab token sharing via BroadcastChannel + * + * This suite validates that when multiple browser tabs share the same user session, + * token fetches in one tab are automatically broadcast and cached in other tabs, + * eliminating redundant network requests. + */ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'MemoryTokenCache Multi-Tab Integration @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + /** + * Test Flow: + * 1. Open two tabs with the same browser context (shared cookies) + * 2. Sign in on tab1, which creates a session + * 3. Reload tab2 to pick up the session from cookies + * 4. Clear token cache on both tabs + * 5. Fetch token on tab1 (triggers network request + broadcast) + * 6. Fetch token on tab2 (should use broadcasted token, no network request) + * + * Expected Behavior: + * - Both tabs receive identical tokens + * - Only ONE network request is made (from tab1) + * - Tab2 gets the token via BroadcastChannel, proving cross-tab cache sharing + */ + test('MemoryTokenCache multi-tab token sharing', async ({ context }) => { + const page1 = await context.newPage(); + const page2 = await context.newPage(); + + await page1.goto(app.serverUrl); + await page2.goto(app.serverUrl); + + await page1.waitForFunction(() => (window as any).Clerk?.loaded); + await page2.waitForFunction(() => (window as any).Clerk?.loaded); + + const u1 = createTestUtils({ app, page: page1 }); + await u1.po.signIn.goTo(); + await u1.po.signIn.setIdentifier(fakeUser.email); + await u1.po.signIn.continue(); + await u1.po.signIn.setPassword(fakeUser.password); + await u1.po.signIn.continue(); + await u1.po.expect.toBeSignedIn(); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page1.waitForTimeout(1000); + + await page2.reload(); + await page2.waitForFunction(() => (window as any).Clerk?.loaded); + + const u2 = createTestUtils({ app, page: page2 }); + await u2.po.expect.toBeSignedIn(); + + const page1SessionInfo = await page1.evaluate(() => { + const clerk = (window as any).Clerk; + return { + sessionId: clerk?.session?.id, + userId: clerk?.user?.id, + }; + }); + + expect(page1SessionInfo.sessionId).toBeDefined(); + expect(page1SessionInfo.userId).toBeDefined(); + + await Promise.all([ + page1.evaluate(() => (window as any).Clerk.session?.clearCache()), + page2.evaluate(() => (window as any).Clerk.session?.clearCache()), + ]); + + // Track token fetch requests to verify only one network call happens + const tokenRequests: string[] = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + tokenRequests.push(route.request().url()); + await route.continue(); + }); + + const page1Token = await page1.evaluate(async () => { + const clerk = (window as any).Clerk; + return await clerk.session?.getToken({ skipCache: true }); + }); + + expect(page1Token).toBeTruthy(); + + // Wait for broadcast to propagate between tabs (broadcast is nearly instant, but we add buffer) + // eslint-disable-next-line playwright/no-wait-for-timeout + await page2.waitForTimeout(2000); + + const page2Result = await page2.evaluate(async () => { + const clerk = (window as any).Clerk; + + const token = await clerk.session?.getToken(); + + return { + sessionId: clerk?.session?.id, + token, + userId: clerk?.user?.id, + }; + }); + + expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId); + expect(page2Result.userId).toBe(page1SessionInfo.userId); + + // If BroadcastChannel worked, both tabs should have the EXACT same token + expect(page2Result.token).toBe(page1Token); + + // Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel + expect(tokenRequests.length).toBe(1); + }); + }, +);