diff --git a/integration/README.md b/integration/README.md index 33a054f90f6..62f17c6d2e5 100644 --- a/integration/README.md +++ b/integration/README.md @@ -590,3 +590,17 @@ Before writing tests, it's important to understand how Playwright handles test i > - `VERCEL_PROJECT_ID`: Only required if you plan on running deployment tests locally. This is the Vercel project ID, and it points to an application created via the Vercel dashboard. The easiest way to get access to it is by linking a local app to the Vercel project using the Vercel CLI, and then copying the values from the `.vercel` directory. > - `VERCEL_ORG_ID`: The organization that owns the Vercel project. See above for more details. > - `VERCEL_TOKEN`: A personal access token. This corresponds to a real user running the deployment command. Attention: Be extra careful with this token as it can't be scoped to a single Vercel project, meaning that the token has access to every project in the account it belongs to. + +## Appendix + +### Production Hosts + +Production instances necessitate the use of DNS hostnames. +For example, `multiple-apps-e2e.clerk.app` facilitates subdomain testing. +During a test, a local proxy is established to direct requests from the DNS host to a local server. + +To incorporate a new hostname: + +- Provision a new `.clerk.app` host domain. +- Establish and configure a new Clerk production application. +- Update the local test certificates to encompass the new domain alongside existing ones. diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index eb1bbed8da6..b3a87023673 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -42,6 +42,13 @@ const withEmailCodes = base .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk) .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key'); +const sessionsProd1 = base + .clone() + .setId('sessionsProd1') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('sessions-prod-1').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('sessions-prod-1').pk) + .setEnvVariable('public', 'CLERK_JS_URL', ''); + const withEmailCodes_destroy_client = withEmailCodes .clone() .setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false'); @@ -187,4 +194,5 @@ export const envs = { withBillingStaging, withBilling, withWhatsappPhoneCode, + sessionsProd1, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 49ec2d7d480..392d8fbf82d 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -24,6 +24,7 @@ export const createLongRunningApps = () => { { id: 'react.vite.withEmailCodes_persist_client', config: react.vite, env: envs.withEmailCodes_destroy_client }, { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, { id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes }, + { id: 'next.appRouter.sessionsProd1', config: next.appRouter, env: envs.sessionsProd1 }, { id: 'next.appRouter.withEmailCodes_persist_client', config: next.appRouter, diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 09dde2d7660..f3ffc9fc8f6 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -6,10 +6,10 @@ import type { Application } from '../models/application'; import { createEmailService } from './emailService'; import { createInvitationService } from './invitationsService'; import { createOrganizationsService } from './organizationsService'; -import type { FakeOrganization, FakeUser } from './usersService'; +import type { FakeOrganization, FakeUser, FakeUserWithEmail } from './usersService'; import { createUserService } from './usersService'; -export type { FakeUser, FakeOrganization }; +export type { FakeUser, FakeUserWithEmail, FakeOrganization }; const createClerkClient = (app: Application) => { return backendCreateClerkClient({ apiUrl: app.env.privateVariables.get('CLERK_API_URL'), diff --git a/integration/testUtils/usersService.ts b/integration/testUtils/usersService.ts index 2914a15f816..8fb16178a2e 100644 --- a/integration/testUtils/usersService.ts +++ b/integration/testUtils/usersService.ts @@ -51,6 +51,8 @@ export type FakeUser = { deleteIfExists: () => Promise; }; +export type FakeUserWithEmail = FakeUser & { email: string }; + export type FakeOrganization = { name: string; organization: { id: string }; diff --git a/integration/tests/handshake/handshake.test.ts b/integration/tests/handshake/handshake.test.ts new file mode 100644 index 00000000000..87717403f30 --- /dev/null +++ b/integration/tests/handshake/handshake.test.ts @@ -0,0 +1,83 @@ +import type { Server, ServerOptions } from 'node:https'; + +import { expect, test } from '@playwright/test'; + +import { constants } from '../../constants'; +import { fs } from '../../scripts'; +import { createProxyServer } from '../../scripts/proxyServer'; +import type { FakeUserWithEmail } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouter.sessionsProd1'] })('handshake flow @handshake', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + test.describe('with Production instance', () => { + // TODO: change host name (see integration/README.md#production-hosts) + const host = 'multiple-apps-e2e.clerk.app:8443'; + + let fakeUser: FakeUserWithEmail; + let server: Server; + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + server.close(); + }); + + test.beforeAll(async () => { + // GIVEN a Production App and Clerk instance + // TODO: Factor out proxy server creation to helper + const ssl: Pick = { + cert: fs.readFileSync(constants.CERTS_DIR + '/sessions.pem'), + key: fs.readFileSync(constants.CERTS_DIR + '/sessions-key.pem'), + }; + + server = createProxyServer({ + ssl, + targets: { + [host]: app.serverUrl, + }, + }); + + const u = createTestUtils({ app, useTestingToken: false }); + // AND an existing user in the instance + fakeUser = u.services.users.createFakeUser({ withEmail: true }) as FakeUserWithEmail; + await u.services.users.createBapiUser(fakeUser); + }); + + test('when the client uat cookies are deleted', async ({ context }) => { + const page = await context.newPage(); + const u = createTestUtils({ app, page, context, useTestingToken: false }); + + // GIVEN the user is signed into the app on the app homepage + await u.page.goto(`https://${host}`); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); + await u.po.expect.toBeSignedIn(); + + // AND the user has no client uat cookies + // (which forces a handshake flow) + await context.clearCookies({ name: /__client_uat.*/ }); + + // WHEN the user goes to the protected page + // (the handshake should happen here) + await u.page.goToRelative('/protected'); + + // THEN the user is signed in + await u.po.expect.toBeSignedIn(); + // AND the user is on the protected page + expect(u.page.url()).toBe(`https://${host}/protected`); + // AND the user has valid cookies (session, client_uat, refresh, etc) + const cookies = await u.page.context().cookies(); + const clientUatCookies = cookies.filter(c => c.name.startsWith('__client_uat')); + // TODO: should we be more specific about the number of cookies? (some are suffixed, some are not) + expect(clientUatCookies.length).toBeGreaterThan(0); + const sessionCookies = cookies.filter(c => c.name.startsWith('__session')); + expect(sessionCookies.length).toBeGreaterThan(0); + const refreshCookies = cookies.filter(c => c.name.startsWith('__refresh')); + expect(refreshCookies.length).toBeGreaterThan(0); + // AND the user does not have temporary cookies (e.g. __clerk_handshake, __clerk_handshake_nonce) + const handshakeCookies = cookies.filter(c => c.name.includes('handshake')); + expect(handshakeCookies.length).toBe(0); + }); + }); +}); diff --git a/integration/tests/sessions/prod-app-migration.test.ts b/integration/tests/sessions/prod-app-migration.test.ts deleted file mode 100644 index 86660c8bb5b..00000000000 --- a/integration/tests/sessions/prod-app-migration.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Server, ServerOptions } from 'node:https'; - -import { expect, test } from '@playwright/test'; - -import { constants } from '../../constants'; -import { appConfigs } from '../../presets'; -import { fs, getPort } from '../../scripts'; -import { createProxyServer } from '../../scripts/proxyServer'; -import type { FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; -import { getEnvForMultiAppInstance } from './utils'; - -test.describe('root and subdomain production apps @manual-run', () => { - test.describe.configure({ mode: 'serial' }); - - test.describe('multiple apps same domain for production instances', () => { - const host = 'multiple-apps-e2e.clerk.app'; - const fakeUsers: FakeUser[] = []; - - let server: Server; - - test.afterAll(async () => { - await Promise.all(fakeUsers.map(u => u.deleteIfExists())); - server.close(); - }); - - test('apps can be used without clearing the cookies after instance switch', async ({ context }) => { - // We need both apps to run on the same port - const port = await getPort(); - - const apps = await Promise.all([ - // Last version before multi-app-same-domain support - await appConfigs.next.appRouter.clone().addDependency('@clerk/nextjs', '5.2.4').commit(), - // Locally-built SDKs - await appConfigs.next.appRouter.clone().commit(), - ]); - - // Write both apps to the disk and install dependencies - await Promise.all(apps.map(a => a.setup())); - - // Start the app with the older SDK version and let it hotload clerkjs from the CF worker - let app = apps[0]; - await app.withEnv(getEnvForMultiAppInstance('sessions-prod-1').setEnvVariable('public', 'CLERK_JS_URL', '')); - await app.dev({ port }); - - // Prepare the proxy server tha maps from the prod domain to the local apps - // We don't need to restart this one as the serverUrl will be the same for both apps - const ssl: Pick = { - cert: fs.readFileSync(constants.CERTS_DIR + '/sessions.pem'), - key: fs.readFileSync(constants.CERTS_DIR + '/sessions-key.pem'), - }; - server = createProxyServer({ ssl, targets: { [host]: apps[0].serverUrl } }); - - const page = await context.newPage(); - let u = createTestUtils({ app, page, context }); - - const fakeUser = u.services.users.createFakeUser(); - fakeUsers.push(fakeUser); - await u.services.users.createBapiUser(fakeUser); - - await u.po.testingToken.setup(); - await u.page.goto(`https://${host}`); - await u.po.signIn.goTo({ timeout: 30000 }); - await u.po.signIn.signInWithEmailAndInstantPassword(fakeUser); - await u.po.expect.toBeSignedIn(); - - expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); - expect((await u.page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe( - (await u.po.clerk.getClientSideUser()).id, - ); - - await u.page.pause(); - // TODO - // Add cookie checks - // ... - - await app.stop(); - - // Switch to and start the app with the latest SDK version - app = apps[1]; - await app.withEnv(getEnvForMultiAppInstance('sessions-prod-1')); - await app.dev({ port }); - - await page.reload(); - u = createTestUtils({ app, page, context }); - - await u.po.expect.toBeSignedIn(); - - expect((await u.po.clerk.getClientSideUser()).primaryEmailAddress.emailAddress).toBe(fakeUser.email); - expect((await u.page.evaluate(() => fetch('/api/me').then(r => r.json()))).userId).toBe( - (await u.po.clerk.getClientSideUser()).id, - ); - - await u.page.pause(); - // TODO - // Add cookie checks - // ... - - await Promise.all(apps.map(a => a.teardown())); - }); - }); -}); diff --git a/package.json b/package.json index 10484e8427a..d8e6be0be03 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:integration:expo-web": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web", "test:integration:express": "E2E_APP_ID=express.* pnpm test:integration:base --grep @express", "test:integration:generic": "E2E_APP_ID=react.vite.*,next.appRouter.withEmailCodes* pnpm test:integration:base --grep @generic", + "test:integration:handshake": "DISABLE_WEB_SECURITY=true E2E_APP_ID=next.appRouter.sessionsProd1 pnpm test:integration:base --grep @handshake", "test:integration:localhost": "pnpm test:integration:base --grep @localhost", "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs", "test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt",