Skip to content

Commit ad782ff

Browse files
authored
chore(repo): refactor machine auth tests for Next.js and Astro (#8124)
1 parent adc7718 commit ad782ff

8 files changed

Lines changed: 868 additions & 737 deletions

File tree

integration/testUtils/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import { createUserService } from './usersService';
1212
import { createWaitlistService } from './waitlistService';
1313

1414
export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail };
15+
export type { FakeMachineNetwork, FakeOAuthApp } from './machineAuthService';
16+
export {
17+
createFakeMachineNetwork,
18+
createFakeOAuthApp,
19+
createJwtM2MToken,
20+
obtainOAuthAccessToken,
21+
} from './machineAuthService';
1522

1623
const createClerkClient = (app: Application) => {
1724
return backendCreateClerkClient({
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { randomBytes } from 'node:crypto';
2+
3+
import type { ClerkClient, M2MToken, Machine, OAuthApplication } from '@clerk/backend';
4+
import { faker } from '@faker-js/faker';
5+
import type { Page } from '@playwright/test';
6+
import { expect } from '@playwright/test';
7+
8+
// ─── M2M ────────────────────────────────────────────────────────────────────
9+
10+
export type FakeMachineNetwork = {
11+
primaryServer: Machine;
12+
scopedSender: Machine;
13+
unscopedSender: Machine;
14+
scopedSenderToken: M2MToken;
15+
unscopedSenderToken: M2MToken;
16+
cleanup: () => Promise<void>;
17+
};
18+
19+
/**
20+
* Creates a network of three machines for M2M testing:
21+
* - A primary API server (the "receiver")
22+
* - A sender machine scoped to the primary (should succeed)
23+
* - A sender machine with no scope (should fail)
24+
*
25+
* Each sender gets an opaque M2M token created for it.
26+
* Call `cleanup()` to revoke tokens and delete all machines.
27+
*/
28+
export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise<FakeMachineNetwork> {
29+
const fakeCompanyName = faker.company.name();
30+
31+
const primaryServer = await clerkClient.machines.create({
32+
name: `${fakeCompanyName} Primary API Server`,
33+
});
34+
35+
const scopedSender = await clerkClient.machines.create({
36+
name: `${fakeCompanyName} Scoped Sender`,
37+
scopedMachines: [primaryServer.id],
38+
});
39+
const scopedSenderToken = await clerkClient.m2m.createToken({
40+
machineSecretKey: scopedSender.secretKey,
41+
secondsUntilExpiration: 60 * 30,
42+
});
43+
44+
const unscopedSender = await clerkClient.machines.create({
45+
name: `${fakeCompanyName} Unscoped Sender`,
46+
});
47+
const unscopedSenderToken = await clerkClient.m2m.createToken({
48+
machineSecretKey: unscopedSender.secretKey,
49+
secondsUntilExpiration: 60 * 30,
50+
});
51+
52+
return {
53+
primaryServer,
54+
scopedSender,
55+
unscopedSender,
56+
scopedSenderToken,
57+
unscopedSenderToken,
58+
cleanup: async () => {
59+
await Promise.all([
60+
clerkClient.m2m.revokeToken({ m2mTokenId: scopedSenderToken.id }),
61+
clerkClient.m2m.revokeToken({ m2mTokenId: unscopedSenderToken.id }),
62+
]);
63+
await Promise.all([
64+
clerkClient.machines.delete(scopedSender.id),
65+
clerkClient.machines.delete(unscopedSender.id),
66+
clerkClient.machines.delete(primaryServer.id),
67+
]);
68+
},
69+
};
70+
}
71+
72+
/**
73+
* Creates a JWT-format M2M token for a sender machine.
74+
* JWT tokens are self-contained and expire via the `exp` claim (no revocation needed).
75+
*/
76+
export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise<M2MToken> {
77+
return clerkClient.m2m.createToken({
78+
machineSecretKey: senderSecretKey,
79+
secondsUntilExpiration: 60 * 30,
80+
tokenFormat: 'jwt',
81+
});
82+
}
83+
84+
// ─── OAuth ──────────────────────────────────────────────────────────────────
85+
86+
export type FakeOAuthApp = {
87+
oAuthApp: OAuthApplication;
88+
cleanup: () => Promise<void>;
89+
};
90+
91+
/**
92+
* Creates an OAuth application via BAPI for testing the full authorization code flow.
93+
* Call `cleanup()` to delete the OAuth application.
94+
*/
95+
export async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise<FakeOAuthApp> {
96+
const oAuthApp = await clerkClient.oauthApplications.create({
97+
name: `Integration Test OAuth App - ${Date.now()}`,
98+
redirectUris: [callbackUrl],
99+
scopes: 'profile email',
100+
});
101+
102+
return {
103+
oAuthApp,
104+
cleanup: async () => {
105+
await clerkClient.oauthApplications.delete(oAuthApp.id);
106+
},
107+
};
108+
}
109+
110+
export type ObtainOAuthAccessTokenParams = {
111+
page: Page;
112+
oAuthApp: OAuthApplication;
113+
redirectUri: string;
114+
fakeUser: { email?: string; password: string };
115+
signIn: {
116+
waitForMounted: (...args: any[]) => Promise<any>;
117+
signInWithEmailAndInstantPassword: (params: { email: string; password: string }) => Promise<any>;
118+
};
119+
};
120+
121+
/**
122+
* Runs the full OAuth 2.0 authorization code flow using Playwright:
123+
* 1. Navigates to the authorize URL
124+
* 2. Signs in with the provided user credentials
125+
* 3. Accepts the consent screen
126+
* 4. Extracts the authorization code from the callback
127+
* 5. Exchanges the code for an access token
128+
*
129+
* Returns the access token string.
130+
*/
131+
export async function obtainOAuthAccessToken({
132+
page,
133+
oAuthApp,
134+
redirectUri,
135+
fakeUser,
136+
signIn,
137+
}: ObtainOAuthAccessTokenParams): Promise<string> {
138+
const state = randomBytes(16).toString('hex');
139+
const authorizeUrl = new URL(oAuthApp.authorizeUrl);
140+
authorizeUrl.searchParams.set('client_id', oAuthApp.clientId);
141+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
142+
authorizeUrl.searchParams.set('response_type', 'code');
143+
authorizeUrl.searchParams.set('scope', 'profile email');
144+
authorizeUrl.searchParams.set('state', state);
145+
146+
await page.goto(authorizeUrl.toString());
147+
148+
// Sign in on Account Portal
149+
await signIn.waitForMounted();
150+
await signIn.signInWithEmailAndInstantPassword({
151+
email: fakeUser.email,
152+
password: fakeUser.password,
153+
});
154+
155+
// Accept consent screen
156+
const consentButton = page.getByRole('button', { name: 'Allow' });
157+
await consentButton.waitFor({ timeout: 10000 });
158+
await consentButton.click();
159+
160+
// Wait for redirect and extract authorization code
161+
await page.waitForURL(/oauth\/callback/, { timeout: 10000 });
162+
const callbackUrl = new URL(page.url());
163+
const authCode = callbackUrl.searchParams.get('code');
164+
expect(authCode).toBeTruthy();
165+
166+
// Exchange code for access token
167+
expect(oAuthApp.clientSecret).toBeTruthy();
168+
const tokenResponse = await page.request.post(oAuthApp.tokenFetchUrl, {
169+
data: new URLSearchParams({
170+
grant_type: 'authorization_code',
171+
code: authCode,
172+
redirect_uri: redirectUri,
173+
client_id: oAuthApp.clientId,
174+
client_secret: oAuthApp.clientSecret,
175+
}).toString(),
176+
headers: {
177+
'Content-Type': 'application/x-www-form-urlencoded',
178+
},
179+
});
180+
181+
expect(tokenResponse.status()).toBe(200);
182+
const tokenData = (await tokenResponse.json()) as { access_token?: string };
183+
expect(tokenData.access_token).toBeTruthy();
184+
185+
return tokenData.access_token;
186+
}

integration/tests/machine-auth/component.test.ts renamed to integration/tests/api-keys-component.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Page } from '@playwright/test';
22
import { expect, test } from '@playwright/test';
33

4-
import type { Application } from '../../models/application';
5-
import { appConfigs } from '../../presets';
6-
import type { FakeOrganization, FakeUser } from '../../testUtils';
7-
import { createTestUtils } from '../../testUtils';
4+
import type { Application } from '../models/application';
5+
import { appConfigs } from '../presets';
6+
import type { FakeOrganization, FakeUser } from '../testUtils';
7+
import { createTestUtils } from '../testUtils';
88

99
const mockAPIKeysEnvironmentSettings = async (
1010
page: Page,

0 commit comments

Comments
 (0)