|
| 1 | +import { expect, test } from '@playwright/test'; |
| 2 | + |
| 3 | +import { appConfigs } from '../../presets'; |
| 4 | +import type { FakeUser } from '../../testUtils'; |
| 5 | +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; |
| 6 | + |
| 7 | +/** |
| 8 | + * Tests MemoryTokenCache session isolation in multi-session scenarios |
| 9 | + * |
| 10 | + * This suite validates that when multiple user sessions exist simultaneously, |
| 11 | + * each session maintains its own isolated token cache. Tokens are not shared |
| 12 | + * between different sessions, even within the same tab, ensuring proper |
| 13 | + * security boundaries between users. |
| 14 | + */ |
| 15 | +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( |
| 16 | + 'MemoryTokenCache Multi-Session Integration @nextjs', |
| 17 | + ({ app }) => { |
| 18 | + test.describe.configure({ mode: 'serial' }); |
| 19 | + |
| 20 | + let fakeUser1: FakeUser; |
| 21 | + let fakeUser2: FakeUser; |
| 22 | + |
| 23 | + test.beforeAll(async () => { |
| 24 | + const u = createTestUtils({ app }); |
| 25 | + fakeUser1 = u.services.users.createFakeUser(); |
| 26 | + fakeUser2 = u.services.users.createFakeUser(); |
| 27 | + await u.services.users.createBapiUser(fakeUser1); |
| 28 | + await u.services.users.createBapiUser(fakeUser2); |
| 29 | + }); |
| 30 | + |
| 31 | + test.afterAll(async () => { |
| 32 | + await fakeUser1.deleteIfExists(); |
| 33 | + await fakeUser2.deleteIfExists(); |
| 34 | + await app.teardown(); |
| 35 | + }); |
| 36 | + |
| 37 | + /** |
| 38 | + * Test Flow: |
| 39 | + * 1. Tab1: Sign in as user1, fetch and cache their token |
| 40 | + * 2. Tab2: Opens and inherits user1's session via cookies |
| 41 | + * 3. Tab2: Sign in as user2 using programmatic sign-in (preserves both sessions) |
| 42 | + * 4. Tab2: Now has two active sessions (user1 and user2) |
| 43 | + * 5. Tab2: Switch between sessions and fetch tokens for each |
| 44 | + * 6. Verify no network requests occur (tokens served from cache) |
| 45 | + * 7. Tab1: Verify it still has user1 as active session (tab independence) |
| 46 | + * |
| 47 | + * Expected Behavior: |
| 48 | + * - Each session has its own isolated token cache |
| 49 | + * - Switching sessions in tab2 returns different tokens |
| 50 | + * - Both tokens are served from cache (no network requests) |
| 51 | + * - Tab1 remains unaffected by tab2's session changes |
| 52 | + * - Multi-session state is properly maintained per-tab |
| 53 | + */ |
| 54 | + test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({ |
| 55 | + context, |
| 56 | + }) => { |
| 57 | + const page1 = await context.newPage(); |
| 58 | + await page1.goto(app.serverUrl); |
| 59 | + await page1.waitForFunction(() => (window as any).Clerk?.loaded); |
| 60 | + |
| 61 | + const u1 = createTestUtils({ app, page: page1 }); |
| 62 | + await u1.po.signIn.goTo(); |
| 63 | + await u1.po.signIn.setIdentifier(fakeUser1.email); |
| 64 | + await u1.po.signIn.continue(); |
| 65 | + await u1.po.signIn.setPassword(fakeUser1.password); |
| 66 | + await u1.po.signIn.continue(); |
| 67 | + await u1.po.expect.toBeSignedIn(); |
| 68 | + |
| 69 | + const user1SessionInfo = await page1.evaluate(() => { |
| 70 | + const clerk = (window as any).Clerk; |
| 71 | + return { |
| 72 | + sessionId: clerk?.session?.id, |
| 73 | + userId: clerk?.user?.id, |
| 74 | + }; |
| 75 | + }); |
| 76 | + |
| 77 | + expect(user1SessionInfo.sessionId).toBeDefined(); |
| 78 | + expect(user1SessionInfo.userId).toBeDefined(); |
| 79 | + |
| 80 | + const user1Token = await page1.evaluate(async () => { |
| 81 | + const clerk = (window as any).Clerk; |
| 82 | + return await clerk.session?.getToken({ skipCache: true }); |
| 83 | + }); |
| 84 | + |
| 85 | + expect(user1Token).toBeTruthy(); |
| 86 | + |
| 87 | + const page2 = await context.newPage(); |
| 88 | + await page2.goto(app.serverUrl); |
| 89 | + await page2.waitForFunction(() => (window as any).Clerk?.loaded); |
| 90 | + |
| 91 | + // eslint-disable-next-line playwright/no-wait-for-timeout |
| 92 | + await page2.waitForTimeout(1000); |
| 93 | + |
| 94 | + const u2 = createTestUtils({ app, page: page2 }); |
| 95 | + await u2.po.expect.toBeSignedIn(); |
| 96 | + |
| 97 | + const page2User1SessionInfo = await page2.evaluate(() => { |
| 98 | + const clerk = (window as any).Clerk; |
| 99 | + return { |
| 100 | + sessionId: clerk?.session?.id, |
| 101 | + userId: clerk?.user?.id, |
| 102 | + }; |
| 103 | + }); |
| 104 | + |
| 105 | + expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId); |
| 106 | + expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId); |
| 107 | + |
| 108 | + // Use clerk.client.signIn.create() instead of navigating to /sign-in |
| 109 | + // because navigating replaces the session by default (transferable: true) |
| 110 | + const signInResult = await page2.evaluate( |
| 111 | + async ({ email, password }) => { |
| 112 | + const clerk = (window as any).Clerk; |
| 113 | + |
| 114 | + try { |
| 115 | + const signIn = await clerk.client.signIn.create({ |
| 116 | + identifier: email, |
| 117 | + password: password, |
| 118 | + }); |
| 119 | + |
| 120 | + await clerk.setActive({ |
| 121 | + session: signIn.createdSessionId, |
| 122 | + }); |
| 123 | + |
| 124 | + return { |
| 125 | + allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], |
| 126 | + sessionCount: clerk?.client?.sessions?.length || 0, |
| 127 | + success: true, |
| 128 | + }; |
| 129 | + } catch (error: any) { |
| 130 | + return { |
| 131 | + error: error.message || String(error), |
| 132 | + success: false, |
| 133 | + }; |
| 134 | + } |
| 135 | + }, |
| 136 | + { email: fakeUser2.email, password: fakeUser2.password }, |
| 137 | + ); |
| 138 | + |
| 139 | + expect(signInResult.success).toBe(true); |
| 140 | + expect(signInResult.sessionCount).toBe(2); |
| 141 | + |
| 142 | + await u2.po.expect.toBeSignedIn(); |
| 143 | + |
| 144 | + const user2SessionInfo = await page2.evaluate(() => { |
| 145 | + const clerk = (window as any).Clerk; |
| 146 | + return { |
| 147 | + allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], |
| 148 | + sessionCount: clerk?.client?.sessions?.length || 0, |
| 149 | + sessionId: clerk?.session?.id, |
| 150 | + userId: clerk?.user?.id, |
| 151 | + }; |
| 152 | + }); |
| 153 | + |
| 154 | + expect(user2SessionInfo.sessionId).toBeDefined(); |
| 155 | + expect(user2SessionInfo.userId).toBeDefined(); |
| 156 | + expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId); |
| 157 | + expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId); |
| 158 | + |
| 159 | + const user2Token = await page2.evaluate(async () => { |
| 160 | + const clerk = (window as any).Clerk; |
| 161 | + return await clerk.session?.getToken({ skipCache: true }); |
| 162 | + }); |
| 163 | + |
| 164 | + expect(user2Token).toBeTruthy(); |
| 165 | + expect(user2Token).not.toBe(user1Token); |
| 166 | + |
| 167 | + const page2MultiSessionInfo = await page2.evaluate(() => { |
| 168 | + const clerk = (window as any).Clerk; |
| 169 | + return { |
| 170 | + activeSessionId: clerk?.session?.id, |
| 171 | + allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [], |
| 172 | + sessionCount: clerk?.client?.sessions?.length || 0, |
| 173 | + }; |
| 174 | + }); |
| 175 | + |
| 176 | + expect(page2MultiSessionInfo.sessionCount).toBe(2); |
| 177 | + expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId); |
| 178 | + expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId); |
| 179 | + expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId); |
| 180 | + |
| 181 | + const tokenFetchRequests: Array<{ sessionId: string; url: string }> = []; |
| 182 | + await context.route('**/v1/client/sessions/*/tokens*', async route => { |
| 183 | + const url = route.request().url(); |
| 184 | + const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/); |
| 185 | + const sessionId = sessionIdMatch?.[1] || 'unknown'; |
| 186 | + tokenFetchRequests.push({ sessionId, url }); |
| 187 | + await route.continue(); |
| 188 | + }); |
| 189 | + |
| 190 | + const tokenIsolation = await page2.evaluate( |
| 191 | + async ({ user1SessionId, user2SessionId }) => { |
| 192 | + const clerk = (window as any).Clerk; |
| 193 | + |
| 194 | + await clerk.setActive({ session: user1SessionId }); |
| 195 | + const user1Token = await clerk.session?.getToken(); |
| 196 | + |
| 197 | + await clerk.setActive({ session: user2SessionId }); |
| 198 | + const user2Token = await clerk.session?.getToken(); |
| 199 | + |
| 200 | + return { |
| 201 | + tokensAreDifferent: user1Token !== user2Token, |
| 202 | + user1Token, |
| 203 | + user2Token, |
| 204 | + }; |
| 205 | + }, |
| 206 | + { user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId }, |
| 207 | + ); |
| 208 | + |
| 209 | + expect(tokenIsolation.tokensAreDifferent).toBe(true); |
| 210 | + expect(tokenIsolation.user1Token).toBeTruthy(); |
| 211 | + expect(tokenIsolation.user2Token).toBeTruthy(); |
| 212 | + expect(tokenFetchRequests.length).toBe(0); |
| 213 | + |
| 214 | + await context.unroute('**/v1/client/sessions/*/tokens*'); |
| 215 | + |
| 216 | + // In multi-session apps, each tab can have a different active session |
| 217 | + const tab1FinalInfo = await page1.evaluate(() => { |
| 218 | + const clerk = (window as any).Clerk; |
| 219 | + return { |
| 220 | + activeSessionId: clerk?.session?.id, |
| 221 | + userId: clerk?.user?.id, |
| 222 | + }; |
| 223 | + }); |
| 224 | + |
| 225 | + // Tab1 should STILL have user1 as the active session (independent per tab) |
| 226 | + expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId); |
| 227 | + expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId); |
| 228 | + }); |
| 229 | + }, |
| 230 | +); |
0 commit comments