Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/backend-enterprise-connection-test-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@clerk/backend': minor
---

Add `createEnterpriseConnectionTestRun()` and `getEnterpriseConnectionTestRunList()` to `clerkClient.enterpriseConnections` for testing a SAML or OIDC enterprise connection against its Identity Provider (IdP). `createEnterpriseConnectionTestRun()` returns a short-lived `url` that starts a test sign-in. `getEnterpriseConnectionTestRunList()` returns the connection's test runs as `EnterpriseConnectionTestRun` objects, with each run's status, the user information parsed from the IdP and its log entries, and accepts a `status` filter plus `limit` and `offset`. `EnterpriseConnectionTestRun` and its related types are exported from `@clerk/backend`.

```ts
const { url } = await clerkClient.enterpriseConnections.createEnterpriseConnectionTestRun('entconn_123');

const { data } = await clerkClient.enterpriseConnections.getEnterpriseConnectionTestRunList('entconn_123', {
status: ['failed'],
});
```
14 changes: 14 additions & 0 deletions .typedoc/custom-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ const LINK_REPLACEMENTS = [
'enterprise-connection-saml-connection-login-hint',
'/docs/reference/backend/types/backend-enterprise-connection-saml-connection-login-hint',
],
['enterprise-connection-test-run', '/docs/reference/backend/types/backend-enterprise-connection-test-run'],
['enterprise-connection-test-run-log', '/docs/reference/backend/types/backend-enterprise-connection-test-run-log'],
[
'enterprise-connection-test-run-oauth-payload',
'/docs/reference/backend/types/backend-enterprise-connection-test-run-oauth-payload',
],
[
'enterprise-connection-test-run-parsed-user-info',
'/docs/reference/backend/types/backend-enterprise-connection-test-run-parsed-user-info',
],
[
'enterprise-connection-test-run-saml-payload',
'/docs/reference/backend/types/backend-enterprise-connection-test-run-saml-payload',
],
['saml-connection-idp-certificate', '/docs/reference/backend/types/backend-saml-connection-idp-certificate'],
['external-account', '/docs/reference/backend/types/backend-external-account'],
['phone-number', '/docs/reference/backend/types/backend-phone-number'],
Expand Down
137 changes: 137 additions & 0 deletions packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, expectTypeOf, it } from 'vitest';

import type { EnterpriseConnectionTestRun as PublicEnterpriseConnectionTestRun } from '../../index';
import { server, validateHeaders } from '../../mock-server';
import type {
CreateEnterpriseConnectionParams,
EnterpriseConnectionSamlLoginHintParams,
} from '../endpoints/EnterpriseConnectionApi';
import { createBackendApiClient } from '../factory';
import { EnterpriseConnectionTestRun } from '../resources';

describe('EnterpriseConnectionAPI', () => {
const apiClient = createBackendApiClient({
Expand Down Expand Up @@ -410,4 +412,139 @@ describe('EnterpriseConnectionAPI', () => {
expect(response.name).toBe('Clerk');
});
});

describe('createEnterpriseConnectionTestRun', () => {
it('starts a test run and returns its URL', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/enterprise_connections/entconn_123/test_runs',
validateHeaders(() => HttpResponse.json({ url: 'https://clerk.example.com/v1/test_runs/start?token=abc' })),
),
);

const response = await apiClient.enterpriseConnections.createEnterpriseConnectionTestRun('entconn_123');

expect(response).toEqual({ url: 'https://clerk.example.com/v1/test_runs/start?token=abc' });
});

it('requires an enterprise connection ID', async () => {
await expect(apiClient.enterpriseConnections.createEnterpriseConnectionTestRun('')).rejects.toThrow(
'A valid resource ID is required.',
);
});
});

