From 6f069f9422b19ba656decfa0a8ec36594a4a667b Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 13 Mar 2026 10:02:08 -0400 Subject: [PATCH 1/2] test(e2e): Expand Hono testing --- integration/presets/longRunningApps.ts | 1 + .../templates/hono-vite/src/client/main.ts | 5 +- .../templates/hono-vite/src/server/main.ts | 24 ++++ integration/tests/hono/error-handling.test.ts | 52 ++++++++ integration/tests/hono/middleware.test.ts | 82 +++++++++++++ integration/tests/hono/organizations.test.ts | 114 ++++++++++++++++++ integration/tests/hono/webhook.test.ts | 112 +++++++++++++++++ 7 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 integration/tests/hono/error-handling.test.ts create mode 100644 integration/tests/hono/middleware.test.ts create mode 100644 integration/tests/hono/organizations.test.ts create mode 100644 integration/tests/hono/webhook.test.ts diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 9f4caa4b16d..b0216022c29 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -87,6 +87,7 @@ export const createLongRunningApps = () => { */ { id: 'hono.vite.withEmailCodes', config: hono.vite, env: envs.withEmailCodes }, { id: 'hono.vite.withEmailCodesProxy', config: hono.vite, env: envs.withEmailCodesProxy }, + { id: 'hono.vite.withCustomRoles', config: hono.vite, env: envs.withCustomRoles }, ] as const; const apps = configs.map(longRunningApplication); diff --git a/integration/templates/hono-vite/src/client/main.ts b/integration/templates/hono-vite/src/client/main.ts index 7dcc4eb0a36..a21f68d5c23 100644 --- a/integration/templates/hono-vite/src/client/main.ts +++ b/integration/templates/hono-vite/src/client/main.ts @@ -13,11 +13,14 @@ document.addEventListener('DOMContentLoaded', async function () { if (clerk.isSignedIn) { document.getElementById('app')!.innerHTML = `
+
`; const userButtonDiv = document.getElementById('user-button'); - clerk.mountUserButton(userButtonDiv); + + const orgSwitcherDiv = document.getElementById('org-switcher'); + clerk.mountOrganizationSwitcher(orgSwitcherDiv); } else { document.getElementById('app')!.innerHTML = `
diff --git a/integration/templates/hono-vite/src/server/main.ts b/integration/templates/hono-vite/src/server/main.ts index 8128c4b0d7b..f821ee8defa 100644 --- a/integration/templates/hono-vite/src/server/main.ts +++ b/integration/templates/hono-vite/src/server/main.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import { getRequestListener } from '@hono/node-server'; import { clerkMiddleware, getAuth } from '@clerk/hono'; +import { verifyWebhook } from '@clerk/hono/webhooks'; import express from 'express'; import { Hono } from 'hono'; import ViteExpress from 'vite-express'; @@ -27,6 +28,29 @@ app.get('/protected', c => { return c.text('Protected API response'); }); +app.get('/me', c => { + const auth = getAuth(c); + return c.json({ + userId: auth.userId, + sessionId: auth.sessionId, + orgId: auth.orgId ?? null, + orgRole: auth.orgRole ?? null, + orgSlug: auth.orgSlug ?? null, + }); +}); + +// Must match the secret in integration/tests/hono/webhook.test.ts +const TEST_WEBHOOK_SECRET = 'whsec_dGVzdF9zaWduaW5nX3NlY3JldF8zMl9jaGFyc19sb25n'; + +app.post('/webhooks/clerk', async c => { + try { + const evt = await verifyWebhook(c, { signingSecret: TEST_WEBHOOK_SECRET }); + return c.json({ success: true, type: evt.type, data: evt.data }); + } catch (err) { + return c.json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' }, 400); + } +}); + const expressApp = express(); const honoRequestListener = getRequestListener(app.fetch); diff --git a/integration/tests/hono/error-handling.test.ts b/integration/tests/hono/error-handling.test.ts new file mode 100644 index 00000000000..2b4bc65803d --- /dev/null +++ b/integration/tests/hono/error-handling.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import { testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('error handling tests for @hono', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test('direct API call without browser cookies returns null userId', async () => { + const url = new URL('/api/me', app.serverUrl); + const res = await fetch(url.toString()); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.userId).toBeNull(); + }); + + test('request with invalid Authorization header is handled gracefully', async () => { + const url = new URL('/api/me', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + Authorization: 'Bearer invalid_token_here', + }, + }); + + // Should not crash the server — returns either 401 or unauthenticated state + expect([200, 401]).toContain(res.status); + if (res.status === 200) { + const json = await res.json(); + expect(json.userId).toBeNull(); + } + }); + + test('request with malformed cookie is handled gracefully', async () => { + const url = new URL('/api/me', app.serverUrl); + const res = await fetch(url.toString(), { + headers: { + Cookie: '__session=malformed_jwt_value; __client_uat=0', + }, + }); + + // Should not crash — server handles gracefully + expect(res.status).toBeLessThan(500); + }); + + test('non-existent API route returns 404', async () => { + const url = new URL('/api/this-route-does-not-exist', app.serverUrl); + const res = await fetch(url.toString()); + + expect(res.status).toBe(404); + }); +}); diff --git a/integration/tests/hono/middleware.test.ts b/integration/tests/hono/middleware.test.ts new file mode 100644 index 00000000000..6650050a9d4 --- /dev/null +++ b/integration/tests/hono/middleware.test.ts @@ -0,0 +1,82 @@ +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] })( + 'middleware and auth object tests for @hono', + ({ 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('auth object contains userId and sessionId when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/me', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + + const json = await res.json(); + expect(typeof json.userId).toBe('string'); + expect(typeof json.sessionId).toBe('string'); + }); + + test('auth object contains null userId when signed out', async () => { + const url = new URL('/api/me', app.serverUrl); + const res = await fetch(url.toString()); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.userId).toBeNull(); + expect(json.sessionId).toBeNull(); + }); + + test('multiple sequential requests maintain session', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/'); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/me', app.serverUrl); + + const res1 = await u.page.request.get(url.toString()); + const json1 = await res1.json(); + + const res2 = await u.page.request.get(url.toString()); + const json2 = await res2.json(); + + expect(json1.userId).toBeTruthy(); + expect(json1.sessionId).toBeTruthy(); + expect(json1.userId).toBe(json2.userId); + expect(json1.sessionId).toBe(json2.sessionId); + }); + }, +); diff --git a/integration/tests/hono/organizations.test.ts b/integration/tests/hono/organizations.test.ts new file mode 100644 index 00000000000..81d0a1ce6fa --- /dev/null +++ b/integration/tests/hono/organizations.test.ts @@ -0,0 +1,114 @@ +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 @hono', + ({ 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.setIdentifier(fakeAdmin.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeAdmin.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + const url = new URL('/api/me', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + + const json = await res.json(); + expect(json.userId).toBeTruthy(); + expect(json.orgId).toBe(fakeOrganization.organization.id); + expect(json.orgRole).toBe('org:admin'); + expect(json.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.setIdentifier(fakeNonMember.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeNonMember.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + const url = new URL('/api/me', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + + const json = await res.json(); + expect(json.userId).toBeTruthy(); + expect(json.orgId).toBeNull(); + }); + + 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.setIdentifier(fakeViewer.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(fakeViewer.password); + await u.po.signIn.continue(); + + await u.po.userButton.waitForMounted(); + + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + const url = new URL('/api/me', app.serverUrl); + const res = await u.page.request.get(url.toString()); + expect(res.status()).toBe(200); + + const json = await res.json(); + expect(json.userId).toBeTruthy(); + expect(json.orgId).toBe(fakeOrganization.organization.id); + expect(json.orgRole).toBe('org:viewer'); + }); + }, +); diff --git a/integration/tests/hono/webhook.test.ts b/integration/tests/hono/webhook.test.ts new file mode 100644 index 00000000000..39e8347e5f2 --- /dev/null +++ b/integration/tests/hono/webhook.test.ts @@ -0,0 +1,112 @@ +import { createHmac } from 'node:crypto'; + +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import { testAgainstRunningApps } from '../../testUtils'; + +// Must match the hardcoded secret in integration/templates/hono-vite/src/server/main.ts +const TEST_WEBHOOK_SECRET = 'whsec_dGVzdF9zaWduaW5nX3NlY3JldF8zMl9jaGFyc19sb25n'; + +function signPayload(msgId: string, timestamp: string, body: string): string { + const secretBytes = Buffer.from(TEST_WEBHOOK_SECRET.replace('whsec_', ''), 'base64'); + const content = `${msgId}.${timestamp}.${body}`; + const sig = createHmac('sha256', secretBytes).update(content).digest('base64'); + return `v1,${sig}`; +} + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'webhook verification tests for @hono', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test('valid webhook signature returns 200 with parsed event data', async () => { + const body = JSON.stringify({ type: 'user.created', data: { id: 'user_123' } }); + const msgId = 'msg_test1'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = signPayload(msgId, timestamp, body); + + const url = new URL('/api/webhooks/clerk', app.serverUrl); + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'svix-id': msgId, + 'svix-timestamp': timestamp, + 'svix-signature': signature, + }, + body, + }); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + expect(json.type).toBe('user.created'); + expect(json.data.id).toBe('user_123'); + }); + + test('invalid webhook signature returns 400', async () => { + const body = JSON.stringify({ type: 'user.created', data: { id: 'user_123' } }); + const msgId = 'msg_test2'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + + const url = new URL('/api/webhooks/clerk', app.serverUrl); + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'svix-id': msgId, + 'svix-timestamp': timestamp, + 'svix-signature': 'v1,invalid_signature_here', + }, + body, + }); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + test('missing webhook headers returns 400', async () => { + const body = JSON.stringify({ type: 'user.created', data: { id: 'user_123' } }); + + const url = new URL('/api/webhooks/clerk', app.serverUrl); + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body, + }); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + test('tampered body returns 400', async () => { + const originalBody = JSON.stringify({ type: 'user.created', data: { id: 'user_123' } }); + const msgId = 'msg_test4'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = signPayload(msgId, timestamp, originalBody); + + const tamperedBody = JSON.stringify({ type: 'user.created', data: { id: 'user_TAMPERED' } }); + + const url = new URL('/api/webhooks/clerk', app.serverUrl); + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'svix-id': msgId, + 'svix-timestamp': timestamp, + 'svix-signature': signature, + }, + body: tamperedBody, + }); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + }, +); From 4ba5a4314f409d337c5ba850db7d7d15b7a73633 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 13 Mar 2026 10:13:21 -0400 Subject: [PATCH 2/2] chore: Review comments --- .changeset/breezy-monkeys-end.md | 2 ++ integration/tests/hono/error-handling.test.ts | 16 ++++++++-------- integration/tests/hono/middleware.test.ts | 1 + 3 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 .changeset/breezy-monkeys-end.md diff --git a/.changeset/breezy-monkeys-end.md b/.changeset/breezy-monkeys-end.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/breezy-monkeys-end.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/tests/hono/error-handling.test.ts b/integration/tests/hono/error-handling.test.ts index 2b4bc65803d..00f19b0c3f7 100644 --- a/integration/tests/hono/error-handling.test.ts +++ b/integration/tests/hono/error-handling.test.ts @@ -23,12 +23,10 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('error han }, }); - // Should not crash the server — returns either 401 or unauthenticated state - expect([200, 401]).toContain(res.status); - if (res.status === 200) { - const json = await res.json(); - expect(json.userId).toBeNull(); - } + // Clerk middleware treats an invalid bearer token as unauthenticated (not a crash) + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.userId).toBeNull(); }); test('request with malformed cookie is handled gracefully', async () => { @@ -39,8 +37,10 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('error han }, }); - // Should not crash — server handles gracefully - expect(res.status).toBeLessThan(500); + // Clerk middleware handles malformed cookies gracefully, treating the request as unauthenticated + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.userId).toBeNull(); }); test('non-existent API route returns 404', async () => { diff --git a/integration/tests/hono/middleware.test.ts b/integration/tests/hono/middleware.test.ts index 6650050a9d4..64d0f836ff3 100644 --- a/integration/tests/hono/middleware.test.ts +++ b/integration/tests/hono/middleware.test.ts @@ -45,6 +45,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( test('auth object contains null userId when signed out', async () => { const url = new URL('/api/me', app.serverUrl); + // Raw fetch has no browser cookies, simulating an unauthenticated request. const res = await fetch(url.toString()); expect(res.status).toBe(200);