Skip to content

Commit 9a96d73

Browse files
authored
test(e2e): Expand Hono testing (#8063)
1 parent ee6ef4c commit 9a96d73

8 files changed

Lines changed: 392 additions & 1 deletion

File tree

.changeset/breezy-monkeys-end.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const createLongRunningApps = () => {
9494
*/
9595
{ id: 'hono.vite.withEmailCodes', config: hono.vite, env: envs.withEmailCodes },
9696
{ id: 'hono.vite.withEmailCodesProxy', config: hono.vite, env: envs.withEmailCodesProxy },
97+
{ id: 'hono.vite.withCustomRoles', config: hono.vite, env: envs.withCustomRoles },
9798
] as const;
9899

99100
const apps = configs.map(longRunningApplication);

integration/templates/hono-vite/src/client/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ document.addEventListener('DOMContentLoaded', async function () {
1313
if (clerk.isSignedIn) {
1414
document.getElementById('app')!.innerHTML = `
1515
<div id="user-button"></div>
16+
<div id="org-switcher"></div>
1617
`;
1718

1819
const userButtonDiv = document.getElementById('user-button');
19-
2020
clerk.mountUserButton(userButtonDiv);
21+
22+
const orgSwitcherDiv = document.getElementById('org-switcher');
23+
clerk.mountOrganizationSwitcher(orgSwitcherDiv);
2124
} else {
2225
document.getElementById('app')!.innerHTML = `
2326
<div id="sign-in"></div>

integration/templates/hono-vite/src/server/main.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dotenv/config';
22

33
import { getRequestListener } from '@hono/node-server';
44
import { clerkMiddleware, getAuth } from '@clerk/hono';
5+
import { verifyWebhook } from '@clerk/hono/webhooks';
56
import express from 'express';
67
import { Hono } from 'hono';
78
import ViteExpress from 'vite-express';
@@ -27,6 +28,29 @@ app.get('/protected', c => {
2728
return c.text('Protected API response');
2829
});
2930

31+
app.get('/me', c => {
32+
const auth = getAuth(c);
33+
return c.json({
34+
userId: auth.userId,
35+
sessionId: auth.sessionId,
36+
orgId: auth.orgId ?? null,
37+
orgRole: auth.orgRole ?? null,
38+
orgSlug: auth.orgSlug ?? null,
39+
});
40+
});
41+
42+
// Must match the secret in integration/tests/hono/webhook.test.ts
43+
const TEST_WEBHOOK_SECRET = 'whsec_dGVzdF9zaWduaW5nX3NlY3JldF8zMl9jaGFyc19sb25n';
44+
45+
app.post('/webhooks/clerk', async c => {
46+
try {
47+
const evt = await verifyWebhook(c, { signingSecret: TEST_WEBHOOK_SECRET });
48+
return c.json({ success: true, type: evt.type, data: evt.data });
49+
} catch (err) {
50+
return c.json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' }, 400);
51+
}
52+
});
53+
3054
const expressApp = express();
3155
const honoRequestListener = getRequestListener(app.fetch);
3256

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../../presets';
4+
import { testAgainstRunningApps } from '../../testUtils';
5+
6+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('error handling tests for @hono', ({ app }) => {
7+
test.describe.configure({ mode: 'parallel' });
8+
9+
test('direct API call without browser cookies returns null userId', async () => {
10+
const url = new URL('/api/me', app.serverUrl);
11+
const res = await fetch(url.toString());
12+
13+
expect(res.status).toBe(200);
14+
const json = await res.json();
15+
expect(json.userId).toBeNull();
16+
});
17+
18+
test('request with invalid Authorization header is handled gracefully', async () => {
19+
const url = new URL('/api/me', app.serverUrl);
20+
const res = await fetch(url.toString(), {
21+
headers: {
22+
Authorization: 'Bearer invalid_token_here',
23+
},
24+
});
25+
26+
// Clerk middleware treats an invalid bearer token as unauthenticated (not a crash)
27+
expect(res.status).toBe(200);
28+
const json = await res.json();
29+
expect(json.userId).toBeNull();
30+
});
31+
32+
test('request with malformed cookie is handled gracefully', async () => {
33+
const url = new URL('/api/me', app.serverUrl);
34+
const res = await fetch(url.toString(), {
35+
headers: {
36+
Cookie: '__session=malformed_jwt_value; __client_uat=0',
37+
},
38+
});
39+
40+
// Clerk middleware handles malformed cookies gracefully, treating the request as unauthenticated
41+
expect(res.status).toBe(200);
42+
const json = await res.json();
43+
expect(json.userId).toBeNull();
44+
});
45+
46+
test('non-existent API route returns 404', async () => {
47+
const url = new URL('/api/this-route-does-not-exist', app.serverUrl);
48+
const res = await fetch(url.toString());
49+
50+
expect(res.status).toBe(404);
51+
});
52+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../../presets';
4+
import type { FakeUser } from '../../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
8+
'middleware and auth object tests for @hono',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'parallel' });
11+
12+
let fakeUser: FakeUser;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser();
17+
await u.services.users.createBapiUser(fakeUser);
18+
});
19+
20+
test.afterAll(async () => {
21+
await fakeUser.deleteIfExists();
22+
await app.teardown();
23+
});
24+
25+
test('auth object contains userId and sessionId when signed in', async ({ page, context }) => {
26+
const u = createTestUtils({ app, page, context });
27+
await u.page.goToRelative('/');
28+
29+
await u.po.signIn.waitForMounted();
30+
await u.po.signIn.setIdentifier(fakeUser.email);
31+
await u.po.signIn.continue();
32+
await u.po.signIn.setPassword(fakeUser.password);
33+
await u.po.signIn.continue();
34+
35+
await u.po.userButton.waitForMounted();
36+
37+
const url = new URL('/api/me', app.serverUrl);
38+
const res = await u.page.request.get(url.toString());
39+
expect(res.status()).toBe(200);
40+
41+
const json = await res.json();
42+
expect(typeof json.userId).toBe('string');
43+
expect(typeof json.sessionId).toBe('string');
44+
});
45+
46+
test('auth object contains null userId when signed out', async () => {
47+
const url = new URL('/api/me', app.serverUrl);
48+
// Raw fetch has no browser cookies, simulating an unauthenticated request.
49+
const res = await fetch(url.toString());
50+
51+
expect(res.status).toBe(200);
52+
const json = await res.json();
53+
expect(json.userId).toBeNull();
54+
expect(json.sessionId).toBeNull();
55+
});
56+
57+
test('multiple sequential requests maintain session', async ({ page, context }) => {
58+
const u = createTestUtils({ app, page, context });
59+
await u.page.goToRelative('/');
60+
61+
await u.po.signIn.waitForMounted();
62+
await u.po.signIn.setIdentifier(fakeUser.email);
63+
await u.po.signIn.continue();
64+
await u.po.signIn.setPassword(fakeUser.password);
65+
await u.po.signIn.continue();
66+
67+
await u.po.userButton.waitForMounted();
68+
69+
const url = new URL('/api/me', app.serverUrl);
70+
71+
const res1 = await u.page.request.get(url.toString());
72+
const json1 = await res1.json();
73+
74+
const res2 = await u.page.request.get(url.toString());
75+
const json2 = await res2.json();
76+
77+
expect(json1.userId).toBeTruthy();
78+
expect(json1.sessionId).toBeTruthy();
79+
expect(json1.userId).toBe(json2.userId);
80+
expect(json1.sessionId).toBe(json2.sessionId);
81+
});
82+
},
83+
);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { OrganizationMembershipRole } from '@clerk/backend';
2+
import { expect, test } from '@playwright/test';
3+
4+
import { appConfigs } from '../../presets';
5+
import type { FakeOrganization, FakeUser } from '../../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
7+
8+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })(
9+
'organization auth tests for @hono',
10+
({ app }) => {
11+
test.describe.configure({ mode: 'serial' });
12+
13+
let fakeAdmin: FakeUser;
14+
let fakeViewer: FakeUser;
15+
let fakeNonMember: FakeUser;
16+
let fakeOrganization: FakeOrganization;
17+
18+
test.beforeAll(async () => {
19+
const m = createTestUtils({ app });
20+
fakeAdmin = m.services.users.createFakeUser();
21+
const admin = await m.services.users.createBapiUser(fakeAdmin);
22+
fakeOrganization = await m.services.users.createFakeOrganization(admin.id);
23+
fakeViewer = m.services.users.createFakeUser();
24+
const viewer = await m.services.users.createBapiUser(fakeViewer);
25+
await m.services.clerk.organizations.createOrganizationMembership({
26+
organizationId: fakeOrganization.organization.id,
27+
role: 'org:viewer' as OrganizationMembershipRole,
28+
userId: viewer.id,
29+
});
30+
fakeNonMember = m.services.users.createFakeUser();
31+
await m.services.users.createBapiUser(fakeNonMember);
32+
});
33+
34+
test.afterAll(async () => {
35+
await fakeOrganization.delete();
36+
await fakeNonMember.deleteIfExists();
37+
await fakeViewer.deleteIfExists();
38+
await fakeAdmin.deleteIfExists();
39+
await app.teardown();
40+
});
41+
42+
test('admin auth object includes orgId, orgRole, orgSlug after selecting org', async ({ page, context }) => {
43+
const u = createTestUtils({ app, page, context });
44+
await u.page.goToRelative('/');
45+
46+
await u.po.signIn.waitForMounted();
47+
await u.po.signIn.setIdentifier(fakeAdmin.email);
48+
await u.po.signIn.continue();
49+
await u.po.signIn.setPassword(fakeAdmin.password);
50+
await u.po.signIn.continue();
51+
52+
await u.po.userButton.waitForMounted();
53+
54+
await u.po.organizationSwitcher.waitForMounted();
55+
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
56+
57+
const url = new URL('/api/me', app.serverUrl);
58+
const res = await u.page.request.get(url.toString());
59+
expect(res.status()).toBe(200);
60+
61+
const json = await res.json();
62+
expect(json.userId).toBeTruthy();
63+
expect(json.orgId).toBe(fakeOrganization.organization.id);
64+
expect(json.orgRole).toBe('org:admin');
65+
expect(json.orgSlug).toBeTruthy();
66+
});
67+
68+
test('non-member auth object has null orgId', async ({ page, context }) => {
69+
const u = createTestUtils({ app, page, context });
70+
await u.page.goToRelative('/');
71+
72+
await u.po.signIn.waitForMounted();
73+
await u.po.signIn.setIdentifier(fakeNonMember.email);
74+
await u.po.signIn.continue();
75+
await u.po.signIn.setPassword(fakeNonMember.password);
76+
await u.po.signIn.continue();
77+
78+
await u.po.userButton.waitForMounted();
79+
80+
const url = new URL('/api/me', app.serverUrl);
81+
const res = await u.page.request.get(url.toString());
82+
expect(res.status()).toBe(200);
83+
84+
const json = await res.json();
85+
expect(json.userId).toBeTruthy();
86+
expect(json.orgId).toBeNull();
87+
});
88+
89+
test('viewer org role is correctly reflected in auth response', async ({ page, context }) => {
90+
const u = createTestUtils({ app, page, context });
91+
await u.page.goToRelative('/');
92+
93+
await u.po.signIn.waitForMounted();
94+
await u.po.signIn.setIdentifier(fakeViewer.email);
95+
await u.po.signIn.continue();
96+
await u.po.signIn.setPassword(fakeViewer.password);
97+
await u.po.signIn.continue();
98+
99+
await u.po.userButton.waitForMounted();
100+
101+
await u.po.organizationSwitcher.waitForMounted();
102+
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
103+
104+
const url = new URL('/api/me', app.serverUrl);
105+
const res = await u.page.request.get(url.toString());
106+
expect(res.status()).toBe(200);
107+
108+
const json = await res.json();
109+
expect(json.userId).toBeTruthy();
110+
expect(json.orgId).toBe(fakeOrganization.organization.id);
111+
expect(json.orgRole).toBe('org:viewer');
112+
});
113+
},
114+
);

0 commit comments

Comments
 (0)