From 63c86ac2336454680fc29a157518e3ea77eb14e0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Feb 2026 13:33:12 +0200 Subject: [PATCH] test(integration): add AP Core 3 migration coverage for v5 SDK, sign-out, and handshake recovery Why: Account Portal is being auto-bumped to Core 3 for all instances. Existing tests only cover happy-path sign-in/sign-up/SSR for v6 and v7 SDKs. This leaves gaps in backwards compatibility validation for older SDKs and in edge cases that could surface Core 2/Core 3 protocol differences. What changed: - Added v5 @clerk/nextjs test suite against Core 3 staging AP (sign-in, sign-up, SSR) to validate the oldest supported Core 2 SDK still works with Core 3 AP using the legacy token-based handshake protocol. - Added sign-out test to v6 and v7 suites: signs in via AP, signs out, verifies session cookie is cleared, reload stays signed out, and AP shows sign-in form on next visit (no stale cross-domain state). - Added handshake recovery test to v6 and v7 suites: signs in via AP, deletes __session cookie to simulate expiry, reloads page, verifies handshake recovers the session without redirect loop and no leftover handshake params in the URL. - Added withAPCore3ClerkV5 env and appRouterAPWithClerkNextV5 app config (reuses v6 template since both use SignedIn/SignedOut and clerkMiddleware). --- integration/presets/envs.ts | 9 ++ integration/presets/next.ts | 6 ++ .../clerk-ap-core-3-v5.test.ts | 41 +++++++++ .../clerk-ap-core-3-v6.test.ts | 10 ++- .../clerk-ap-core-3-v7.test.ts | 10 ++- .../tests/next-account-portal/common.ts | 86 +++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 integration/tests/next-account-portal/clerk-ap-core-3-v5.test.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 413cff07792..9d855b1db8b 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -86,6 +86,14 @@ const withEmailCodesQuickstart = withEmailCodes .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '') .setEnvVariable('public', 'CLERK_SIGN_UP_URL', ''); +// Uses staging instance which runs Core 3 +const withAPCore3ClerkV5 = environmentConfig() + .setId('withAPCore3ClerkV5') + .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) + .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-billing-staging').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-billing-staging').pk); + // Uses staging instance which runs Core 3 const withAPCore3ClerkV6 = environmentConfig() .setId('withAPCore3ClerkV6') @@ -206,6 +214,7 @@ export const envs = { sessionsProd1, withAPIKeys, withAPCore3ClerkLatest, + withAPCore3ClerkV5, withAPCore3ClerkV6, withBilling, withBillingJwtV2, diff --git a/integration/presets/next.ts b/integration/presets/next.ts index ae212742525..ea6dbabf9d6 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -31,6 +31,11 @@ const appRouterQuickstartV6 = appRouter .setName('next-app-router-quickstart-v6') .useTemplate(templates['next-app-router-quickstart-v6']); +const appRouterAPWithClerkNextV5 = appRouterQuickstartV6 + .clone() + .setName('next-app-router-ap-clerk-next-v5') + .addDependency('@clerk/nextjs', '5'); + const appRouterAPWithClerkNextV6 = appRouterQuickstartV6 .clone() .setName('next-app-router-ap-clerk-next-v6') @@ -67,6 +72,7 @@ export const next = { appRouterTurbo, appRouterQuickstart, appRouterAPWithClerkNextLatest, + appRouterAPWithClerkNextV5, appRouterAPWithClerkNextV6, appRouterQuickstartV6, appRouterBundledUI, diff --git a/integration/tests/next-account-portal/clerk-ap-core-3-v5.test.ts b/integration/tests/next-account-portal/clerk-ap-core-3-v5.test.ts new file mode 100644 index 00000000000..49bd60e8935 --- /dev/null +++ b/integration/tests/next-account-portal/clerk-ap-core-3-v5.test.ts @@ -0,0 +1,41 @@ +import { test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; +import { testSignIn, testSignUp, testSSR } from './common'; + +test.describe('Next with ClerkJS V5 <-> Account Portal Core 3 @ap-flows', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(90_000); // Wait for app to be ready + app = await appConfigs.next.appRouterAPWithClerkNextV5.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withAPCore3ClerkV5); + await app.dev(); + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('sign in', async ({ page, context }) => { + await testSignIn({ app, page, context, fakeUser }); + }); + + test('sign up', async ({ page, context }) => { + await testSignUp({ app, page, context, fakeUser }); + }); + + test('ssr', async ({ page, context }) => { + await testSSR({ app, page, context, fakeUser }); + }); +}); diff --git a/integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts b/integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts index e4630add21c..711562bd142 100644 --- a/integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts +++ b/integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts @@ -4,7 +4,7 @@ import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; import type { FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -import { testSignIn, testSignUp, testSSR } from './common'; +import { testHandshakeRecovery, testSignIn, testSignOut, testSignUp, testSSR } from './common'; test.describe('Next with ClerkJS V6 <-> Account Portal Core 3 @ap-flows', () => { test.describe.configure({ mode: 'serial' }); @@ -38,4 +38,12 @@ test.describe('Next with ClerkJS V6 <-> Account Portal Core 3 @ap-flows', () => test('ssr', async ({ page, context }) => { await testSSR({ app, page, context, fakeUser }); }); + + test('sign out clears session and AP state', async ({ page, context }) => { + await testSignOut({ app, page, context, fakeUser }); + }); + + test('handshake recovery after session cookie loss', async ({ page, context }) => { + await testHandshakeRecovery({ app, page, context, fakeUser }); + }); }); diff --git a/integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts b/integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts index 3896b9e9dc3..90a54af635a 100644 --- a/integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts +++ b/integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts @@ -4,7 +4,7 @@ import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; import type { FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -import { testSignIn, testSignUp, testSSR } from './common'; +import { testHandshakeRecovery, testSignIn, testSignOut, testSignUp, testSSR } from './common'; test.describe('Next with ClerkJS V7 <-> Account Portal Core 3 @ap-flows', () => { test.describe.configure({ mode: 'serial' }); @@ -38,4 +38,12 @@ test.describe('Next with ClerkJS V7 <-> Account Portal Core 3 @ap-flows', () => test('ssr', async ({ page, context }) => { await testSSR({ app, page, context, fakeUser }); }); + + test('sign out clears session and AP state', async ({ page, context }) => { + await testSignOut({ app, page, context, fakeUser }); + }); + + test('handshake recovery after session cookie loss', async ({ page, context }) => { + await testHandshakeRecovery({ app, page, context, fakeUser }); + }); }); diff --git a/integration/tests/next-account-portal/common.ts b/integration/tests/next-account-portal/common.ts index 09762e0fb13..282eb5c6513 100644 --- a/integration/tests/next-account-portal/common.ts +++ b/integration/tests/next-account-portal/common.ts @@ -182,3 +182,89 @@ export const testSSR = async ({ app, page, context, fakeUser }: TestParams) => { expect(await u.po.userButton.waitForMounted()).not.toBeUndefined(); }; + +export const testSignOut = async ({ app, page, context, fakeUser }: TestParams) => { + const u = createTestUtils({ app, page, context, useTestingToken: false }); + + // Sign in via Account Portal first + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.page.getByRole('button', { name: /Sign in/i }).click(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.waitForAppUrl('/'); + await u.po.expect.toBeSignedIn(); + await u.po.userButton.waitForMounted(); + + // Verify session cookie is set before sign-out + const sessionBefore = await context + .cookies(page.url()) + .then(cookies => cookies.find(c => c.name === CLERK_SESSION_COOKIE_NAME)?.value); + expect(!!sessionBefore).toBeTruthy(); + + // Sign out via Clerk.signOut() + await page.evaluate(() => window.Clerk.signOut()); + await u.po.expect.toBeSignedOut(); + + // Verify session cookie is cleared + const sessionAfter = await context + .cookies(page.url()) + .then(cookies => cookies.find(c => c.name === CLERK_SESSION_COOKIE_NAME)?.value); + expect(!!sessionAfter).toBeFalsy(); + + // Reload and verify user stays signed out (no auto-sign-in from stale state) + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + // Navigate to AP again and verify sign-in form is shown (not auto-signed-in) + await u.page.getByRole('button', { name: /Sign in/i }).click(); + await u.po.signIn.waitForMounted(); + const apURL = page.url(); + expect(apURL).toMatch(/\.accounts(stage\.dev|\.dev|\.stg)/); +}; + +export const testHandshakeRecovery = async ({ app, page, context, fakeUser }: TestParams) => { + const u = createTestUtils({ app, page, context, useTestingToken: false }); + + // Sign in via Account Portal + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.page.getByRole('button', { name: /Sign in/i }).click(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.waitForAppUrl('/'); + await u.po.expect.toBeSignedIn(); + + // Delete the __session cookie to simulate an expired/invalid session. + // Keep __client_uat so the middleware detects a mismatch and triggers a handshake. + const appUrl = new URL(page.url()); + await context.clearCookies({ name: CLERK_SESSION_COOKIE_NAME, domain: appUrl.hostname }); + + // Reload the page. The middleware should: + // 1. Detect missing session + present client_uat + // 2. Trigger a handshake redirect to FAPI + // 3. FAPI resolves the handshake and returns fresh cookies + // 4. User ends up signed in again (no redirect loop, no error) + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + // The page should load successfully (not stuck in a redirect loop). + // The user should be signed in because the handshake recovered the session. + await u.po.expect.toBeSignedIn(); + + // Verify the session cookie was re-established by the handshake + const sessionAfterRecovery = await context + .cookies(page.url()) + .then(cookies => cookies.find(c => c.name === CLERK_SESSION_COOKIE_NAME)?.value); + expect(!!sessionAfterRecovery).toBeTruthy(); + + // Verify no leftover handshake params in the URL + const finalURL = new URL(page.url()); + expect(finalURL.searchParams.has('__clerk_handshake')).toBeFalsy(); + expect(finalURL.searchParams.has('__clerk_handshake_nonce')).toBeFalsy(); +};