From 1c4af4e03987809933b99c6d3e362c93f2160cf5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 13 Mar 2026 13:09:36 -0400 Subject: [PATCH] test(e2e): Expand tests for Tanstack React Start --- .changeset/tame-snakes-move.md | 2 + integration/presets/longRunningApps.ts | 2 + .../tanstack-react-start/src/routes/index.tsx | 5 +- .../tanstack-react-start/src/routes/me.tsx | 36 ++++++ .../tanstack-start/error-handling.test.ts | 35 ++++++ .../tanstack-start/organizations.test.ts | 117 ++++++++++++++++++ .../tests/tanstack-start/proxy.test.ts | 74 +++++++++++ package.json | 2 +- 8 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 .changeset/tame-snakes-move.md create mode 100644 integration/templates/tanstack-react-start/src/routes/me.tsx create mode 100644 integration/tests/tanstack-start/error-handling.test.ts create mode 100644 integration/tests/tanstack-start/organizations.test.ts create mode 100644 integration/tests/tanstack-start/proxy.test.ts diff --git a/.changeset/tame-snakes-move.md b/.changeset/tame-snakes-move.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tame-snakes-move.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 876c3eaa9ca..0d2352b3e11 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -72,6 +72,8 @@ export const createLongRunningApps = () => { * Tanstack apps - basic flows */ { id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes }, + { id: 'tanstack.react-start.withCustomRoles', config: tanstack.reactStart, env: envs.withCustomRoles }, + { id: 'tanstack.react-start.withEmailCodesProxy', config: tanstack.reactStart, env: envs.withEmailCodesProxy }, /** * Various apps - basic flows diff --git a/integration/templates/tanstack-react-start/src/routes/index.tsx b/integration/templates/tanstack-react-start/src/routes/index.tsx index a179e88ed71..758cbb3b954 100644 --- a/integration/templates/tanstack-react-start/src/routes/index.tsx +++ b/integration/templates/tanstack-react-start/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start'; +import { OrganizationSwitcher, Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/')({ @@ -15,6 +15,9 @@ function Home() {

View your profile here

+
+ +
diff --git a/integration/templates/tanstack-react-start/src/routes/me.tsx b/integration/templates/tanstack-react-start/src/routes/me.tsx new file mode 100644 index 00000000000..57fcfd375df --- /dev/null +++ b/integration/templates/tanstack-react-start/src/routes/me.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import { auth } from '@clerk/tanstack-react-start/server'; + +const fetchAuthData = createServerFn({ method: 'GET' }).handler(async () => { + const { userId, sessionId, orgId, orgRole, orgSlug } = await auth(); + return { userId, sessionId, orgId, orgRole, orgSlug }; +}); + +export const Route = createFileRoute('/me')({ + component: MePage, + beforeLoad: async () => await fetchAuthData(), + loader: async ({ context }) => { + return { + userId: context.userId, + sessionId: context.sessionId, + orgId: context.orgId, + orgRole: context.orgRole, + orgSlug: context.orgSlug, + }; + }, +}); + +function MePage() { + const state = Route.useLoaderData(); + + return ( +
+

{state.userId ?? ''}

+

{state.sessionId ?? ''}

+

{state.orgId ?? ''}

+

{state.orgRole ?? ''}

+

{state.orgSlug ?? ''}

+
+ ); +} diff --git a/integration/tests/tanstack-start/error-handling.test.ts b/integration/tests/tanstack-start/error-handling.test.ts new file mode 100644 index 00000000000..1d3a6c3efaa --- /dev/null +++ b/integration/tests/tanstack-start/error-handling.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import { testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'error handling tests for @tanstack-react-start', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test('request with invalid Authorization header is handled gracefully', async () => { + const url = new URL('/me', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + Authorization: 'Bearer invalid_token_here', + }, + }); + + // Clerk middleware treats an invalid bearer token as unauthenticated (not a crash) + expect(res.status).toBe(200); + }); + + test('request with malformed cookie is handled gracefully', async () => { + const url = new URL('/me', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + Cookie: '__session=malformed_jwt_value; __client_uat=0', + }, + }); + + // Clerk middleware handles malformed cookies gracefully, treating the request as unauthenticated + expect(res.status).toBe(200); + }); + }, +); diff --git a/integration/tests/tanstack-start/organizations.test.ts b/integration/tests/tanstack-start/organizations.test.ts new file mode 100644 index 00000000000..c18e50ac0b9 --- /dev/null +++ b/integration/tests/tanstack-start/organizations.test.ts @@ -0,0 +1,117 @@ +import type { OrganizationMembershipRole } from '@clerk/backend'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })( + 'organization auth tests for @tanstack-react-start', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeAdmin: FakeUser; + let fakeViewer: FakeUser; + let fakeNonMember: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + const m = createTestUtils({ app }); + fakeAdmin = m.services.users.createFakeUser(); + const admin = await m.services.users.createBapiUser(fakeAdmin); + fakeOrganization = await m.services.users.createFakeOrganization(admin.id); + fakeViewer = m.services.users.createFakeUser(); + const viewer = await m.services.users.createBapiUser(fakeViewer); + await m.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:viewer' as OrganizationMembershipRole, + userId: viewer.id, + }); + fakeNonMember = m.services.users.createFakeUser(); + await m.services.users.createBapiUser(fakeNonMember); + }); + + test.afterAll(async () => { + await fakeOrganization.delete(); + await fakeNonMember.deleteIfExists(); + await fakeViewer.deleteIfExists(); + await fakeAdmin.deleteIfExists(); + await app.teardown(); + }); + + test('admin auth object includes orgId, orgRole, orgSlug after selecting org', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeAdmin.email, + password: fakeAdmin.password, + }); + + await u.po.userButton.waitForMounted(); + + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/me'); + + const userId = await u.page.getByTestId('userId').textContent(); + const orgId = await u.page.getByTestId('orgId').textContent(); + const orgRole = await u.page.getByTestId('orgRole').textContent(); + const orgSlug = await u.page.getByTestId('orgSlug').textContent(); + + expect(userId).toBeTruthy(); + expect(orgId).toBe(fakeOrganization.organization.id); + expect(orgRole).toBe('org:admin'); + expect(orgSlug).toBeTruthy(); + }); + + test('non-member auth object has null orgId', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeNonMember.email, + password: fakeNonMember.password, + }); + + await u.po.userButton.waitForMounted(); + + await u.page.goToRelative('/me'); + + const userId = await u.page.getByTestId('userId').textContent(); + const orgId = await u.page.getByTestId('orgId').textContent(); + + expect(userId).toBeTruthy(); + expect(orgId).toBe(''); + }); + + test('viewer org role is correctly reflected in auth response', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeViewer.email, + password: fakeViewer.password, + }); + + await u.po.userButton.waitForMounted(); + + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/me'); + + const userId = await u.page.getByTestId('userId').textContent(); + const orgId = await u.page.getByTestId('orgId').textContent(); + const orgRole = await u.page.getByTestId('orgRole').textContent(); + + expect(userId).toBeTruthy(); + expect(orgId).toBe(fakeOrganization.organization.id); + expect(orgRole).toBe('org:viewer'); + }); + }, +); diff --git a/integration/tests/tanstack-start/proxy.test.ts b/integration/tests/tanstack-start/proxy.test.ts new file mode 100644 index 00000000000..2b2134c91dc --- /dev/null +++ b/integration/tests/tanstack-start/proxy.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })( + 'frontend API proxy tests for @tanstack-react-start', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + 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('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => { + // This test proves that the SDK must derive proxyUrl from x-forwarded-* headers. + // When a reverse proxy sits in front of the app, the raw request URL is localhost, + // but the handshake redirect must point to the public origin. + // + // We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host + // headers, with a __client_uat cookie (non-zero) but no session cookie, which forces + // a handshake. The handshake redirect Location should use the forwarded origin. + const url = new URL('/me', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'myapp.example.com', + 'sec-fetch-dest': 'document', + Accept: 'text/html', + Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1', + }, + redirect: 'manual', + }); + + // The server should respond with a 307 handshake redirect + expect(res.status).toBe(307); + const location = res.headers.get('location') ?? ''; + // The redirect must point to the public origin (from forwarded headers), + // NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead + // of forwarded headers, this assertion will fail. + const decoded = decodeURIComponent(location); + expect(decoded).toContain('https://myapp.example.com'); + expect(decoded).not.toContain('localhost'); + }); + + test('auth works correctly with proxy enabled', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + await u.po.userButton.waitForMounted(); + + await u.page.goToRelative('/me'); + + const userId = await u.page.getByTestId('userId').textContent(); + expect(userId).toBeTruthy(); + }); + }, +); diff --git a/package.json b/package.json index 920a4b4eae3..57229d041af 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router", "test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions", "test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @sessions", - "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start", + "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start* pnpm test:integration:base --grep @tanstack-react-start", "test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue", "test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run", "turbo:clean": "turbo daemon clean",