Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/breezy-monkeys-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion integration/templates/hono-vite/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ document.addEventListener('DOMContentLoaded', async function () {
if (clerk.isSignedIn) {
document.getElementById('app')!.innerHTML = `
<div id="user-button"></div>
<div id="org-switcher"></div>
`;

const userButtonDiv = document.getElementById('user-button');

clerk.mountUserButton(userButtonDiv);

const orgSwitcherDiv = document.getElementById('org-switcher');
clerk.mountOrganizationSwitcher(orgSwitcherDiv);
} else {
document.getElementById('app')!.innerHTML = `
<div id="sign-in"></div>
Expand Down
24 changes: 24 additions & 0 deletions integration/templates/hono-vite/src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down
52 changes: 52 additions & 0 deletions integration/tests/hono/error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});

// 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 () => {
const url = new URL('/api/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);
const json = await res.json();
expect(json.userId).toBeNull();
});

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);
});
});
83 changes: 83 additions & 0 deletions integration/tests/hono/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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);
// Raw fetch has no browser cookies, simulating an unauthenticated request.
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);
});
},
);
114 changes: 114 additions & 0 deletions integration/tests/hono/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
},
);
Loading
Loading