describe('getEnterpriseConnectionTestRunList', () => {
const mockTestRunListResponse = {
data: [
{
id: 'ectr_1',
status: 'success',
connection_type: 'saml',
parsed_user_info: {
email_address: 'jane@clerk.dev',
first_name: 'Jane',
last_name: 'Doe',
user_id: 'jane',
},
logs: [
{ level: 'info', code: 'response_received', short_message: 'Received', message: 'SAML response received' },
],
saml: {
saml_request: 'PHNhbWxwOkF1dGhuUmVxdWVzdA==',
saml_response: 'PHNhbWxwOlJlc3BvbnNl',
relay_state: 'rs',
},
oauth: null,
created_at: 1672531200000,
},
{
id: 'ectr_2',
status: 'failed',
connection_type: 'oauth',
parsed_user_info: null,
oauth: { id_token: 'id', access_token: 'access', user_info: '{}' },
created_at: 1672531100000,
},
],
total_count: 2,
};

it('fetches test runs as resources, with status filters and pagination in the query', async () => {
let capturedRequestUrl: string | null = null;
server.use(
http.get(
'https://api.clerk.test/v1/enterprise_connections/entconn_123/test_runs',
validateHeaders(({ request }) => {
capturedRequestUrl = request.url;
return HttpResponse.json(mockTestRunListResponse);
}),
),
);

const response = await apiClient.enterpriseConnections.getEnterpriseConnectionTestRunList('entconn_123', {
status: ['success', 'failed'],
limit: 10,
offset: 5,
});

expect(capturedRequestUrl).toBeTruthy();
const url = new URL(capturedRequestUrl!);
expect(url.searchParams.getAll('status')).toEqual(['success', 'failed']);
expect(url.searchParams.get('limit')).toBe('10');
expect(url.searchParams.get('offset')).toBe('5');

expect(response.totalCount).toBe(2);
expect(response.data).toHaveLength(2);
expectTypeOf(response.data).toEqualTypeOf<PublicEnterpriseConnectionTestRun[]>();

const [saml, oauth] = response.data;
expect(saml).toBeInstanceOf(EnterpriseConnectionTestRun);
expect(saml.status).toBe('success');
expect(saml.connectionType).toBe('saml');
expect(saml.createdAt).toBe(1672531200000);
expect(saml.parsedUserInfo).toEqual({
emailAddress: 'jane@clerk.dev',
firstName: 'Jane',
lastName: 'Doe',
userId: 'jane',
});
expect(saml.logs).toEqual([
{ level: 'info', code: 'response_received', shortMessage: 'Received', message: 'SAML response received' },
]);
expect(saml.saml?.samlResponse).toBe('PHNhbWxwOlJlc3BvbnNl');
expect(saml.oauth).toBeNull();

expect(oauth.status).toBe('failed');
expect(oauth.parsedUserInfo).toBeNull();
expect(oauth.logs).toEqual([]);
expect(oauth.saml).toBeNull();
expect(oauth.oauth?.idToken).toBe('id');
});

it('sends no query params when called without filters', async () => {
let capturedRequestUrl: string | null = null;
server.use(
http.get(
'https://api.clerk.test/v1/enterprise_connections/entconn_123/test_runs',
validateHeaders(({ request }) => {
capturedRequestUrl = request.url;
return HttpResponse.json({ data: [], total_count: 0 });
}),
),
);

const response = await apiClient.enterpriseConnections.getEnterpriseConnectionTestRunList('entconn_123');

expect(capturedRequestUrl).toBeTruthy();
expect(new URL(capturedRequestUrl!).search).toBe('');
expect(response).toEqual({ data: [], totalCount: 0 });
});

it('requires an enterprise connection ID', async () => {
await expect(apiClient.enterpriseConnections.getEnterpriseConnectionTestRunList('')).rejects.toThrow(
'A valid resource ID is required.',
);
});
});
});
68 changes: 67 additions & 1 deletion packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { ClerkPaginationRequest, OrganizationEnterpriseConnectionProvider } from '@clerk/shared/types';
import type {
ClerkPaginationRequest,
EnterpriseConnectionTestRunStatus,
OrganizationEnterpriseConnectionProvider,
} from '@clerk/shared/types';

import { joinPaths } from '../../util/path';
import type { EnterpriseConnection } from '../resources';
import { EnterpriseConnectionTestRun } from '../resources';
import type { PaginatedResourceResponse } from '../resources/Deserializer';
import type { EnterpriseConnectionTestRunJSON } from '../resources/JSON';
import { AbstractAPI } from './AbstractApi';

const basePath = '/enterprise_connections';
Expand All @@ -19,6 +25,14 @@ export type EnterpriseConnectionListParams = ClerkPaginationRequest<{
active?: boolean;
}>;

/** @generateWithEmptyComment */
export type EnterpriseConnectionTestRunListParams = ClerkPaginationRequest<{
/**
* Filters test runs by status. If omitted, test runs of every status are returned.
*/
status?: EnterpriseConnectionTestRunStatus[];
}>;

/** @inline */
export interface EnterpriseConnectionOidcParams {
/** The OAuth (OIDC) authorization URL. */
Expand Down Expand Up @@ -243,4 +257,56 @@ export class EnterpriseConnectionAPI extends AbstractAPI {
path: joinPaths(basePath, enterpriseConnectionId),
});
}

/**
* Starts a test run for the given enterprise connection: a test sign-in against its Identity Provider (IdP).
* @param enterpriseConnectionId - The ID of the enterprise connection to test.
* @returns An object with a `url` property: a short-lived URL that starts the test sign-in when visited.
*
* @example
* ### Start a test run
*
* Send the person testing the connection to the returned `url` to sign in with the IdP.
*
* ```ts
* const { url } = await clerkClient.enterpriseConnections.createEnterpriseConnectionTestRun('entconn_123');
* ```
*/
public async createEnterpriseConnectionTestRun(enterpriseConnectionId: string): Promise<{ url: string }> {
this.requireId(enterpriseConnectionId);
return this.request<{ url: string }>({
method: 'POST',
path: joinPaths(basePath, enterpriseConnectionId, 'test_runs'),
});
}

/**
* Gets the list of test runs for the given enterprise connection.
* @param enterpriseConnectionId - The ID of the enterprise connection to get the test runs of.
* @param params - Filters and pagination for the list.
* @returns A [`PaginatedResourceResponse`](https://clerk.com/docs/reference/backend/types/paginated-resource-response) object with a `data` property containing an array of [`EnterpriseConnectionTestRun`](https://clerk.com/docs/reference/backend/types/backend-enterprise-connection-test-run) objects and a `totalCount` property containing the total number of test runs for the connection.
*
* @example
* ### Get the failed test runs
*
* Filter by `status` to see why test sign-ins failed. Each test run's `logs` describe what happened.
*
* ```ts
* const { data } = await clerkClient.enterpriseConnections.getEnterpriseConnectionTestRunList('entconn_123', {
* status: ['failed'],
* });
* ```
*/
public async getEnterpriseConnectionTestRunList(
enterpriseConnectionId: string,
params: EnterpriseConnectionTestRunListParams = {},
): Promise<PaginatedResourceResponse<EnterpriseConnectionTestRun[]>> {
this.requireId(enterpriseConnectionId);
const { data, totalCount } = await this.request<PaginatedResourceResponse<EnterpriseConnectionTestRunJSON[]>>({
method: 'GET',
path: joinPaths(basePath, enterpriseConnectionId, 'test_runs'),
queryParams: params,
});
return { data: data.map(testRun => EnterpriseConnectionTestRun.fromJSON(testRun)), totalCount };
}
}
Loading
Loading