-
Notifications
You must be signed in to change notification settings - Fork 391
test multitab token cache failure #6899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -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()), | ||||||||||||||||||||||||||||||
]); | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
Comment on lines
+87
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard If - await Promise.all([
- page1.evaluate(() => (window as any).Clerk.session?.clearCache()),
- page2.evaluate(() => (window as any).Clerk.session?.clearCache()),
- ]);
+ await Promise.all([
+ page1.evaluate(async () => {
+ const s = (window as any).Clerk?.session;
+ if (s && typeof s.clearCache === 'function') await s.clearCache();
+ }),
+ page2.evaluate(async () => {
+ const s = (window as any).Clerk?.session;
+ if (s && typeof s.clearCache === 'function') await s.clearCache();
+ }),
+ ]); 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||
// 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); | ||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||
); |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -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); | ||||||||
Comment on lines
+91
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace hard-coded timeout with deterministic wait. Similar to the single-session test, this hard-coded timeout is brittle. Consider waiting for a specific condition that indicates the session has been fully propagated to page2. Apply this diff: - // eslint-disable-next-line playwright/no-wait-for-timeout
- await page2.waitForTimeout(1000);
+ await page2.waitForFunction(() => (window as any).Clerk?.session?.id); 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||
|
||||||||
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); | ||||||||
}); | ||||||||
}, | ||||||||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid truthy “loaded” checks — can pass too early.
waitForFunction(() => Clerk?.loaded)
may resolve immediately ifloaded
is a Promise/object. Check explicitly for=== true
and a ready session.Based on learnings.
Also applies to: 49-50
🤖 Prompt for AI Agents