Skip to content

Commit 1f804dc

Browse files
authored
test(e2e): improve integration test reliability (#8422)
1 parent 7680859 commit 1f804dc

7 files changed

Lines changed: 54 additions & 117 deletions

File tree

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

integration/models/helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ const dedent = (strings: string | Array<string>, ...values: Array<string>) => {
6767

6868
export const hash = () => randomBytes(5).toString('hex');
6969

70+
/**
71+
* Generates a strong, unique password for fake test users.
72+
*
73+
* Avoids any pattern derived from the user's email or other guessable inputs,
74+
* so it doesn't collide with HIBP / compromised-password lists that would
75+
* cause FAPI to reject sign-in with `form_password_compromised` (HTTP 422).
76+
*
77+
* Includes upper, lower, digit, and symbol to satisfy default Clerk password
78+
* complexity rules.
79+
*/
80+
export const fakerPassword = () => {
81+
const bytes = randomBytes(18).toString('base64url');
82+
return `Aa1!${bytes}`;
83+
};
84+
7085
export const waitUntilMessage = async (stream: Readable, message: string) => {
7186
return new Promise<void>(resolve => {
7287
stream.on('data', chunk => {

integration/testUtils/machineAuthHelpers.ts

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,9 @@ export const registerApiKeyAuthTests = (adapter: MachineAuthTestAdapter): void =
220220
});
221221

222222
test.afterAll(async () => {
223-
await fakeAPIKey.revoke();
224-
await fakeUser.deleteIfExists();
225-
await app.teardown();
223+
await fakeAPIKey?.revoke();
224+
await fakeUser?.deleteIfExists();
225+
await app?.teardown();
226226
});
227227

228228
test('should return 401 if no API key is provided', async ({ request }) => {
@@ -311,8 +311,8 @@ export const registerM2MAuthTests = (adapter: MachineAuthTestAdapter): void => {
311311
});
312312

313313
test.afterAll(async () => {
314-
await network.cleanup();
315-
await app.teardown();
314+
await network?.cleanup();
315+
await app?.teardown();
316316
});
317317

318318
test('rejects requests with invalid M2M tokens', async ({ request }) => {
@@ -345,28 +345,6 @@ export const registerM2MAuthTests = (adapter: MachineAuthTestAdapter): void => {
345345
expect(body.tokenType).toBe(TokenType.M2MToken);
346346
});
347347

348-
test('authorizes after dynamically granting scope', async ({ page, context }) => {
349-
const u = createTestUtils({ app, page, context });
350-
351-
await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id);
352-
const m2mToken = await u.services.clerk.m2m.createToken({
353-
machineSecretKey: network.unscopedSender.secretKey,
354-
secondsUntilExpiration: 60 * 30,
355-
});
356-
357-
try {
358-
const res = await u.page.request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), {
359-
headers: { Authorization: `Bearer ${m2mToken.token}` },
360-
});
361-
expect(res.status()).toBe(200);
362-
const body = await res.json();
363-
expect(body.subject).toBe(network.unscopedSender.id);
364-
expect(body.tokenType).toBe(TokenType.M2MToken);
365-
} finally {
366-
await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id });
367-
}
368-
});
369-
370348
test('verifies JWT format M2M token via local verification', async ({ request }) => {
371349
const jwtToken = await createJwtM2MToken(createMachineClient(), network.scopedSender.secretKey);
372350

@@ -418,9 +396,9 @@ export const registerOAuthAuthTests = (adapter: MachineAuthTestAdapter): void =>
418396
});
419397

420398
test.afterAll(async () => {
421-
await fakeOAuth.cleanup();
422-
await fakeUser.deleteIfExists();
423-
await app.teardown();
399+
await fakeOAuth?.cleanup();
400+
await fakeUser?.deleteIfExists();
401+
await app?.teardown();
424402
});
425403

426404
test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => {

integration/testUtils/usersService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { APIKey, ClerkClient, Organization, User } from '@clerk/backend';
22
import { faker } from '@faker-js/faker';
33

4-
import { hash } from '../models/helpers';
4+
import { fakerPassword, hash } from '../models/helpers';
55

66
async function withErrorLogging<T>(operation: string, fn: () => Promise<T>): Promise<T> {
77
try {
@@ -133,7 +133,7 @@ export const createUserService = (clerkClient: ClerkClient) => {
133133
lastName: faker.person.lastName(),
134134
email: withEmail ? email : undefined,
135135
username: withUsername ? `${randomHash}_clerk_cookie` : undefined,
136-
password: withPassword ? `${email}${randomHash}` : undefined,
136+
password: withPassword ? fakerPassword() : undefined,
137137
phoneNumber: withPhoneNumber ? phoneNumber : undefined,
138138
deleteIfExists: () => self.deleteIfExists({ email, phoneNumber }),
139139
};

integration/tests/components.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component
2020

2121
test.afterAll(async () => {
2222
await app.teardown();
23-
await fakeUser.deleteIfExists();
24-
await fakeOrganization.delete();
23+
await fakeUser?.deleteIfExists();
24+
await fakeOrganization?.delete();
2525
});
2626

2727
const components = [

integration/tests/session-token-cache/multi-session.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
246246
* this might be something we want to add in the future, but currently it is not
247247
* deterministic.
248248
*/
249-
test('multi-session scheduled refreshes produce one request per session', async ({ context }) => {
250-
test.setTimeout(90_000);
251-
249+
test('cross-session token refreshes do not deduplicate', async ({ context }) => {
252250
const page1 = await context.newPage();
253251
await page1.goto(app.serverUrl);
254252
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
@@ -297,7 +295,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
297295
expect(user2SessionId).not.toBe(user1SessionId);
298296

299297
// Tab1 has user1's active session; tab2 has user2's active session.
300-
// Start counting /tokens requests.
298+
// Start counting /tokens requests from here on.
301299
const refreshRequests: Array<{ sessionId: string; url: string }> = [];
302300
await context.route('**/v1/client/sessions/*/tokens*', async route => {
303301
const url = route.request().url();
@@ -306,23 +304,26 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
306304
await route.continue();
307305
});
308306

309-
// Wait for proactive refresh timers to fire.
310-
// Default token TTL is 60s; onRefresh fires at 60 - 15 - 2 = 43s from iat.
311-
// Uses page.evaluate to avoid the global actionTimeout (10s) capping the wait.
312-
await page1.evaluate(() => new Promise(resolve => setTimeout(resolve, 50_000)));
307+
// Manually trigger a fresh /tokens fetch on each tab. Because the two
308+
// tabs hold different sessions (different tokenIds), BroadcastChannel
309+
// does NOT deduplicate across them — each tab is expected to make its
310+
// own request.
311+
const [page1Token, page2Token] = await Promise.all([
312+
page1.evaluate(() => (window as any).Clerk.session?.getToken({ skipCache: true })),
313+
page2.evaluate(() => (window as any).Clerk.session?.getToken({ skipCache: true })),
314+
]);
315+
316+
// Allow both broadcasts to settle.
317+
// eslint-disable-next-line playwright/no-wait-for-timeout
318+
await page1.waitForTimeout(500);
313319

314-
// Two different sessions should each produce exactly one refresh request.
315-
// BroadcastChannel deduplication is per-tokenId, so different sessions refresh independently.
316320
expect(refreshRequests.length).toBe(2);
317321

318322
const refreshedSessionIds = new Set(refreshRequests.map(r => r.sessionId));
319323
expect(refreshedSessionIds.has(user1SessionId)).toBe(true);
320324
expect(refreshedSessionIds.has(user2SessionId)).toBe(true);
321325

322-
// Both tabs should still have valid tokens after the refresh cycle
323-
const page1Token = await page1.evaluate(() => (window as any).Clerk.session?.getToken());
324-
const page2Token = await page2.evaluate(() => (window as any).Clerk.session?.getToken());
325-
326+
// Both tabs should hold valid, distinct tokens (different sessions).
326327
expect(page1Token).toBeTruthy();
327328
expect(page2Token).toBeTruthy();
328329
expect(page1Token).not.toBe(page2Token);

integration/tests/session-token-cache/single-session.test.ts

Lines changed: 10 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -129,74 +129,15 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
129129
expect(tokenRequests.length).toBe(1);
130130
});
131131

132-
/**
133-
* Test Flow:
134-
* 1. Open two tabs with the same browser context (shared cookies)
135-
* 2. Sign in on tab1, reload tab2 to pick up the session
136-
* 3. Both tabs hydrate their token cache with the session token
137-
* 4. Start counting /tokens requests, then wait for the timers to fire
138-
* 5. Assert only 1 /tokens request was made (not 2)
139-
*/
140-
test('multi-tab scheduled refreshes are deduped to a single request', async ({ context }) => {
141-
test.setTimeout(90_000);
142-
143-
const page1 = await context.newPage();
144-
const page2 = await context.newPage();
145-
146-
await page1.goto(app.serverUrl);
147-
await page2.goto(app.serverUrl);
148-
149-
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
150-
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
151-
152-
const u1 = createTestUtils({ app, page: page1 });
153-
await u1.po.signIn.goTo();
154-
await u1.po.signIn.setIdentifier(fakeUser.email);
155-
await u1.po.signIn.continue();
156-
await u1.po.signIn.setPassword(fakeUser.password);
157-
await u1.po.signIn.continue();
158-
await u1.po.expect.toBeSignedIn();
159-
160-
// eslint-disable-next-line playwright/no-wait-for-timeout
161-
await page1.waitForTimeout(1000);
162-
163-
await page2.reload();
164-
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
165-
166-
const u2 = createTestUtils({ app, page: page2 });
167-
await u2.po.expect.toBeSignedIn();
168-
169-
// Both tabs are now signed in and have hydrated their token caches
170-
// via Session constructor -> #hydrateCache, each with an independent
171-
// onRefresh timer that fires at ~43s (TTL 60s - 15s leeway - 2s lead).
172-
// Start counting /tokens requests from this point.
173-
const refreshRequests: string[] = [];
174-
await context.route('**/v1/client/sessions/*/tokens*', async route => {
175-
refreshRequests.push(route.request().url());
176-
await route.continue();
177-
});
178-
179-
// Wait for proactive refresh timers to fire.
180-
// Default token TTL is 60s; onRefresh fires at 60 - 15 - 2 = 43s from iat.
181-
// We wait 50s to give comfortable buffer, this includes the broadcast delay.
182-
//
183-
// Uses page.evaluate instead of page.waitForTimeout to avoid
184-
// the global actionTimeout (10s) silently capping the wait.
185-
await page1.evaluate(() => new Promise(resolve => setTimeout(resolve, 50_000)));
186-
187-
// Only one tab should have made a /tokens request; the other tab should have
188-
// received the refreshed token via BroadcastChannel.
189-
expect(refreshRequests.length).toBe(1);
190-
191-
// Both tabs should still have valid tokens after the refresh cycle
192-
const [page1Token, page2Token] = await Promise.all([
193-
page1.evaluate(() => (window as any).Clerk.session?.getToken()),
194-
page2.evaluate(() => (window as any).Clerk.session?.getToken()),
195-
]);
196-
197-
expect(page1Token).toBeTruthy();
198-
expect(page2Token).toBeTruthy();
199-
expect(page1Token).toBe(page2Token);
200-
});
132+
// The previous "multi-tab scheduled refreshes are deduped to a single request"
133+
// test relied on the proactive-refresh setTimeout firing within a 50s wall-clock
134+
// window, which assumed JWT TTL = 60s. The dev test instance now issues 300s
135+
// tokens, so the timer fires at ~283s and the test never reached it. The
136+
// BroadcastChannel-based dedup it was checking is already covered by the
137+
// "multi-tab token sharing works when clearing the cache" test above, which
138+
// explicitly triggers a fetch via `getToken({ skipCache: true })`. The
139+
// proactive-refresh timer scheduling itself (the math, the leeway, the
140+
// re-registration on success) is best validated by unit tests that mock
141+
// `setTimeout` rather than depending on real time in a real browser.
201142
},
202143
);

0 commit comments

Comments
 (0)