From c38dd7d41bf5de08b5806d51314593d404153c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Gr=C3=A9lard?= Date: Tue, 13 Feb 2024 17:48:25 +0000 Subject: [PATCH] Support Cloudflare workers/Edge runtime environments (#962) ## Description This PR aims to make the node SDK (will need to be renamed to JS SDK maybe?) compatible with Cloudflare workers / Edge runtime environments. This is achieved by: - Removing `axios` and using native `fetch` instead - Removing usage of Node's `crypto` module in favour for native web `crypto` Here are the important changes: - Swapping out axios's client for [our own custom fetch-based client](https://github.com/workos/workos-node/pull/962/files#diff-32c7ee5e94c4dc53afedbcdb133765b9ebfeee5922e4b6a1fefd0987ee0d86d8) in [the main `WorkOS` class](https://github.com/workos/workos-node/pull/962/files#diff-43fae42284da1898f98b43b56a7bc6f5fb979e271bca59f4670751ba2c0020ef) - Rewrite the webhooks APIs using web `crypto` in [the `Webhooks` class](https://github.com/workos/workos-node/pull/962/files#diff-8b772e2b2baa9169ed0a1e9039df9b4b4b353690593830c843dff21f3dc525f7) Most of the rest of the changes are rewriting the tests to ensure they all pass because of the changes. ## Documentation Does this require changes to the WorkOS Docs? E.g. the [API Reference](https://workos.com/docs/reference) or code snippets need updates. ``` [x] Yes ``` If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required. > [!WARNING] > TODO: Add Docs PR --- jest.config.js | 3 +- package.json | 6 +- setup-jest.ts | 6 + src/audit-logs/audit-logs.spec.ts | 32 ++- .../interfaces/workos-options.interface.ts | 4 +- src/common/utils/fetch-client.ts | 107 ++++++++ src/common/utils/fetch-error.ts | 17 ++ src/common/utils/test-utils.ts | 28 ++ src/directory-sync/directory-sync.spec.ts | 89 +++---- src/events/events.spec.ts | 12 +- src/mfa/mfa.spec.ts | 129 +++++----- .../organization-domains.spec.ts | 38 +-- src/organizations/organizations.spec.ts | 134 +++++----- src/passwordless/passwordless.spec.ts | 28 +- src/passwordless/passwordless.ts | 1 - src/portal/portal.spec.ts | 90 +++---- src/sso/__snapshots__/sso.spec.ts.snap | 4 +- src/sso/sso.spec.ts | 195 ++++++-------- src/user-management/user-management.spec.ts | 242 +++++++----------- src/user-management/user-management.ts | 11 - src/webhooks/webhooks.spec.ts | 67 ++--- src/webhooks/webhooks.ts | 88 ++++--- src/workos.spec.ts | 58 ++--- src/workos.ts | 67 +++-- tsconfig.json | 3 +- yarn.lock | 146 ++++++++--- 26 files changed, 839 insertions(+), 766 deletions(-) create mode 100644 setup-jest.ts create mode 100644 src/common/utils/fetch-client.ts create mode 100644 src/common/utils/fetch-error.ts create mode 100644 src/common/utils/test-utils.ts diff --git a/jest.config.js b/jest.config.js index 2f60edacb..3b426b531 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,9 +3,10 @@ module.exports = { resetMocks: true, restoreMocks: true, verbose: true, - testEnvironment: "node", + testEnvironment: 'node', testPathIgnorePatterns: ['/node_modules/', '/dist/'], roots: ['/src'], + setupFiles: ['./setup-jest.ts'], transform: { '^.+\\.ts?$': 'ts-jest', }, diff --git a/package.json b/package.json index 009944566..3ea7bad02 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "5.2.0", + "version": "6.0.0", "name": "@workos-inc/node", "author": "WorkOS", "description": "A Node wrapper for the WorkOS API", @@ -34,15 +34,15 @@ "prepublishOnly": "yarn run build" }, "dependencies": { - "axios": "~1.6.5", "pluralize": "8.0.0" }, "devDependencies": { + "@peculiar/webcrypto": "^1.4.5", "@types/jest": "29.5.3", "@types/node": "14.18.54", "@types/pluralize": "0.0.30", - "axios-mock-adapter": "1.21.5", "jest": "29.6.2", + "jest-fetch-mock": "^3.0.3", "prettier": "2.8.8", "supertest": "6.3.3", "ts-jest": "29.1.1", diff --git a/setup-jest.ts b/setup-jest.ts new file mode 100644 index 000000000..8010b5081 --- /dev/null +++ b/setup-jest.ts @@ -0,0 +1,6 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; +import { Crypto } from '@peculiar/webcrypto'; + +enableFetchMocks(); + +global.crypto = new Crypto(); diff --git a/src/audit-logs/audit-logs.spec.ts b/src/audit-logs/audit-logs.spec.ts index 4277c3c97..5983f8d41 100644 --- a/src/audit-logs/audit-logs.spec.ts +++ b/src/audit-logs/audit-logs.spec.ts @@ -1,4 +1,4 @@ -import { AxiosError } from 'axios'; +import fetch from 'jest-fetch-mock'; import { UnauthorizedException } from '../common/exceptions'; import { BadRequestException } from '../common/exceptions/bad-request.exception'; import { mockWorkOsResponse } from '../common/utils/workos-mock-response'; @@ -13,6 +13,7 @@ import { serializeAuditLogExportOptions, serializeCreateAuditLogEventOptions, } from './serializers'; +import { FetchError } from '../common/utils/fetch-error'; const event: CreateAuditLogEventOptions = { action: 'document.updated', @@ -38,6 +39,8 @@ const event: CreateAuditLogEventOptions = { }; describe('AuditLogs', () => { + beforeEach(() => fetch.resetMocks()); + describe('createEvent', () => { describe('with an idempotency key', () => { it('includes an idempotency key with request', async () => { @@ -96,10 +99,11 @@ describe('AuditLogs', () => { const workosSpy = jest.spyOn(WorkOS.prototype, 'post'); workosSpy.mockImplementationOnce(() => { - throw new AxiosError( - 'Could not authorize the request. Maybe your API key is invalid?', - '401', - ); + throw new FetchError({ + message: + 'Could not authorize the request. Maybe your API key is invalid?', + response: { status: 401, headers: new Headers(), data: {} }, + }); }); const workos = new WorkOS('invalid apikey'); @@ -247,10 +251,11 @@ describe('AuditLogs', () => { }; workosSpy.mockImplementationOnce(() => { - throw new AxiosError( - 'Could not authorize the request. Maybe your API key is invalid?', - '401', - ); + throw new FetchError({ + message: + 'Could not authorize the request. Maybe your API key is invalid?', + response: { status: 401, headers: new Headers(), data: {} }, + }); }); const workos = new WorkOS('invalid apikey'); @@ -306,10 +311,11 @@ describe('AuditLogs', () => { const workosSpy = jest.spyOn(WorkOS.prototype, 'get'); workosSpy.mockImplementationOnce(() => { - throw new AxiosError( - 'Could not authorize the request. Maybe your API key is invalid?', - '401', - ); + throw new FetchError({ + message: + 'Could not authorize the request. Maybe your API key is invalid?', + response: { status: 401, headers: new Headers(), data: {} }, + }); }); const workos = new WorkOS('invalid apikey'); diff --git a/src/common/interfaces/workos-options.interface.ts b/src/common/interfaces/workos-options.interface.ts index 7ada5392e..2d506833f 100644 --- a/src/common/interfaces/workos-options.interface.ts +++ b/src/common/interfaces/workos-options.interface.ts @@ -1,8 +1,6 @@ -import { AxiosRequestConfig } from 'axios'; - export interface WorkOSOptions { apiHostname?: string; https?: boolean; port?: number; - axios?: Omit; + config?: RequestInit; } diff --git a/src/common/utils/fetch-client.ts b/src/common/utils/fetch-client.ts new file mode 100644 index 000000000..a96d602db --- /dev/null +++ b/src/common/utils/fetch-client.ts @@ -0,0 +1,107 @@ +import { FetchError } from './fetch-error'; + +export class FetchClient { + constructor(readonly baseURL: string, readonly options?: RequestInit) {} + + async get( + path: string, + options: { params?: Record; headers?: HeadersInit }, + ) { + const resourceURL = this.getResourceURL(path, options.params); + const response = await this.fetch(resourceURL, { + headers: options.headers, + }); + return { data: await response.json() }; + } + + async post( + path: string, + entity: Entity, + options: { params?: Record; headers?: HeadersInit }, + ) { + const resourceURL = this.getResourceURL(path, options.params); + const bodyIsSearchParams = entity instanceof URLSearchParams; + const contentTypeHeader = bodyIsSearchParams + ? { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' } + : undefined; + const body = bodyIsSearchParams ? entity : JSON.stringify(entity); + const response = await this.fetch(resourceURL, { + method: 'POST', + headers: { ...contentTypeHeader, ...options.headers }, + body, + }); + return { data: await response.json() }; + } + + async put( + path: string, + entity: Entity, + options: { params?: Record; headers?: HeadersInit }, + ) { + const resourceURL = this.getResourceURL(path, options.params); + const response = await this.fetch(resourceURL, { + method: 'PUT', + headers: options.headers, + body: JSON.stringify(entity), + }); + return { data: await response.json() }; + } + + async delete( + path: string, + options: { params?: Record; headers?: HeadersInit }, + ) { + const resourceURL = this.getResourceURL(path, options.params); + await this.fetch(resourceURL, { + method: 'DELETE', + headers: options.headers, + }); + } + + private getResourceURL(path: string, params?: Record) { + const queryString = getQueryString(params); + const url = new URL( + [path, queryString].filter(Boolean).join('?'), + this.baseURL, + ); + return url.toString(); + } + + private async fetch(url: string, options?: RequestInit) { + const response = await fetch(url, { + ...this.options, + ...options, + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + ...this.options?.headers, + ...options?.headers, + }, + }); + + if (!response.ok) { + throw new FetchError({ + message: response.statusText, + response: { + status: response.status, + headers: response.headers, + data: await response.json(), + }, + }); + } + + return response; + } +} + +function getQueryString(queryObj?: Record) { + if (!queryObj) return undefined; + + const sanitizedQueryObj: Record = {}; + + Object.entries(queryObj).forEach(([param, value]) => { + if (value !== '' && value !== undefined) sanitizedQueryObj[param] = value; + }); + + return new URLSearchParams(sanitizedQueryObj).toString(); +} diff --git a/src/common/utils/fetch-error.ts b/src/common/utils/fetch-error.ts new file mode 100644 index 000000000..a79f4c2d2 --- /dev/null +++ b/src/common/utils/fetch-error.ts @@ -0,0 +1,17 @@ +export class FetchError extends Error { + readonly name: string = 'FetchError'; + readonly message: string = 'The request could not be completed.'; + readonly response: { status: number; headers: Headers; data: T }; + + constructor({ + message, + response, + }: { + message: string; + readonly response: FetchError['response']; + }) { + super(message); + this.message = message; + this.response = response; + } +} diff --git a/src/common/utils/test-utils.ts b/src/common/utils/test-utils.ts new file mode 100644 index 000000000..8a09628c3 --- /dev/null +++ b/src/common/utils/test-utils.ts @@ -0,0 +1,28 @@ +import fetch, { MockParams } from 'jest-fetch-mock'; + +export function fetchOnce( + response: any = {}, + { status = 200, ...rest }: MockParams = {}, +) { + return fetch.once(JSON.stringify(response), { status, ...rest }); +} + +export function fetchURL() { + return fetch.mock.calls[0][0]; +} + +export function fetchSearchParams() { + return Object.fromEntries(new URL(String(fetchURL())).searchParams); +} + +export function fetchHeaders() { + return fetch.mock.calls[0][1]?.headers; +} + +export function fetchBody() { + const body = fetch.mock.calls[0][1]?.body; + if (body instanceof URLSearchParams) { + return body.toString(); + } + return JSON.parse(String(body)); +} diff --git a/src/directory-sync/directory-sync.spec.ts b/src/directory-sync/directory-sync.spec.ts index 89c24332f..ffee28a4b 100644 --- a/src/directory-sync/directory-sync.spec.ts +++ b/src/directory-sync/directory-sync.spec.ts @@ -1,6 +1,9 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; - +import fetch from 'jest-fetch-mock'; +import { + fetchOnce, + fetchURL, + fetchSearchParams, +} from '../common/utils/test-utils'; import { ListResponse } from '../common/interfaces/list.interface'; import { WorkOS } from '../workos'; import { @@ -12,10 +15,8 @@ import { DirectoryUserWithGroupsResponse, } from './interfaces'; -const mock = new MockAdapter(axios); - describe('DirectorySync', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -134,18 +135,17 @@ describe('DirectorySync', () => { list_metadata: {}, }; - mock - .onGet('/directories', { - domain: 'google.com', - organization_id: 'org_1234', - }) - .replyOnce(200, directoryListResponse); + fetchOnce(directoryListResponse); const subject = await workos.directorySync.listDirectories({ domain: 'google.com', organizationId: 'org_1234', }); + expect(fetchSearchParams()).toMatchObject({ + domain: 'google.com', + organization_id: 'org_1234', + }); expect(subject).toMatchObject({ object: 'list', list: { @@ -164,9 +164,7 @@ describe('DirectorySync', () => { describe('getDirectory', () => { it(`requests a Directory`, async () => { - mock - .onGet('/directories/directory_123') - .replyOnce(200, directoryResponse); + fetchOnce(directoryResponse); const subject = await workos.directorySync.getDirectory('directory_123'); @@ -176,17 +174,17 @@ describe('DirectorySync', () => { describe('deleteDirectory', () => { it('sends a request to delete the directory', async () => { - mock.onDelete('/directories/directory_123').replyOnce(202, {}); + fetchOnce({}, { status: 202 }); await workos.directorySync.deleteDirectory('directory_123'); - expect(mock.history.delete[0].url).toEqual('/directories/directory_123'); + expect(fetchURL()).toContain('/directories/directory_123'); }); }); describe('getGroup', () => { it(`requests a Directory Group`, async () => { - mock.onGet('/directory_groups/dir_grp_123').replyOnce(200, groupResponse); + fetchOnce(groupResponse); const subject = await workos.directorySync.getGroup('dir_grp_123'); @@ -203,16 +201,15 @@ describe('DirectorySync', () => { describe('with a Directory', () => { it(`requests a Directory's Groups`, async () => { - mock - .onGet('/directory_groups', { - directory: 'directory_123', - }) - .replyOnce(200, groupListResponse); + fetchOnce(groupListResponse); const subject = await workos.directorySync.listGroups({ directory: 'directory_123', }); + expect(fetchSearchParams()).toMatchObject({ + directory: 'directory_123', + }); expect(subject).toMatchObject({ object: 'list', list: { @@ -230,16 +227,15 @@ describe('DirectorySync', () => { describe('with a User', () => { it(`requests a Directory's Groups`, async () => { - mock - .onGet('/directory_groups', { - user: 'directory_usr_123', - }) - .replyOnce(200, groupListResponse); + fetchOnce(groupListResponse); const subject = await workos.directorySync.listGroups({ user: 'directory_usr_123', }); + expect(fetchSearchParams()).toMatchObject({ + user: 'directory_usr_123', + }); expect(subject).toEqual({ object: 'list', list: { @@ -266,16 +262,15 @@ describe('DirectorySync', () => { describe('with a Directory', () => { it(`requests a Directory's Users`, async () => { - mock - .onGet('/directory_users', { - directory: 'directory_123', - }) - .replyOnce(200, userWithGroupListResponse); + fetchOnce(userWithGroupListResponse); const subject = await workos.directorySync.listUsers({ directory: 'directory_123', }); + expect(fetchSearchParams()).toMatchObject({ + directory: 'directory_123', + }); expect(subject).toMatchObject({ object: 'list', list: { @@ -296,11 +291,8 @@ describe('DirectorySync', () => { managerId: string; } - mock - .onGet('/directory_users', { - directory: 'directory_123', - }) - .replyOnce(200, { + fetchOnce( + { data: [ { object: 'directory_user', @@ -357,13 +349,19 @@ describe('DirectorySync', () => { ], }, ], - }); + }, + { status: 200 }, + ); const users = await workos.directorySync.listUsers({ directory: 'directory_123', }); + expect(fetchSearchParams()).toMatchObject({ + directory: 'directory_123', + }); + const managerIds = users.data.map( (user) => user.customAttributes.managerId, ); @@ -378,16 +376,15 @@ describe('DirectorySync', () => { describe('with a Group', () => { it(`requests a Directory's Users`, async () => { - mock - .onGet('/directory_users', { - group: 'directory_grp_123', - }) - .replyOnce(200, userWithGroupListResponse); + fetchOnce(userWithGroupListResponse); const subject = await workos.directorySync.listUsers({ group: 'directory_grp_123', }); + expect(fetchSearchParams()).toMatchObject({ + group: 'directory_grp_123', + }); expect(subject).toMatchObject({ object: 'list', list: { @@ -406,9 +403,7 @@ describe('DirectorySync', () => { describe('getUser', () => { it(`requests a Directory User`, async () => { - mock - .onGet('/directory_users/dir_usr_123') - .replyOnce(200, userWithGroupResponse); + fetchOnce(userWithGroupResponse); const subject = await workos.directorySync.getUser('dir_usr_123'); diff --git a/src/events/events.spec.ts b/src/events/events.spec.ts index 397e35d1f..097bf4078 100644 --- a/src/events/events.spec.ts +++ b/src/events/events.spec.ts @@ -1,13 +1,11 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchOnce } from '../common/utils/test-utils'; import { Event, EventResponse, ListResponse } from '../common/interfaces'; import { WorkOS } from '../workos'; import { ConnectionType } from '../sso/interfaces'; -const mock = new MockAdapter(axios); - describe('Event', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -54,7 +52,7 @@ describe('Event', () => { }; it(`requests Events`, async () => { - mock.onGet('/events', {}).replyOnce(200, eventsListResponse); + fetchOnce(eventsListResponse); const subject = await workos.events.listEvents({}); @@ -66,7 +64,7 @@ describe('Event', () => { }); it(`requests Events with a valid event name`, async () => { - mock.onGet('/events').replyOnce(200, eventsListResponse); + fetchOnce(eventsListResponse); const list = await workos.events.listEvents({ events: ['connection.activated'], diff --git a/src/mfa/mfa.spec.ts b/src/mfa/mfa.spec.ts index 252e1a3a6..7d8cf56f3 100644 --- a/src/mfa/mfa.spec.ts +++ b/src/mfa/mfa.spec.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; import { UnprocessableEntityException } from '../common/exceptions'; import { WorkOS } from '../workos'; @@ -14,9 +14,9 @@ import { VerifyResponseResponse, } from './interfaces'; -const mock = new MockAdapter(axios); - describe('MFA', () => { + beforeEach(() => fetch.resetMocks()); + describe('getFactor', () => { it('returns the requested factor', async () => { const factor: Factor = { @@ -43,7 +43,7 @@ describe('MFA', () => { }, }; - mock.onGet().reply(200, factorResponse); + fetchOnce(factorResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const subject = await workos.mfa.getFactor('test_123'); @@ -54,12 +54,12 @@ describe('MFA', () => { describe('deleteFactor', () => { it('sends request to delete a Factor', async () => { - mock.onDelete().reply(200, {}); + fetchOnce(); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); await workos.mfa.deleteFactor('conn_123'); - expect(mock.history.delete[0].url).toEqual('/auth/factors/conn_123'); + expect(fetchURL()).toContain('/auth/factors/conn_123'); }); }); @@ -82,7 +82,7 @@ describe('MFA', () => { type: 'generic_otp', }; - mock.onPost('/auth/factors/enroll').reply(200, factorResponse); + fetchOnce(factorResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -128,7 +128,7 @@ describe('MFA', () => { }, }; - mock.onPost('/auth/factors/enroll').reply(200, factorResponse); + fetchOnce(factorResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', }); @@ -167,7 +167,7 @@ describe('MFA', () => { }, }; - mock.onPost('/auth/factors/enroll').reply(200, factorResponse); + fetchOnce(factorResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -183,14 +183,14 @@ describe('MFA', () => { describe('when phone number is invalid', () => { it('throws an exception', async () => { - mock.onPost('/auth/factors/enroll').reply( - 422, + fetchOnce( { message: `Phone number is invalid: 'foo'`, code: 'invalid_phone_number', }, { - 'X-Request-ID': 'req_123', + status: 422, + headers: { 'X-Request-ID': 'req_123' }, }, ); @@ -232,9 +232,7 @@ describe('MFA', () => { authentication_factor_id: 'auth_factor_1234', }; - mock - .onPost('/auth/factors/auth_factor_1234/challenge') - .reply(200, challengeResponse); + fetchOnce(challengeResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -270,11 +268,7 @@ describe('MFA', () => { authentication_factor_id: 'auth_factor_1234', }; - mock - .onPost('/auth/factors/auth_factor_1234/challenge', { - sms_template: 'This is your code: 12345', - }) - .reply(200, challengeResponse); + fetchOnce(challengeResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -285,6 +279,9 @@ describe('MFA', () => { smsTemplate: 'This is your code: 12345', }); + expect(fetchBody()).toEqual({ + sms_template: 'This is your code: 12345', + }); expect(subject).toEqual(challenge); }); }); @@ -319,11 +316,7 @@ describe('MFA', () => { valid: true, }; - mock - .onPost('/auth/challenges/auth_challenge_1234/verify', { - code: '12345', - }) - .reply(200, verifyResponseResponse); + fetchOnce(verifyResponseResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -334,26 +327,25 @@ describe('MFA', () => { code: '12345', }); + expect(fetchBody()).toEqual({ + code: '12345', + }); expect(subject).toEqual(verifyResponse); }); }); describe('when the challenge has been previously verified', () => { it('throws an exception', async () => { - mock - .onPost('/auth/challenges/auth_challenge_1234/verify', { - code: '12345', - }) - .reply( - 422, - { - message: `The authentication challenge '12345' has already been verified.`, - code: 'authentication_challenge_previously_verified', - }, - { - 'X-Request-ID': 'req_123', - }, - ); + fetchOnce( + { + message: `The authentication challenge '12345' has already been verified.`, + code: 'authentication_challenge_previously_verified', + }, + { + status: 422, + headers: { 'X-Request-ID': 'req_123' }, + }, + ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -365,25 +357,24 @@ describe('MFA', () => { code: '12345', }), ).rejects.toThrow(UnprocessableEntityException); + expect(fetchBody()).toEqual({ + code: '12345', + }); }); }); describe('when the challenge has expired', () => { it('throws an exception', async () => { - mock - .onPost('/auth/challenges/auth_challenge_1234/verify', { - code: '12345', - }) - .reply( - 422, - { - message: `The authentication challenge '12345' has expired.`, - code: 'authentication_challenge_expired', - }, - { - 'X-Request-ID': 'req_123', - }, - ); + fetchOnce( + { + message: `The authentication challenge '12345' has expired.`, + code: 'authentication_challenge_expired', + }, + { + status: 422, + headers: { 'X-Request-ID': 'req_123' }, + }, + ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -395,23 +386,22 @@ describe('MFA', () => { code: '12345', }), ).rejects.toThrow(UnprocessableEntityException); + expect(fetchBody()).toEqual({ + code: '12345', + }); }); it('exception has code', async () => { - mock - .onPost('/auth/challenges/auth_challenge_1234/verify', { - code: '12345', - }) - .reply( - 422, - { - message: `The authentication challenge '12345' has expired.`, - code: 'authentication_challenge_expired', - }, - { - 'X-Request-ID': 'req_123', - }, - ); + fetchOnce( + { + message: `The authentication challenge '12345' has expired.`, + code: 'authentication_challenge_expired', + }, + { + status: 422, + headers: { 'X-Request-ID': 'req_123' }, + }, + ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { apiHostname: 'api.workos.dev', @@ -427,6 +417,9 @@ describe('MFA', () => { code: 'authentication_challenge_expired', }); } + expect(fetchBody()).toEqual({ + code: '12345', + }); }); }); }); diff --git a/src/organization-domains/organization-domains.spec.ts b/src/organization-domains/organization-domains.spec.ts index 9fa8a6eed..8d14baccd 100644 --- a/src/organization-domains/organization-domains.spec.ts +++ b/src/organization-domains/organization-domains.spec.ts @@ -1,27 +1,24 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import getOrganizationDomainPending from './fixtures/get-organization-domain-pending.json'; import getOrganizationDomainVerified from './fixtures/get-organization-domain-verified.json'; import { OrganizationDomainState } from './interfaces'; -const mock = new MockAdapter(axios); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); describe('OrganizationDomains', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); describe('get', () => { it('requests an Organization Domain', async () => { - mock - .onGet('/organization_domains/org_domain_01HCZRAP3TPQ0X0DKJHR32TATG') - .replyOnce(200, getOrganizationDomainVerified); + fetchOnce(getOrganizationDomainVerified); const subject = await workos.organizationDomains.get( 'org_domain_01HCZRAP3TPQ0X0DKJHR32TATG', ); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( '/organization_domains/org_domain_01HCZRAP3TPQ0X0DKJHR32TATG', ); expect(subject.id).toEqual('org_domain_01HCZRAP3TPQ0X0DKJHR32TATG'); @@ -32,15 +29,13 @@ describe('OrganizationDomains', () => { }); it('requests an Organization Domain', async () => { - mock - .onGet('/organization_domains/org_domain_01HD50K7EPWCMNPGMKXKKE14XT') - .replyOnce(200, getOrganizationDomainPending); + fetchOnce(getOrganizationDomainPending); const subject = await workos.organizationDomains.get( 'org_domain_01HD50K7EPWCMNPGMKXKKE14XT', ); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( '/organization_domains/org_domain_01HD50K7EPWCMNPGMKXKKE14XT', ); expect(subject.id).toEqual('org_domain_01HD50K7EPWCMNPGMKXKKE14XT'); @@ -53,17 +48,13 @@ describe('OrganizationDomains', () => { describe('verify', () => { it('start Organization Domain verification flow', async () => { - mock - .onPost( - '/organization_domains/org_domain_01HD50K7EPWCMNPGMKXKKE14XT/verify', - ) - .replyOnce(200, getOrganizationDomainPending); + fetchOnce(getOrganizationDomainPending); const subject = await workos.organizationDomains.verify( 'org_domain_01HD50K7EPWCMNPGMKXKKE14XT', ); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( '/organization_domains/org_domain_01HD50K7EPWCMNPGMKXKKE14XT/verify', ); expect(subject.id).toEqual('org_domain_01HD50K7EPWCMNPGMKXKKE14XT'); @@ -76,20 +67,15 @@ describe('OrganizationDomains', () => { describe('create', () => { it('creates an Organization Domain', async () => { - mock - .onPost('/organization_domains', { - domain: 'workos.com', - organization_id: 'org_01EHT88Z8J8795GZNQ4ZP1J81T', - }) - .replyOnce(200, getOrganizationDomainPending); + fetchOnce(getOrganizationDomainPending); const subject = await workos.organizationDomains.create({ organizationId: 'org_01EHT88Z8J8795GZNQ4ZP1J81T', domain: 'workos.com', }); - expect(mock.history.post[0].url).toEqual('/organization_domains'); - expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + expect(fetchURL()).toContain('/organization_domains'); + expect(fetchBody()).toEqual({ domain: 'workos.com', organization_id: 'org_01EHT88Z8J8795GZNQ4ZP1J81T', }); diff --git a/src/organizations/organizations.spec.ts b/src/organizations/organizations.spec.ts index 14b83c7be..0ab77572e 100644 --- a/src/organizations/organizations.spec.ts +++ b/src/organizations/organizations.spec.ts @@ -1,5 +1,11 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { + fetchOnce, + fetchURL, + fetchSearchParams, + fetchHeaders, + fetchBody, +} from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import createOrganizationInvalid from './fixtures/create-organization-invalid.json'; import createOrganization from './fixtures/create-organization.json'; @@ -7,24 +13,23 @@ import getOrganization from './fixtures/get-organization.json'; import listOrganizationsFixture from './fixtures/list-organizations.json'; import updateOrganization from './fixtures/update-organization.json'; -const mock = new MockAdapter(axios); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); describe('Organizations', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); describe('listOrganizations', () => { describe('without any options', () => { it('returns organizations and metadata', async () => { - mock.onGet('/organizations').replyOnce(200, listOrganizationsFixture); + fetchOnce(listOrganizationsFixture); const { data, listMetadata } = await workos.organizations.listOrganizations(); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ order: 'desc', }); - expect(mock.history.get[0].url).toEqual('/organizations'); + expect(fetchURL()).toContain('/organizations'); expect(data).toHaveLength(7); @@ -37,22 +42,18 @@ describe('Organizations', () => { describe('with the domain option', () => { it('forms the proper request to the API', async () => { - mock - .onGet('/organizations', { - domains: ['example.com'], - }) - .replyOnce(200, listOrganizationsFixture); + fetchOnce(listOrganizationsFixture); const { data } = await workos.organizations.listOrganizations({ - domains: ['example.com'], + domains: ['example.com', 'example2.com'], }); - expect(mock.history.get[0].params).toEqual({ - domains: ['example.com'], + expect(fetchSearchParams()).toEqual({ + domains: 'example.com,example2.com', order: 'desc', }); - expect(mock.history.get[0].url).toEqual('/organizations'); + expect(fetchURL()).toContain('/organizations'); expect(data).toHaveLength(7); }); @@ -60,22 +61,18 @@ describe('Organizations', () => { describe('with the before option', () => { it('forms the proper request to the API', async () => { - mock - .onGet('/organizations', { - before: 'before-id', - }) - .replyOnce(200, listOrganizationsFixture); + fetchOnce(listOrganizationsFixture); const { data } = await workos.organizations.listOrganizations({ before: 'before-id', }); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ before: 'before-id', order: 'desc', }); - expect(mock.history.get[0].url).toEqual('/organizations'); + expect(fetchURL()).toContain('/organizations'); expect(data).toHaveLength(7); }); @@ -83,22 +80,18 @@ describe('Organizations', () => { describe('with the after option', () => { it('forms the proper request to the API', async () => { - mock - .onGet('/organizations', { - after: 'after-id', - }) - .replyOnce(200, listOrganizationsFixture); + fetchOnce(listOrganizationsFixture); const { data } = await workos.organizations.listOrganizations({ after: 'after-id', }); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ after: 'after-id', order: 'desc', }); - expect(mock.history.get[0].url).toEqual('/organizations'); + expect(fetchURL()).toContain('/organizations'); expect(data).toHaveLength(7); }); @@ -106,22 +99,18 @@ describe('Organizations', () => { describe('with the limit option', () => { it('forms the proper request to the API', async () => { - mock - .onGet('/organizations', { - limit: 10, - }) - .replyOnce(200, listOrganizationsFixture); + fetchOnce(listOrganizationsFixture); const { data } = await workos.organizations.listOrganizations({ limit: 10, }); - expect(mock.history.get[0].params).toEqual({ - limit: 10, + expect(fetchSearchParams()).toEqual({ + limit: '10', order: 'desc', }); - expect(mock.history.get[0].url).toEqual('/organizations'); + expect(fetchURL()).toContain('/organizations'); expect(data).toHaveLength(7); }); @@ -131,12 +120,7 @@ describe('Organizations', () => { describe('createOrganization', () => { describe('with an idempotency key', () => { it('includes an idempotency key with request', async () => { - mock - .onPost('/organizations', { - domains: ['example.com'], - name: 'Test Organization', - }) - .replyOnce(201, createOrganization); + fetchOnce(createOrganization, { status: 201 }); await workos.organizations.createOrganization( { @@ -148,26 +132,29 @@ describe('Organizations', () => { }, ); - expect(mock.history.post[0].headers?.['Idempotency-Key']).toEqual( - 'the-idempotency-key', - ); + expect(fetchHeaders()).toMatchObject({ + 'Idempotency-Key': 'the-idempotency-key', + }); + expect(fetchBody()).toEqual({ + domains: ['example.com'], + name: 'Test Organization', + }); }); }); describe('with a valid payload', () => { it('creates an organization', async () => { - mock - .onPost('/organizations', { - domains: ['example.com'], - name: 'Test Organization', - }) - .replyOnce(201, createOrganization); + fetchOnce(createOrganization, { status: 201 }); const subject = await workos.organizations.createOrganization({ domains: ['example.com'], name: 'Test Organization', }); + expect(fetchBody()).toEqual({ + domains: ['example.com'], + name: 'Test Organization', + }); expect(subject.id).toEqual('org_01EHT88Z8J8795GZNQ4ZP1J81T'); expect(subject.name).toEqual('Test Organization'); expect(subject.domains).toHaveLength(1); @@ -176,14 +163,10 @@ describe('Organizations', () => { describe('with an invalid payload', () => { it('returns an error', async () => { - mock - .onPost('/organizations', { - domains: ['example.com'], - name: 'Test Organization', - }) - .replyOnce(409, createOrganizationInvalid, { - 'X-Request-ID': 'a-request-id', - }); + fetchOnce(createOrganizationInvalid, { + status: 409, + headers: { 'X-Request-ID': 'a-request-id' }, + }); await expect( workos.organizations.createOrganization({ @@ -193,23 +176,24 @@ describe('Organizations', () => { ).rejects.toThrowError( 'An Organization with the domain example.com already exists.', ); + expect(fetchBody()).toEqual({ + domains: ['example.com'], + name: 'Test Organization', + }); }); }); }); describe('getOrganization', () => { it(`requests an Organization`, async () => { - const mock = new MockAdapter(axios); - mock - .onGet('/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T') - .replyOnce(200, getOrganization); + fetchOnce(getOrganization); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const subject = await workos.organizations.getOrganization( 'org_01EHT88Z8J8795GZNQ4ZP1J81T', ); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( '/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T', ); expect(subject.id).toEqual('org_01EHT88Z8J8795GZNQ4ZP1J81T'); @@ -221,17 +205,14 @@ describe('Organizations', () => { describe('deleteOrganization', () => { it('sends request to delete an Organization', async () => { - const mock = new MockAdapter(axios); - mock - .onDelete('/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T') - .replyOnce(200, {}); + fetchOnce(); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); await workos.organizations.deleteOrganization( 'org_01EHT88Z8J8795GZNQ4ZP1J81T', ); - expect(mock.history.delete[0].url).toEqual( + expect(fetchURL()).toContain( '/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T', ); }); @@ -240,12 +221,7 @@ describe('Organizations', () => { describe('updateOrganization', () => { describe('with a valid payload', () => { it('updates an organization', async () => { - mock - .onPut('/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T', { - domains: ['example.com'], - name: 'Test Organization 2', - }) - .replyOnce(201, updateOrganization); + fetchOnce(updateOrganization, { status: 201 }); const subject = await workos.organizations.updateOrganization({ organization: 'org_01EHT88Z8J8795GZNQ4ZP1J81T', @@ -253,6 +229,10 @@ describe('Organizations', () => { name: 'Test Organization 2', }); + expect(fetchBody()).toEqual({ + domains: ['example.com'], + name: 'Test Organization 2', + }); expect(subject.id).toEqual('org_01EHT88Z8J8795GZNQ4ZP1J81T'); expect(subject.name).toEqual('Test Organization 2'); expect(subject.domains).toHaveLength(1); diff --git a/src/passwordless/passwordless.spec.ts b/src/passwordless/passwordless.spec.ts index 8733a3e0b..0038b3b4f 100644 --- a/src/passwordless/passwordless.spec.ts +++ b/src/passwordless/passwordless.spec.ts @@ -1,13 +1,11 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; import createSession from './fixtures/create-session.json'; import { WorkOS } from '../workos'; -const mock = new MockAdapter(axios); - describe('Passwordless', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); describe('createSession', () => { describe('with valid options', () => { @@ -15,13 +13,7 @@ describe('Passwordless', () => { const email = 'passwordless-session-email@workos.com'; const redirectURI = 'https://example.com/passwordless/callback'; - mock - .onPost('/passwordless/sessions', { - type: 'MagicLink', - email, - redirect_uri: redirectURI, - }) - .replyOnce(201, createSession); + fetchOnce(createSession, { status: 201 }); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -34,11 +26,9 @@ describe('Passwordless', () => { expect(session.email).toEqual(email); expect(session.object).toEqual('passwordless_session'); - expect(JSON.parse(mock.history.post[0].data).email).toEqual(email); - expect(JSON.parse(mock.history.post[0].data).redirect_uri).toEqual( - redirectURI, - ); - expect(mock.history.post[0].url).toEqual('/passwordless/sessions'); + expect(fetchBody().email).toEqual(email); + expect(fetchBody().redirect_uri).toEqual(redirectURI); + expect(fetchURL()).toContain('/passwordless/sessions'); }); }); }); @@ -46,13 +36,13 @@ describe('Passwordless', () => { describe('sendEmail', () => { describe('with a valid session id', () => { it(`sends a request to send a magic link email`, async () => { - mock.onPost('/passwordless/sessions/session_123/send').replyOnce(200); + fetchOnce(); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const sessionId = 'session_123'; await workos.passwordless.sendSession(sessionId); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( `/passwordless/sessions/${sessionId}/send`, ); }); diff --git a/src/passwordless/passwordless.ts b/src/passwordless/passwordless.ts index 1bf2cc0f9..fcc4bd204 100644 --- a/src/passwordless/passwordless.ts +++ b/src/passwordless/passwordless.ts @@ -18,7 +18,6 @@ export class Passwordless { }: CreatePasswordlessSessionOptions): Promise { const { data } = await this.workos.post< PasswordlessSessionResponse, - any, SerializedCreatePasswordlessSessionOptions >('/passwordless/sessions', { ...options, diff --git a/src/portal/portal.spec.ts b/src/portal/portal.spec.ts index eb0eeee08..90dd7b267 100644 --- a/src/portal/portal.spec.ts +++ b/src/portal/portal.spec.ts @@ -1,27 +1,20 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchBody, fetchOnce } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import generateLinkInvalid from './fixtures/generate-link-invalid.json'; import generateLink from './fixtures/generate-link.json'; import { GeneratePortalLinkIntent } from './interfaces/generate-portal-link-intent.interface'; -const mock = new MockAdapter(axios); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); describe('Portal', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); describe('generateLink', () => { describe('with a valid organization', () => { describe('with the sso intent', () => { it('returns an Admin Portal link', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.SSO, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - return_url: 'https://www.example.com', - }) - .replyOnce(201, generateLink); + fetchOnce(generateLink, { status: 201 }); const { link } = await workos.portal.generateLink({ intent: GeneratePortalLinkIntent.SSO, @@ -29,6 +22,11 @@ describe('Portal', () => { returnUrl: 'https://www.example.com', }); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.SSO, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + return_url: 'https://www.example.com', + }); expect(link).toEqual( 'https://id.workos.com/portal/launch?secret=secret', ); @@ -37,13 +35,7 @@ describe('Portal', () => { describe('with the domain_verification intent', () => { it('returns an Admin Portal link', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.DomainVerification, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - return_url: 'https://www.example.com', - }) - .replyOnce(201, generateLink); + fetchOnce(generateLink, { status: 201 }); const { link } = await workos.portal.generateLink({ intent: GeneratePortalLinkIntent.DomainVerification, @@ -51,6 +43,11 @@ describe('Portal', () => { returnUrl: 'https://www.example.com', }); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.DomainVerification, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + return_url: 'https://www.example.com', + }); expect(link).toEqual( 'https://id.workos.com/portal/launch?secret=secret', ); @@ -59,13 +56,7 @@ describe('Portal', () => { describe('with the dsync intent', () => { it('returns an Admin Portal link', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.DSync, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - return_url: 'https://www.example.com', - }) - .reply(201, generateLink); + fetchOnce(generateLink, { status: 201 }); const { link } = await workos.portal.generateLink({ intent: GeneratePortalLinkIntent.DSync, @@ -73,6 +64,11 @@ describe('Portal', () => { returnUrl: 'https://www.example.com', }); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.DSync, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + return_url: 'https://www.example.com', + }); expect(link).toEqual( 'https://id.workos.com/portal/launch?secret=secret', ); @@ -81,13 +77,7 @@ describe('Portal', () => { describe('with the `audit_logs` intent', () => { it('returns an Admin Portal link', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.AuditLogs, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - return_url: 'https://www.example.com', - }) - .reply(201, generateLink); + fetchOnce(generateLink, { status: 201 }); const { link } = await workos.portal.generateLink({ intent: GeneratePortalLinkIntent.AuditLogs, @@ -95,6 +85,11 @@ describe('Portal', () => { returnUrl: 'https://www.example.com', }); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.AuditLogs, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + return_url: 'https://www.example.com', + }); expect(link).toEqual( 'https://id.workos.com/portal/launch?secret=secret', ); @@ -103,13 +98,7 @@ describe('Portal', () => { describe('with the `log_streams` intent', () => { it('returns an Admin Portal link', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.LogStreams, - organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', - return_url: 'https://www.example.com', - }) - .reply(201, generateLink); + fetchOnce(generateLink, { status: 201 }); const { link } = await workos.portal.generateLink({ intent: GeneratePortalLinkIntent.LogStreams, @@ -117,6 +106,11 @@ describe('Portal', () => { returnUrl: 'https://www.example.com', }); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.LogStreams, + organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3', + return_url: 'https://www.example.com', + }); expect(link).toEqual( 'https://id.workos.com/portal/launch?secret=secret', ); @@ -126,15 +120,10 @@ describe('Portal', () => { describe('with an invalid organization', () => { it('throws an error', async () => { - mock - .onPost('/portal/generate_link', { - intent: GeneratePortalLinkIntent.SSO, - organization: 'bogus-id', - return_url: 'https://www.example.com', - }) - .reply(400, generateLinkInvalid, { - 'X-Request-ID': 'a-request-id', - }); + fetchOnce(generateLinkInvalid, { + status: 400, + headers: { 'X-Request-ID': 'a-request-id' }, + }); await expect( workos.portal.generateLink({ @@ -145,6 +134,11 @@ describe('Portal', () => { ).rejects.toThrowError( 'Could not find an organization with the id, bogus-id.', ); + expect(fetchBody()).toEqual({ + intent: GeneratePortalLinkIntent.SSO, + organization: 'bogus-id', + return_url: 'https://www.example.com', + }); }); }); }); diff --git a/src/sso/__snapshots__/sso.spec.ts.snap b/src/sso/__snapshots__/sso.spec.ts.snap index 870233980..e388a531a 100644 --- a/src/sso/__snapshots__/sso.spec.ts.snap +++ b/src/sso/__snapshots__/sso.spec.ts.snap @@ -21,7 +21,7 @@ exports[`SSO SSO getProfileAndToken with all information provided sends a reques "Accept": "application/json, text/plain, */*", "Authorization": "Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU", "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", - "User-Agent": "workos-node/5.2.0", + "User-Agent": "workos-node/6.0.0", } `; @@ -58,7 +58,7 @@ exports[`SSO SSO getProfileAndToken without a groups attribute sends a request t "Accept": "application/json, text/plain, */*", "Authorization": "Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU", "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", - "User-Agent": "workos-node/5.2.0", + "User-Agent": "workos-node/6.0.0", } `; diff --git a/src/sso/sso.spec.ts b/src/sso/sso.spec.ts index a64dcfe05..bcd362d26 100644 --- a/src/sso/sso.spec.ts +++ b/src/sso/sso.spec.ts @@ -1,10 +1,17 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { + fetchOnce, + fetchURL, + fetchHeaders, + fetchBody, +} from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import { ConnectionResponse, ConnectionType } from './interfaces'; describe('SSO', () => { + beforeEach(() => fetch.resetMocks()); + const connectionResponse: ConnectionResponse = { object: 'connection', id: 'conn_123', @@ -166,47 +173,25 @@ describe('SSO', () => { describe('getProfileAndToken', () => { describe('with all information provided', () => { it('sends a request to the WorkOS api for a profile', async () => { - const mock = new MockAdapter(axios); - - const expectedBody = new URLSearchParams({ - client_id: 'proj_123', - client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', - code: 'authorization_code', - grant_type: 'authorization_code', - }); - expectedBody.sort(); - - mock.onPost('/sso/token').replyOnce((config) => { - const actualBody = new URLSearchParams(config.data); - actualBody.sort(); - - if (actualBody.toString() === expectedBody.toString()) { - return [ - 200, - { - access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', - profile: { - id: 'prof_123', - idp_id: '123', - organization_id: 'org_123', - connection_id: 'conn_123', - connection_type: 'OktaSAML', - email: 'foo@test.com', - first_name: 'foo', - last_name: 'bar', - groups: ['Admins', 'Developers'], - raw_attributes: { - email: 'foo@test.com', - first_name: 'foo', - last_name: 'bar', - groups: ['Admins', 'Developers'], - }, - }, - }, - ]; - } - - return [404]; + fetchOnce({ + access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', + profile: { + id: 'prof_123', + idp_id: '123', + organization_id: 'org_123', + connection_id: 'conn_123', + connection_type: 'OktaSAML', + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + groups: ['Admins', 'Developers'], + raw_attributes: { + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + groups: ['Admins', 'Developers'], + }, + }, }); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -215,11 +200,10 @@ describe('SSO', () => { clientId: 'proj_123', }); - expect(mock.history.post.length).toBe(1); - const { data, headers } = mock.history.post[0]; + expect(fetch.mock.calls.length).toEqual(1); - expect(data).toMatchSnapshot(); - expect(headers).toMatchSnapshot(); + expect(fetchBody()).toMatchSnapshot(); + expect(fetchHeaders()).toMatchSnapshot(); expect(accessToken).toBe('01DMEK0J53CVMC32CK5SE0KZ8Q'); expect(profile).toMatchSnapshot(); }); @@ -227,45 +211,23 @@ describe('SSO', () => { describe('without a groups attribute', () => { it('sends a request to the WorkOS api for a profile', async () => { - const mock = new MockAdapter(axios); - - const expectedBody = new URLSearchParams({ - client_id: 'proj_123', - client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', - code: 'authorization_code', - grant_type: 'authorization_code', - }); - expectedBody.sort(); - - mock.onPost('/sso/token').replyOnce((config) => { - const actualBody = new URLSearchParams(config.data); - actualBody.sort(); - - if (actualBody.toString() === expectedBody.toString()) { - return [ - 200, - { - access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', - profile: { - id: 'prof_123', - idp_id: '123', - organization_id: 'org_123', - connection_id: 'conn_123', - connection_type: 'OktaSAML', - email: 'foo@test.com', - first_name: 'foo', - last_name: 'bar', - raw_attributes: { - email: 'foo@test.com', - first_name: 'foo', - last_name: 'bar', - }, - }, - }, - ]; - } - - return [404]; + fetchOnce({ + access_token: '01DMEK0J53CVMC32CK5SE0KZ8Q', + profile: { + id: 'prof_123', + idp_id: '123', + organization_id: 'org_123', + connection_id: 'conn_123', + connection_type: 'OktaSAML', + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + raw_attributes: { + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + }, + }, }); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -274,11 +236,10 @@ describe('SSO', () => { clientId: 'proj_123', }); - expect(mock.history.post.length).toBe(1); - const { data, headers } = mock.history.post[0]; + expect(fetch.mock.calls.length).toEqual(1); - expect(data).toMatchSnapshot(); - expect(headers).toMatchSnapshot(); + expect(fetchBody()).toMatchSnapshot(); + expect(fetchHeaders()).toMatchSnapshot(); expect(accessToken).toBe('01DMEK0J53CVMC32CK5SE0KZ8Q'); expect(profile).toMatchSnapshot(); }); @@ -287,38 +248,33 @@ describe('SSO', () => { describe('getProfile', () => { it('calls the `/sso/profile` endpoint with the provided access token', async () => { - const mock = new MockAdapter(axios); - - mock - .onGet('/sso/profile', { - accessToken: 'access_token', - }) - .replyOnce(200, { - id: 'prof_123', - idp_id: '123', - organization_id: 'org_123', - connection_id: 'conn_123', - connection_type: 'OktaSAML', + fetchOnce({ + id: 'prof_123', + idp_id: '123', + organization_id: 'org_123', + connection_id: 'conn_123', + connection_type: 'OktaSAML', + email: 'foo@test.com', + first_name: 'foo', + last_name: 'bar', + groups: ['Admins', 'Developers'], + raw_attributes: { email: 'foo@test.com', first_name: 'foo', last_name: 'bar', groups: ['Admins', 'Developers'], - raw_attributes: { - email: 'foo@test.com', - first_name: 'foo', - last_name: 'bar', - groups: ['Admins', 'Developers'], - }, - }); + }, + }); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const profile = await workos.sso.getProfile({ accessToken: 'access_token', }); - expect(mock.history.get.length).toBe(1); - const { headers } = mock.history.get[0]; - expect(headers?.Authorization).toBe(`Bearer access_token`); + expect(fetch.mock.calls.length).toEqual(1); + expect(fetchHeaders()).toMatchObject({ + Authorization: 'Bearer access_token', + }); expect(profile.id).toBe('prof_123'); }); @@ -326,27 +282,25 @@ describe('SSO', () => { describe('deleteConnection', () => { it('sends request to delete a Connection', async () => { - const mock = new MockAdapter(axios); - mock.onDelete('/connections/conn_123').replyOnce(200, {}); + fetchOnce(); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); await workos.sso.deleteConnection('conn_123'); - expect(mock.history.delete[0].url).toEqual('/connections/conn_123'); + expect(fetchURL()).toContain('/connections/conn_123'); }); }); describe('getConnection', () => { it(`requests a Connection`, async () => { - const mock = new MockAdapter(axios); - mock.onGet('/connections/conn_123').replyOnce(200, connectionResponse); + fetchOnce(connectionResponse); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const subject = await workos.sso.getConnection('conn_123'); - expect(mock.history.get[0].url).toEqual('/connections/conn_123'); + expect(fetchURL()).toContain('/connections/conn_123'); expect(subject.connectionType).toEqual('OktaSAML'); }); @@ -354,8 +308,7 @@ describe('SSO', () => { describe('listConnections', () => { it(`requests a list of Connections`, async () => { - const mock = new MockAdapter(axios); - mock.onGet('/connections').replyOnce(200, { + fetchOnce({ data: [connectionResponse], list_metadata: {}, }); @@ -366,7 +319,7 @@ describe('SSO', () => { organizationId: 'org_1234', }); - expect(mock.history.get[0].url).toEqual('/connections'); + expect(fetchURL()).toContain('/connections'); expect(subject.data).toHaveLength(1); }); diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 364b743bc..5eda1f3f2 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -1,5 +1,10 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { + fetchOnce, + fetchURL, + fetchSearchParams, + fetchBody, +} from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import userFixture from './fixtures/user.json'; import listUsersFixture from './fixtures/list-users.json'; @@ -9,22 +14,19 @@ import listOrganizationMembershipsFixture from './fixtures/list-organization-mem import invitationFixture from './fixtures/invitation.json'; import listInvitationsFixture from './fixtures/list-invitations.json'; -const mock = new MockAdapter(axios); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const userId = 'user_01H5JQDV7R7ATEYZDEG0W5PRYS'; const organizationMembershipId = 'om_01H5JQDV7R7ATEYZDEG0W5PRYS'; const invitationId = 'invitation_01H5JQDV7R7ATEYZDEG0W5PRYS'; describe('UserManagement', () => { - afterEach(() => mock.resetHistory()); + beforeEach(() => fetch.resetMocks()); describe('getUser', () => { it('sends a Get User request', async () => { - mock.onGet(`/user_management/users/${userId}`).reply(200, userFixture); + fetchOnce(userFixture); const user = await workos.userManagement.getUser(userId); - expect(mock.history.get[0].url).toEqual( - `/user_management/users/${userId}`, - ); + expect(fetchURL()).toContain(`/user_management/users/${userId}`); expect(user).toMatchObject({ object: 'user', id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', @@ -39,9 +41,9 @@ describe('UserManagement', () => { describe('listUsers', () => { it('lists users', async () => { - mock.onGet('/user_management/users').reply(200, listUsersFixture); + fetchOnce(listUsersFixture); const userList = await workos.userManagement.listUsers(); - expect(mock.history.get[0].url).toEqual('/user_management/users'); + expect(fetchURL()).toContain('/user_management/users'); expect(userList).toMatchObject({ object: 'list', data: [ @@ -58,7 +60,7 @@ describe('UserManagement', () => { }); it('sends the correct params when filtering', async () => { - mock.onGet('/user_management/users').reply(200, listUsersFixture); + fetchOnce(listUsersFixture); await workos.userManagement.listUsers({ email: 'foo@example.com', organizationId: 'org_someorg', @@ -66,11 +68,11 @@ describe('UserManagement', () => { limit: 10, }); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ email: 'foo@example.com', organization_id: 'org_someorg', after: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', - limit: 10, + limit: '10', order: 'desc', }); }); @@ -78,7 +80,7 @@ describe('UserManagement', () => { describe('createUser', () => { it('sends a Create User request', async () => { - mock.onPost('/user_management/users').reply(200, userFixture); + fetchOnce(userFixture); const user = await workos.userManagement.createUser({ email: 'test01@example.com', password: 'extra-secure', @@ -87,7 +89,7 @@ describe('UserManagement', () => { emailVerified: true, }); - expect(mock.history.post[0].url).toEqual('/user_management/users'); + expect(fetchURL()).toContain('/user_management/users'); expect(user).toMatchObject({ object: 'user', email: 'test01@example.com', @@ -103,9 +105,7 @@ describe('UserManagement', () => { describe('authenticateUserWithMagicAuth', () => { it('sends a magic auth authentication request', async () => { - mock.onPost('/user_management/authenticate').reply(200, { - user: userFixture, - }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithMagicAuth({ clientId: 'proj_whatever', @@ -113,7 +113,7 @@ describe('UserManagement', () => { email: userFixture.email, }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); + expect(fetchURL()).toContain('/user_management/authenticate'); expect(resp).toMatchObject({ user: { email: 'test01@example.com', @@ -124,16 +124,14 @@ describe('UserManagement', () => { describe('authenticateUserWithPassword', () => { it('sends an password authentication request', async () => { - mock.onPost('/user_management/authenticate').reply(200, { - user: userFixture, - }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithPassword({ clientId: 'proj_whatever', email: 'test01@example.com', password: 'extra-secure', }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); + expect(fetchURL()).toContain('/user_management/authenticate'); expect(resp).toMatchObject({ user: { email: 'test01@example.com', @@ -144,17 +142,17 @@ describe('UserManagement', () => { describe('authenticateUserWithCode', () => { it('sends a token authentication request', async () => { - mock - .onPost('/user_management/authenticate') - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithCode({ clientId: 'proj_whatever', code: 'or this', }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); - expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ + client_id: 'proj_whatever', client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + code: 'or this', grant_type: 'authorization_code', }); @@ -168,9 +166,7 @@ describe('UserManagement', () => { describe('authenticateUserWithTotp', () => { it('sends a token authentication request', async () => { - mock - .onPost('/user_management/authenticate') - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithTotp({ clientId: 'proj_whatever', code: 'or this', @@ -178,8 +174,8 @@ describe('UserManagement', () => { pendingAuthenticationToken: 'cTDQJTTkTkkVYxQUlKBIxEsFs', }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); - expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ client_id: 'proj_whatever', code: 'or this', client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', @@ -199,9 +195,7 @@ describe('UserManagement', () => { describe('authenticateUserWithEmailVerification', () => { it('sends an email verification authentication request', async () => { - mock - .onPost('/user_management/authenticate') - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithEmailVerification({ clientId: 'proj_whatever', @@ -209,8 +203,8 @@ describe('UserManagement', () => { pendingAuthenticationToken: 'cTDQJTTkTkkVYxQUlKBIxEsFs', }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); - expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ client_id: 'proj_whatever', code: 'or this', client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', @@ -228,9 +222,7 @@ describe('UserManagement', () => { describe('authenticateWithOrganizationSelection', () => { it('sends an Organization Selection Authentication request', async () => { - mock - .onPost('/user_management/authenticate') - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.authenticateWithOrganizationSelection({ clientId: 'proj_whatever', @@ -238,8 +230,8 @@ describe('UserManagement', () => { organizationId: 'org_01H5JQDV7R7ATEYZDEG0W5PRYS', }); - expect(mock.history.post[0].url).toEqual('/user_management/authenticate'); - expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + expect(fetchURL()).toContain('/user_management/authenticate'); + expect(fetchBody()).toEqual({ client_id: 'proj_whatever', client_secret: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', grant_type: 'urn:workos:oauth:grant-type:organization-selection', @@ -257,15 +249,13 @@ describe('UserManagement', () => { describe('sendVerificationEmail', () => { it('sends a Create Email Verification Challenge request', async () => { - mock - .onPost(`/user_management/users/${userId}/email_verification/send`) - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.sendVerificationEmail({ userId, }); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/users/${userId}/email_verification/send`, ); @@ -285,16 +275,14 @@ describe('UserManagement', () => { describe('verifyEmail', () => { it('sends a Complete Email Verification request', async () => { - mock - .onPost(`/user_management/users/${userId}/email_verification/confirm`) - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.verifyEmail({ userId, code: '123456', }); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/users/${userId}/email_verification/confirm`, ); @@ -307,35 +295,29 @@ describe('UserManagement', () => { describe('sendMagicAuthCode', () => { it('sends a Send Magic Auth Code request', async () => { - mock - .onPost('/user_management/magic_auth/send', { - email: 'bob.loblaw@example.com', - }) - .reply(200); + fetchOnce(); const response = await workos.userManagement.sendMagicAuthCode({ email: 'bob.loblaw@example.com', }); - expect(mock.history.post[0].url).toEqual( - '/user_management/magic_auth/send', - ); - + expect(fetchURL()).toContain('/user_management/magic_auth/send'); + expect(fetchBody()).toEqual({ + email: 'bob.loblaw@example.com', + }); expect(response).toBeUndefined(); }); }); describe('sendPasswordResetEmail', () => { it('sends a Send Password Reset Email request', async () => { - mock.onPost(`/user_management/password_reset/send`).reply(200); + fetchOnce(); const resp = await workos.userManagement.sendPasswordResetEmail({ email: 'test01@example.com', passwordResetUrl: 'https://example.com/forgot-password', }); - expect(mock.history.post[0].url).toEqual( - `/user_management/password_reset/send`, - ); + expect(fetchURL()).toContain(`/user_management/password_reset/send`); expect(resp).toBeUndefined(); }); @@ -343,18 +325,14 @@ describe('UserManagement', () => { describe('resetPassword', () => { it('sends a Reset Password request', async () => { - mock - .onPost(`/user_management/password_reset/confirm`) - .reply(200, { user: userFixture }); + fetchOnce({ user: userFixture }); const resp = await workos.userManagement.resetPassword({ token: '', newPassword: 'correct horse battery staple', }); - expect(mock.history.post[0].url).toEqual( - `/user_management/password_reset/confirm`, - ); + expect(fetchURL()).toContain(`/user_management/password_reset/confirm`); expect(resp.user).toMatchObject({ email: 'test01@example.com', @@ -364,7 +342,7 @@ describe('UserManagement', () => { describe('updateUser', () => { it('sends a updateUser request', async () => { - mock.onPut(`/user_management/users/${userId}`).reply(200, userFixture); + fetchOnce(userFixture); const resp = await workos.userManagement.updateUser({ userId, firstName: 'Dane', @@ -372,10 +350,8 @@ describe('UserManagement', () => { emailVerified: true, }); - expect(mock.history.put[0].url).toEqual( - `/user_management/users/${userId}`, - ); - expect(JSON.parse(mock.history.put[0].data)).toEqual({ + expect(fetchURL()).toContain(`/user_management/users/${userId}`); + expect(fetchBody()).toEqual({ first_name: 'Dane', last_name: 'Williams', email_verified: true, @@ -388,16 +364,14 @@ describe('UserManagement', () => { describe('when only one property is provided', () => { it('sends a updateUser request', async () => { - mock.onPut(`/user_management/users/${userId}`).reply(200, userFixture); + fetchOnce(userFixture); const resp = await workos.userManagement.updateUser({ userId, firstName: 'Dane', }); - expect(mock.history.put[0].url).toEqual( - `/user_management/users/${userId}`, - ); - expect(JSON.parse(mock.history.put[0].data)).toEqual({ + expect(fetchURL()).toContain(`/user_management/users/${userId}`); + expect(fetchBody()).toEqual({ first_name: 'Dane', }); expect(resp).toMatchObject({ @@ -410,7 +384,7 @@ describe('UserManagement', () => { describe('enrollAuthFactor', () => { it('sends an enrollAuthFactor request', async () => { - mock.onPost(`/user_management/users/${userId}/auth_factors`).reply(200, { + fetchOnce({ authentication_factor: { object: 'authentication_factor', id: 'auth_factor_1234', @@ -443,7 +417,7 @@ describe('UserManagement', () => { totpUser: 'some_user', }); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/users/${userId}/auth_factors`, ); expect(resp).toMatchObject({ @@ -476,13 +450,11 @@ describe('UserManagement', () => { describe('listAuthFactors', () => { it('sends a listAuthFactors request', async () => { - mock - .onGet(`/user_management/users/${userId}/auth_factors`) - .reply(200, listFactorFixture); + fetchOnce(listFactorFixture); const resp = await workos.userManagement.listAuthFactors({ userId }); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/users/${userId}/auth_factors`, ); @@ -511,29 +483,25 @@ describe('UserManagement', () => { describe('deleteUser', () => { it('sends a deleteUser request', async () => { - mock.onDelete(`/user_management/users/${userId}`).reply(200); + fetchOnce(); const resp = await workos.userManagement.deleteUser(userId); - expect(mock.history.delete[0].url).toEqual( - `/user_management/users/${userId}`, - ); + expect(fetchURL()).toContain(`/user_management/users/${userId}`); expect(resp).toBeUndefined(); }); }); describe('getOrganizationMembership', () => { it('sends a Get OrganizationMembership request', async () => { - mock - .onGet( - `/user_management/organization_memberships/${organizationMembershipId}`, - ) - .reply(200, organizationMembershipFixture); + fetchOnce(organizationMembershipFixture, { + status: 200, + }); const organizationMembership = await workos.userManagement.getOrganizationMembership( organizationMembershipId, ); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/organization_memberships/${organizationMembershipId}`, ); expect(organizationMembership).toMatchObject({ @@ -547,17 +515,15 @@ describe('UserManagement', () => { describe('listOrganizationMemberships', () => { it('lists organization memberships', async () => { - mock - .onGet('/user_management/organization_memberships') - .reply(200, listOrganizationMembershipsFixture); + fetchOnce(listOrganizationMembershipsFixture, { + status: 200, + }); const organizationMembershipsList = await workos.userManagement.listOrganizationMemberships({ organizationId: 'organization_01H5JQDV7R7ATEYZDEG0W5PRYS', userId: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', }); - expect(mock.history.get[0].url).toEqual( - '/user_management/organization_memberships', - ); + expect(fetchURL()).toContain('/user_management/organization_memberships'); expect(organizationMembershipsList).toMatchObject({ object: 'list', data: [ @@ -575,9 +541,9 @@ describe('UserManagement', () => { }); it('sends the correct params when filtering', async () => { - mock - .onGet('/user_management/organization_memberships') - .reply(200, listOrganizationMembershipsFixture); + fetchOnce(listOrganizationMembershipsFixture, { + status: 200, + }); await workos.userManagement.listOrganizationMemberships({ userId: 'user_someuser', organizationId: 'org_someorg', @@ -585,12 +551,11 @@ describe('UserManagement', () => { limit: 10, }); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ user_id: 'user_someuser', organization_id: 'org_someorg', - before: undefined, after: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', - limit: 10, + limit: '10', order: 'desc', }); }); @@ -598,18 +563,16 @@ describe('UserManagement', () => { describe('createOrganizationMembership', () => { it('sends a create organization membership request', async () => { - mock - .onPost('/user_management/organization_memberships') - .reply(200, organizationMembershipFixture); + fetchOnce(organizationMembershipFixture, { + status: 200, + }); const organizationMembership = await workos.userManagement.createOrganizationMembership({ organizationId: 'org_01H5JQDV7R7ATEYZDEG0W5PRYS', userId: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', }); - expect(mock.history.post[0].url).toEqual( - '/user_management/organization_memberships', - ); + expect(fetchURL()).toContain('/user_management/organization_memberships'); expect(organizationMembership).toMatchObject({ object: 'organization_membership', organizationId: 'organization_01H5JQDV7R7ATEYZDEG0W5PRYS', @@ -620,17 +583,13 @@ describe('UserManagement', () => { describe('deleteOrganizationMembership', () => { it('sends a deleteOrganizationMembership request', async () => { - mock - .onDelete( - `/user_management/organization_memberships/${organizationMembershipId}`, - ) - .reply(200); + fetchOnce(); const resp = await workos.userManagement.deleteOrganizationMembership( organizationMembershipId, ); - expect(mock.history.delete[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/organization_memberships/${organizationMembershipId}`, ); expect(resp).toBeUndefined(); @@ -639,13 +598,11 @@ describe('UserManagement', () => { describe('getInvitation', () => { it('sends a Get Invitation request', async () => { - mock - .onGet(`/user_management/invitations/${invitationId}`) - .reply(200, invitationFixture); + fetchOnce(invitationFixture); const invitation = await workos.userManagement.getInvitation( invitationId, ); - expect(mock.history.get[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/invitations/${invitationId}`, ); expect(invitation).toMatchObject({}); @@ -654,15 +611,13 @@ describe('UserManagement', () => { describe('listInvitations', () => { it('lists invitations', async () => { - mock - .onGet('/user_management/invitations') - .reply(200, listInvitationsFixture); + fetchOnce(listInvitationsFixture); const invitationsList = await workos.userManagement.listInvitations({ organizationId: 'org_01H5JQDV7R7ATEYZDEG0W5PRYS', email: 'dane@workos.com', }); - expect(mock.history.get[0].url).toEqual('/user_management/invitations'); + expect(fetchURL()).toContain('/user_management/invitations'); expect(invitationsList).toMatchObject({ object: 'list', data: [ @@ -681,20 +636,17 @@ describe('UserManagement', () => { }); it('sends the correct params when filtering', async () => { - mock - .onGet('/user_management/invitations') - .reply(200, listInvitationsFixture); + fetchOnce(listInvitationsFixture); await workos.userManagement.listInvitations({ organizationId: 'org_someorg', after: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', limit: 10, }); - expect(mock.history.get[0].params).toEqual({ + expect(fetchSearchParams()).toEqual({ organization_id: 'org_someorg', - before: undefined, after: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', - limit: 10, + limit: '10', order: 'desc', }); }); @@ -702,18 +654,16 @@ describe('UserManagement', () => { describe('sendInvitation', () => { it('sends a Send Invitation request', async () => { - mock - .onPost('/user_management/invitations', { - email: 'dane@workos.com', - }) - .reply(200, invitationFixture); + fetchOnce(invitationFixture); const response = await workos.userManagement.sendInvitation({ email: 'dane@workos.com', }); - expect(mock.history.post[0].url).toEqual('/user_management/invitations'); - + expect(fetchURL()).toContain('/user_management/invitations'); + expect(fetchBody()).toEqual({ + email: 'dane@workos.com', + }); expect(response).toMatchObject({ object: 'invitation', email: 'dane@workos.com', @@ -721,7 +671,7 @@ describe('UserManagement', () => { }); it('sends the correct params when provided', async () => { - mock.onPost('/user_management/invitations').reply(200, invitationFixture); + fetchOnce(invitationFixture); await workos.userManagement.sendInvitation({ email: 'dane@workos.com', organizationId: 'org_someorg', @@ -729,7 +679,7 @@ describe('UserManagement', () => { inviterUserId: 'user_someuser', }); - expect(JSON.parse(mock.history.post[0].data)).toEqual({ + expect(fetchBody()).toEqual({ email: 'dane@workos.com', organization_id: 'org_someorg', expires_in_days: 4, @@ -741,15 +691,13 @@ describe('UserManagement', () => { describe('revokeInvitation', () => { it('send a Revoke Invitation request', async () => { const invitationId = 'invitation_01H5JQDV7R7ATEYZDEG0W5PRYS'; - mock - .onPost(`/user_management/invitations/${invitationId}/revoke`) - .reply(200, invitationFixture); + fetchOnce(invitationFixture); const response = await workos.userManagement.revokeInvitation( invitationId, ); - expect(mock.history.post[0].url).toEqual( + expect(fetchURL()).toContain( `/user_management/invitations/${invitationId}/revoke`, ); expect(response).toMatchObject({ diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index 45c97a3f2..c745671c6 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -139,7 +139,6 @@ export class UserManagement { async createUser(payload: CreateUserOptions): Promise { const { data } = await this.workos.post< UserResponse, - any, SerializedCreateUserOptions >('/user_management/users', serializeCreateUserOptions(payload)); @@ -151,7 +150,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithMagicAuthOptions >( '/user_management/authenticate', @@ -169,7 +167,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithPasswordOptions >( '/user_management/authenticate', @@ -187,7 +184,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithCodeOptions >( '/user_management/authenticate', @@ -205,7 +201,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithTotpOptions >( '/user_management/authenticate', @@ -223,7 +218,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithEmailVerificationOptions >( '/user_management/authenticate', @@ -241,7 +235,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< AuthenticationResponseResponse, - any, SerializedAuthenticateWithOrganizationSelectionOptions >( '/user_management/authenticate', @@ -278,7 +271,6 @@ export class UserManagement { }: VerifyEmailOptions): Promise<{ user: User }> { const { data } = await this.workos.post< { user: UserResponse }, - any, SerializedVerifyEmailOptions >(`/user_management/users/${userId}/email_verification/confirm`, { code, @@ -299,7 +291,6 @@ export class UserManagement { async resetPassword(payload: ResetPasswordOptions): Promise<{ user: User }> { const { data } = await this.workos.post< { user: UserResponse }, - any, SerializedResetPasswordOptions >( '/user_management/password_reset/confirm', @@ -411,7 +402,6 @@ export class UserManagement { ): Promise { const { data } = await this.workos.post< OrganizationMembershipResponse, - any, SerializedCreateOrganizationMembershipOptions >( '/user_management/organization_memberships', @@ -461,7 +451,6 @@ export class UserManagement { async sendInvitation(payload: SendInvitationOptions): Promise { const { data } = await this.workos.post< InvitationResponse, - any, SerializedSendInvitationOptions >( '/user_management/invitations', diff --git a/src/webhooks/webhooks.spec.ts b/src/webhooks/webhooks.spec.ts index 1c58ff755..b734854a4 100644 --- a/src/webhooks/webhooks.spec.ts +++ b/src/webhooks/webhooks.spec.ts @@ -11,6 +11,7 @@ describe('Webhooks', () => { let unhashedString: string; let signatureHash: string; let expectation: object; + beforeEach(() => { payload = mockWebhook; secret = 'secret'; @@ -87,10 +88,10 @@ describe('Webhooks', () => { describe('constructEvent', () => { describe('with the correct payload, sig_header, and secret', () => { - it('returns a webhook event', () => { + it('returns a webhook event', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; const options = { payload, sigHeader, secret }; - const webhook = workos.webhooks.constructEvent(options); + const webhook = await workos.webhooks.constructEvent(options); expect(webhook.data).toEqual(expectation); expect(webhook.event).toEqual('dsync.user.created'); @@ -99,10 +100,10 @@ describe('Webhooks', () => { }); describe('with the correct payload, sig_header, secret, and tolerance', () => { - it('returns a webhook event', () => { + it('returns a webhook event', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; const options = { payload, sigHeader, secret, tolerance: 200 }; - const webhook = workos.webhooks.constructEvent(options); + const webhook = await workos.webhooks.constructEvent(options); expect(webhook.data).toEqual(expectation); expect(webhook.event).toEqual('dsync.user.created'); @@ -111,80 +112,80 @@ describe('Webhooks', () => { }); describe('with an empty header', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = ''; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); describe('with an empty signature hash', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = `t=${timestamp}, v1=`; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); describe('with an incorrect signature hash', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = `t=${timestamp}, v1=99999`; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); describe('with an incorrect payload', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; payload = 'invalid'; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); describe('with an incorrect webhook secret', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; secret = 'invalid'; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); describe('with a timestamp outside tolerance', () => { - it('raises an error', () => { + it('raises an error', async () => { const sigHeader = `t=9999, v1=${signatureHash}`; const options = { payload, sigHeader, secret }; - expect(() => workos.webhooks.constructEvent(options)).toThrowError( - SignatureVerificationException, - ); + await expect( + workos.webhooks.constructEvent(options), + ).rejects.toThrowError(SignatureVerificationException); }); }); }); describe('verifyHeader', () => { - it('returns true when the signature is valid', () => { + it('returns true when the signature is valid', async () => { const sigHeader = `t=${timestamp}, v1=${signatureHash}`; const options = { payload, sigHeader, secret }; - - expect(() => workos.webhooks.verifyHeader(options)).toBeTruthy(); + const result = await workos.webhooks.verifyHeader(options); + expect(result).toBeTruthy(); }); }); @@ -202,8 +203,8 @@ describe('Webhooks', () => { }); describe('computeSignature', () => { - it('returns the computed signature', () => { - const signature = workos.webhooks.computeSignature( + it('returns the computed signature', async () => { + const signature = await workos.webhooks.computeSignature( timestamp, payload, secret, diff --git a/src/webhooks/webhooks.ts b/src/webhooks/webhooks.ts index e4b887b28..07340571f 100644 --- a/src/webhooks/webhooks.ts +++ b/src/webhooks/webhooks.ts @@ -1,10 +1,9 @@ -import crypto from 'crypto'; import { SignatureVerificationException } from '../common/exceptions'; import { deserializeEvent } from '../common/serializers'; import { Event, EventResponse } from '../common/interfaces'; export class Webhooks { - constructEvent({ + async constructEvent({ payload, sigHeader, secret, @@ -14,16 +13,16 @@ export class Webhooks { sigHeader: string; secret: string; tolerance?: number; - }): Event { + }): Promise { const options = { payload, sigHeader, secret, tolerance }; - this.verifyHeader(options); + await this.verifyHeader(options); const webhookPayload = payload as EventResponse; return deserializeEvent(webhookPayload); } - verifyHeader({ + async verifyHeader({ payload, sigHeader, secret, @@ -33,7 +32,7 @@ export class Webhooks { sigHeader: string; secret: string; tolerance?: number; - }): boolean { + }): Promise { const [timestamp, signatureHash] = this.getTimestampAndSignatureHash(sigHeader); @@ -49,8 +48,8 @@ export class Webhooks { ); } - const expectedSig = this.computeSignature(timestamp, payload, secret); - if (this.secureCompare(expectedSig, signatureHash) === false) { + const expectedSig = await this.computeSignature(timestamp, payload, secret); + if ((await this.secureCompare(expectedSig, signatureHash)) === false) { throw new SignatureVerificationException( 'Signature hash does not match the expected signature hash for payload', ); @@ -58,7 +57,7 @@ export class Webhooks { return true; } - getTimestampAndSignatureHash(sigHeader: string): string[] { + getTimestampAndSignatureHash(sigHeader: string): [string, string] { const signature = sigHeader; const [t, v1] = signature.split(','); if (typeof t === 'undefined' || typeof v1 === 'undefined') { @@ -72,37 +71,64 @@ export class Webhooks { return [timestamp, signatureHash]; } - computeSignature(timestamp: any, payload: any, secret: string): string { + async computeSignature( + timestamp: any, + payload: any, + secret: string, + ): Promise { payload = JSON.stringify(payload); const signedPayload = `${timestamp}.${payload}`; - const expectedSignature = crypto - .createHmac('sha256', secret) - .update(signedPayload) - .digest() - .toString('hex'); - return expectedSignature; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + + const signatureBuffer = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(signedPayload), + ); + + // crypto.subtle returns the signature in base64 format. This must be + // encoded in hex to match the CryptoProvider contract. We map each byte in + // the buffer to its corresponding hex octet and then combine into a string. + const signatureBytes = new Uint8Array(signatureBuffer); + const signatureHexCodes = new Array(signatureBytes.length); + + for (let i = 0; i < signatureBytes.length; i++) { + signatureHexCodes[i] = byteHexMapping[signatureBytes[i]]; + } + + return signatureHexCodes.join(''); } - secureCompare(stringA: string, stringB: string): boolean { - const strA = Buffer.from(stringA); - const strB = Buffer.from(stringB); + async secureCompare(stringA: string, stringB: string): Promise { + const bufferA = Buffer.from(stringA); + const bufferB = Buffer.from(stringB); - if (strA.length !== strB.length) { + if (bufferA.length !== bufferB.length) { return false; } - if (crypto.timingSafeEqual) { - return crypto.timingSafeEqual(strA, strB); - } - - const len = strA.length; - let result = 0; + const algorithm = { name: 'HMAC', hash: 'SHA-256' }; + const key = (await crypto.subtle.generateKey(algorithm, false, [ + 'sign', + 'verify', + ])) as CryptoKey; + const hmac = await crypto.subtle.sign(algorithm, key, bufferA); + const equal = await crypto.subtle.verify(algorithm, key, hmac, bufferB); - for (let i = 0; i < len; ++i) { - // tslint:disable-next-line:no-bitwise - result |= strA[i] ^ strB[i]; - } - return result === 0; + return equal; } } + +// Cached mapping of byte to hex representation. We do this once to avoid re- +// computing every time we need to convert the result of a signature to hex. +const byteHexMapping = new Array(256); +for (let i = 0; i < byteHexMapping.length; i++) { + byteHexMapping[i] = i.toString(16).padStart(2, '0'); +} diff --git a/src/workos.spec.ts b/src/workos.spec.ts index e1fedf1bf..ca3853ec7 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchHeaders } from './common/utils/test-utils'; import fs from 'fs/promises'; import { GenericServerException, @@ -9,9 +9,9 @@ import { } from './common/exceptions'; import { WorkOS } from './workos'; -const mock = new MockAdapter(axios); - describe('WorkOS', () => { + beforeEach(() => fetch.resetMocks()); + describe('constructor', () => { const OLD_ENV = process.env; @@ -70,12 +70,12 @@ describe('WorkOS', () => { }); }); - describe('when the `axios` option is provided', () => { - it('applies the configuration to the Axios client', async () => { - mock.onPost().reply(200, 'OK', { 'X-Request-ID': 'a-request-id' }); + describe('when the `config` option is provided', () => { + it('applies the configuration to the fetch client', async () => { + fetchOnce('{}', { headers: { 'X-Request-ID': 'a-request-id' } }); const workos = new WorkOS('sk_test', { - axios: { + config: { headers: { 'X-My-Custom-Header': 'Hey there!', }, @@ -84,7 +84,7 @@ describe('WorkOS', () => { await workos.post('/somewhere', {}); - expect(mock.history.post[0].headers).toMatchObject({ + expect(fetchHeaders()).toMatchObject({ 'X-My-Custom-Header': 'Hey there!', }); }); @@ -107,12 +107,9 @@ describe('WorkOS', () => { describe('when the api responds with a 404', () => { it('throws a NotFoundException', async () => { const message = 'Not Found'; - mock.onPost().reply( - 404, - { - message, - }, - { 'X-Request-ID': 'a-request-id' }, + fetchOnce( + { message }, + { status: 404, headers: { 'X-Request-ID': 'a-request-id' } }, ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -129,13 +126,9 @@ describe('WorkOS', () => { it('preserves the error code, status, and message from the underlying response', async () => { const message = 'The thing you are looking for is not here.'; const code = 'thing-not-found'; - mock.onPost().reply( - 404, - { - code, - message, - }, - { 'X-Request-ID': 'a-request-id' }, + fetchOnce( + { code, message }, + { status: 404, headers: { 'X-Request-ID': 'a-request-id' } }, ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -150,12 +143,9 @@ describe('WorkOS', () => { it('includes the path in the message if there is no message in the response', async () => { const code = 'thing-not-found'; const path = '/path/to/thing/that-aint-there'; - mock.onPost().reply( - 404, - { - code, - }, - { 'X-Request-ID': 'a-request-id' }, + fetchOnce( + { code }, + { status: 404, headers: { 'X-Request-ID': 'a-request-id' } }, ); const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -170,11 +160,11 @@ describe('WorkOS', () => { describe('when the api responds with a 500 and no error/error_description', () => { it('throws an GenericServerException', async () => { - mock.onPost().reply( - 500, + fetchOnce( {}, { - 'X-Request-ID': 'a-request-id', + status: 500, + headers: { 'X-Request-ID': 'a-request-id' }, }, ); @@ -187,11 +177,11 @@ describe('WorkOS', () => { }); describe('when the api responds with a 400 and an error/error_description', () => { it('throws an OauthException', async () => { - mock.onPost().reply( - 400, + fetchOnce( { error: 'error', error_description: 'error description' }, { - 'X-Request-ID': 'a-request-id', + status: 400, + headers: { 'X-Request-ID': 'a-request-id' }, }, ); diff --git a/src/workos.ts b/src/workos.ts index 68124d05e..546df95aa 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -1,4 +1,3 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { GenericServerException, NoApiKeyProvidedException, @@ -26,14 +25,16 @@ import { Mfa } from './mfa/mfa'; import { AuditLogs } from './audit-logs/audit-logs'; import { UserManagement } from './user-management/user-management'; import { BadRequestException } from './common/exceptions/bad-request.exception'; +import { FetchClient } from './common/utils/fetch-client'; +import { FetchError } from './common/utils/fetch-error'; -const VERSION = '5.2.0'; +const VERSION = '6.0.0'; const DEFAULT_HOSTNAME = 'api.workos.com'; export class WorkOS { readonly baseURL: string; - private readonly client: AxiosInstance; + private readonly client: FetchClient; readonly auditLogs = new AuditLogs(this); readonly directorySync = new DirectorySync(this); @@ -49,7 +50,8 @@ export class WorkOS { constructor(readonly key?: string, readonly options: WorkOSOptions = {}) { if (!key) { - this.key = process.env.WORKOS_API_KEY; + // process might be undefined in some environments + this.key = process?.env.WORKOS_API_KEY; if (!this.key) { throw new NoApiKeyProvidedException(); @@ -69,11 +71,10 @@ export class WorkOS { this.baseURL = this.baseURL + `:${port}`; } - this.client = axios.create({ - ...options.axios, - baseURL: this.baseURL, + this.client = new FetchClient(this.baseURL, { + ...options.config, headers: { - ...options.axios?.headers, + ...options.config?.headers, Authorization: `Bearer ${this.key}`, 'User-Agent': `workos-node/${VERSION}`, }, @@ -84,11 +85,11 @@ export class WorkOS { return VERSION; } - async post( + async post( path: string, - entity: P, + entity: Entity, options: PostOptions = {}, - ): Promise> { + ): Promise<{ data: Result }> { const requestHeaders: any = {}; if (options.idempotencyKey) { @@ -96,43 +97,41 @@ export class WorkOS { } try { - return await this.client.post, P>(path, entity, { + return await this.client.post(path, entity, { params: options.query, headers: requestHeaders, }); } catch (error) { - this.handleAxiosError({ path, error }); + this.handleFetchError({ path, error }); throw error; } } - async get( + async get( path: string, options: GetOptions = {}, - ): Promise> { + ): Promise<{ data: Result }> { try { const { accessToken } = options; return await this.client.get(path, { params: options.query, headers: accessToken - ? { - Authorization: `Bearer ${accessToken}`, - } + ? { Authorization: `Bearer ${accessToken}` } : undefined, }); } catch (error) { - this.handleAxiosError({ path, error }); + this.handleFetchError({ path, error }); throw error; } } - async put( + async put( path: string, - entity: any, + entity: Entity, options: PutOptions = {}, - ): Promise> { + ): Promise<{ data: Result }> { const requestHeaders: any = {}; if (options.idempotencyKey) { @@ -140,34 +139,32 @@ export class WorkOS { } try { - return await this.client.put(path, entity, { + return await this.client.put(path, entity, { params: options.query, headers: requestHeaders, }); } catch (error) { - this.handleAxiosError({ path, error }); + this.handleFetchError({ path, error }); throw error; } } - async delete( - path: string, - query?: any, - ): Promise> { + async delete(path: string, query?: any): Promise { try { - return await this.client.delete(path, { + await this.client.delete(path, { params: query, }); } catch (error) { - this.handleAxiosError({ path, error }); + this.handleFetchError({ path, error }); throw error; } } emitWarning(warning: string) { - if (typeof process.emitWarning !== 'function') { + // process might be undefined in some environments + if (typeof process?.emitWarning !== 'function') { // tslint:disable:no-console return console.warn(`WorkOS: ${warning}`); } @@ -175,12 +172,12 @@ export class WorkOS { return process.emitWarning(warning, 'WorkOS'); } - private handleAxiosError({ path, error }: { path: string; error: unknown }) { - const { response } = error as AxiosError; + private handleFetchError({ path, error }: { path: string; error: unknown }) { + const { response } = error as FetchError; if (response) { const { status, data, headers } = response; - const requestID = headers['X-Request-ID']; + const requestID = headers.get('X-Request-ID') ?? ''; const { code, error_description: errorDescription, @@ -194,8 +191,6 @@ export class WorkOS { throw new UnauthorizedException(requestID); } case 422: { - const { errors } = data; - throw new UnprocessableEntityException({ code, errors, diff --git a/tsconfig.json b/tsconfig.json index e7f808853..e4e06f6a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "module": "commonjs", "declaration": true, "outDir": "lib", - "strict": true + "strict": true, + "lib": ["dom", "es2019"] }, "include": ["src"] } diff --git a/yarn.lock b/yarn.lock index 96475944c..a1a5df64e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -581,6 +581,33 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@peculiar/asn1-schema@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz#04b38832a814e25731232dd5be883460a156da3b" + integrity sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA== + dependencies: + asn1js "^3.0.5" + pvtsutils "^1.3.5" + tslib "^2.6.2" + +"@peculiar/json-schema@^1.1.12": + version "1.1.12" + resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" + integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== + dependencies: + tslib "^2.0.0" + +"@peculiar/webcrypto@^1.4.5": + version "1.4.5" + resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.4.5.tgz#424bed6b0d133b772f5cbffd143d0468a90f40a0" + integrity sha512-oDk93QCDGdxFRM8382Zdminzs44dg3M2+E5Np+JWkpqLDyJC9DviMh8F8mEJkYuUcUOGA5jHO5AJJ10MFWdbZw== + dependencies: + "@peculiar/asn1-schema" "^2.3.8" + "@peculiar/json-schema" "^1.1.12" + pvtsutils "^1.3.5" + tslib "^2.6.2" + webcrypto-core "^1.7.8" + "@sinclair/typebox@^0.25.16": version "0.25.24" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz" @@ -755,28 +782,20 @@ asap@^2.0.0: resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1js@^3.0.1, asn1js@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== + dependencies: + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios-mock-adapter@1.21.5: - version "1.21.5" - resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.21.5.tgz#dd85081717a759f88509c20515082dc09c1cedd7" - integrity sha512-5NI1V/VK+8+JeTF8niqOowuysA4b8mGzdlMN/QnTnoXbYh4HZSNiopsDclN2g/m85+G++IrEtUdZaQ3GnaMsSA== - dependencies: - fast-deep-equal "^3.1.3" - is-buffer "^2.0.5" - -axios@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" - integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - babel-jest@^29.6.2: version "29.6.2" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.2.tgz#cada0a59e07f5acaeb11cbae7e3ba92aec9c1126" @@ -1031,6 +1050,13 @@ cookiejar@^2.1.4: resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== +cross-fetch@^3.0.4: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -1170,11 +1196,6 @@ expect@^29.6.2: jest-message-util "^29.6.2" jest-util "^29.6.2" -fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" @@ -1207,11 +1228,6 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -follow-redirects@^1.15.0: - version "1.15.2" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" - integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== - form-data@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz" @@ -1365,11 +1381,6 @@ is-arrayish@^0.2.1: resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-buffer@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - is-core-module@^2.8.1: version "2.9.0" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz" @@ -1574,6 +1585,14 @@ jest-environment-node@^29.6.2: jest-mock "^29.6.2" jest-util "^29.6.2" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^29.4.3: version "29.4.3" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz" @@ -2006,6 +2025,13 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -2153,6 +2179,11 @@ pretty-format@^29.6.2: ansi-styles "^5.0.0" react-is "^18.0.0" +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + prompts@^2.0.1: version "2.4.2" resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz" @@ -2161,16 +2192,23 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - pure-rand@^6.0.0: version "6.0.1" resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.1.tgz" integrity sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg== +pvtsutils@^1.3.2, pvtsutils@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.5.tgz#b8705b437b7b134cd7fd858f025a23456f1ce910" + integrity sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA== + dependencies: + tslib "^2.6.1" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + qs@^6.11.0: version "6.11.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.1.tgz#6c29dff97f0c0060765911ba65cbc9764186109f" @@ -2414,6 +2452,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + ts-jest@29.1.1: version "29.1.1" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b" @@ -2433,6 +2476,11 @@ tslib@^1.13.0, tslib@^1.8.1: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0, tslib@^2.4.0, tslib@^2.6.1, tslib@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslint@6.1.3: version "6.1.3" resolved "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz" @@ -2498,6 +2546,30 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +webcrypto-core@^1.7.8: + version "1.7.8" + resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.7.8.tgz#056918036e846c72cfebbb04052e283f57f1114a" + integrity sha512-eBR98r9nQXTqXt/yDRtInszPMjTaSAMJAFDg2AHsgrnczawT1asx9YNBX6k5p+MekbPF4+s/UJJrr88zsTqkSg== + dependencies: + "@peculiar/asn1-schema" "^2.3.8" + "@peculiar/json-schema" "^1.1.12" + asn1js "^3.0.1" + pvtsutils "^1.3.5" + tslib "^2.6.2" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"