diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4e3f42..47ff1577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to the ordercloud-javascript-sdk will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# [Unreleased] +- Breaking: `customRoles` is a property of the auth options object on `Auth.Login`, `Auth.ElevatedLogin`, `Auth.ClientCredentials`, and `Auth.Anonymous`, instead of its own argument. Custom roles with no standard roles are sent as the scope string. That case previously sent the literal text `undefined`. + # [13.0.0] - 2026-09-25 - Bring SDK up to date with API v1.0.470. Breaking: `ApiClient.ClientSecret` and `SecurityProfile.IsExternalRoleGroup` are removed. Buyer product `PriceSchedule` is now `BuyerPriceSchedule`. - Update dev dependencies, including TypeScript 5.9 and TypeDoc 0.28. TypeDoc 0.28 requires the missing-exports plugin to be listed explicitly. diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 7b696dde..b81ae480 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -2,6 +2,21 @@ The objective of this guide is to document the breaking changes and updates required to migrate from one major version to the next. +## version 13.x.x to version 14.x.x +* `customRoles` is no longer a positional argument on `Auth.Login`, `Auth.ElevatedLogin`, `Auth.ClientCredentials`, or `Auth.Anonymous`. Pass it on the request options object, along with `cancelToken`, `requestType`, and (for `Anonymous`) `anonuserid`. + + Before: + ```typescript + Auth.Login(username, password, clientID, scope, customRoles, requestOptions) + Auth.Anonymous(clientID, scope, customRoles, { anonuserid }) + ``` + + After: + ```typescript + Auth.Login(username, password, clientID, scope, { ...requestOptions, customRoles }) + Auth.Anonymous(clientID, scope, { anonuserid, customRoles }) + ``` + ## version 11.x.x to version 12.x.x * The `URL` and `SharedKey` properties have been removed from the `MessageSender` model, and `DeliveryConfigID` is now required. Configuration must be provided via a `DeliveryConfig` (managed through the new `DeliveryConfigurations` service) using either a `MailchimpConfig` or `MessageSenderConfig` delivery target. See the [updated Message Senders KB article](https://ordercloud.io/knowledge-base/message-senders#configuration-options) for more details. diff --git a/README.md b/README.md index 078c9c7b..f1d2ea77 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ Auth.Login(username, password, clientID, scope) Tokens.SetAccessToken(token) }) .catch(err => console.log(err)); + +// Custom roles are optional and belong on the request options object. +// ElevatedLogin, ClientCredentials, and Anonymous use the same shape. +// Anonymous also accepts anonuserid on that object. +Auth.Login(username, password, clientID, scope, { + customRoles: ['InventoryAdmin'], +}) ``` ## 🔍 Filtering diff --git a/codegen/templates/api/Auth.ts b/codegen/templates/api/Auth.ts index d2bfac4e..fce9b0b7 100644 --- a/codegen/templates/api/Auth.ts +++ b/codegen/templates/api/Auth.ts @@ -7,6 +7,35 @@ import paramsSerializer from '../utils/paramsSerializer' import { RequiredDeep } from '../models/RequiredDeep' import OrderCloudError from '../utils/OrderCloudError' +/** + * @ignore + * not part of public api, don't include in generated docs + * + * Standard roles, then a space, then custom roles. Omitted when both lists are empty. + */ +function resolveAuthScope( + scope?: ApiRole[], + customRoles?: string[] +): string | undefined { + if (scope && !Array.isArray(scope)) { + throw new Error('scope must be a string array') + } + if (customRoles != null && !Array.isArray(customRoles)) { + throw new Error('custom roles must be defined as a string array') + } + + if (scope?.length && customRoles?.length) { + return `${scope.join(' ')} ${customRoles.join(' ')}` + } + if (scope?.length) { + return scope.join(' ') + } + if (customRoles?.length) { + return customRoles.join(' ') + } + return undefined +} + class Auth { constructor() { if (typeof axios === 'undefined') { @@ -33,7 +62,7 @@ class Auth { * @param password of the user logging in * @param client_id of the application the user is logging into * @param scope optional roles being requested, if omitted will return all assigned roles - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. * @param requestOptions.requestType Provide a value that can be used to identify the type of request. Useful for error logs. */ @@ -42,34 +71,19 @@ class Auth { password: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'password', username, password, client_id: clientID, - scope: _scope, + scope: resolveAuthScope(scope, customRoles), } const configuration = Configuration.Get() const response = await axios @@ -82,7 +96,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -102,7 +116,7 @@ class Auth { * @param password of the user logging in * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. @@ -114,31 +128,16 @@ class Auth { password: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'password', - scope: _scope, + scope: resolveAuthScope(scope, customRoles), client_id: clientID, username, password, @@ -155,7 +154,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -173,7 +172,7 @@ class Auth { * @param clientSecret of the application * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. @@ -183,31 +182,16 @@ class Auth { clientSecret: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'client_credentials', - scope: _scope, + scope: resolveAuthScope(scope, customRoles), client_id: clientID, client_secret: clientSecret, } @@ -222,7 +206,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -283,7 +267,7 @@ class Auth { * * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param requestOptions.anonuserid Provide an externally generated id to track this user session, used specifically for the tracking events feature for integrating with Send and Discover * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. * @param requestOptions.requestType Provide a value that can be used to identify the type of request. Useful for error logs. @@ -291,37 +275,21 @@ class Auth { public async Anonymous( clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] anonuserid?: string cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, anonuserid, ...axiosOptions } = requestOptions const body = { grant_type: 'client_credentials', client_id: clientID, - scope: _scope, + scope: resolveAuthScope(scope, customRoles), } - if (requestOptions.anonuserid) { - body['anonuserid'] = requestOptions.anonuserid - delete requestOptions['anonuserid'] + if (anonuserid) { + body['anonuserid'] = anonuserid } const configuration = Configuration.Get() const response = await axios @@ -334,7 +302,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { diff --git a/src/api/Auth.ts b/src/api/Auth.ts index d2bfac4e..fce9b0b7 100644 --- a/src/api/Auth.ts +++ b/src/api/Auth.ts @@ -7,6 +7,35 @@ import paramsSerializer from '../utils/paramsSerializer' import { RequiredDeep } from '../models/RequiredDeep' import OrderCloudError from '../utils/OrderCloudError' +/** + * @ignore + * not part of public api, don't include in generated docs + * + * Standard roles, then a space, then custom roles. Omitted when both lists are empty. + */ +function resolveAuthScope( + scope?: ApiRole[], + customRoles?: string[] +): string | undefined { + if (scope && !Array.isArray(scope)) { + throw new Error('scope must be a string array') + } + if (customRoles != null && !Array.isArray(customRoles)) { + throw new Error('custom roles must be defined as a string array') + } + + if (scope?.length && customRoles?.length) { + return `${scope.join(' ')} ${customRoles.join(' ')}` + } + if (scope?.length) { + return scope.join(' ') + } + if (customRoles?.length) { + return customRoles.join(' ') + } + return undefined +} + class Auth { constructor() { if (typeof axios === 'undefined') { @@ -33,7 +62,7 @@ class Auth { * @param password of the user logging in * @param client_id of the application the user is logging into * @param scope optional roles being requested, if omitted will return all assigned roles - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. * @param requestOptions.requestType Provide a value that can be used to identify the type of request. Useful for error logs. */ @@ -42,34 +71,19 @@ class Auth { password: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'password', username, password, client_id: clientID, - scope: _scope, + scope: resolveAuthScope(scope, customRoles), } const configuration = Configuration.Get() const response = await axios @@ -82,7 +96,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -102,7 +116,7 @@ class Auth { * @param password of the user logging in * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. @@ -114,31 +128,16 @@ class Auth { password: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'password', - scope: _scope, + scope: resolveAuthScope(scope, customRoles), client_id: clientID, username, password, @@ -155,7 +154,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -173,7 +172,7 @@ class Auth { * @param clientSecret of the application * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. @@ -183,31 +182,16 @@ class Auth { clientSecret: string, clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, ...axiosOptions } = requestOptions const body = { grant_type: 'client_credentials', - scope: _scope, + scope: resolveAuthScope(scope, customRoles), client_id: clientID, client_secret: clientSecret, } @@ -222,7 +206,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { @@ -283,7 +267,7 @@ class Auth { * * @param clientID of the application the user is logging into * @param scope roles being requested - space delimited string or array - * @param customRoles optional custom roles being requested - string array + * @param requestOptions.customRoles optional custom roles being requested - string array * @param requestOptions.anonuserid Provide an externally generated id to track this user session, used specifically for the tracking events feature for integrating with Send and Discover * @param requestOptions.cancelToken Provide an [axios cancelToken](https://github.com/axios/axios#cancellation) that can be used to cancel the request. * @param requestOptions.requestType Provide a value that can be used to identify the type of request. Useful for error logs. @@ -291,37 +275,21 @@ class Auth { public async Anonymous( clientID: string, scope?: ApiRole[], - customRoles?: string[], requestOptions: { + customRoles?: string[] anonuserid?: string cancelToken?: CancelToken requestType?: string } = {} ): Promise> { - if (scope && !Array.isArray(scope)) { - throw new Error('scope must be a string array') - } - if (customRoles != null && !Array.isArray(customRoles)) { - throw new Error('custom roles must be defined as a string array') - } - - let _scope: string | undefined - if (scope?.length && !customRoles?.length) { - _scope = scope.join(' ') - } else if (!scope?.length && customRoles?.length) { - _scope += ` ${customRoles.join(' ')}` - } else if (scope?.length && customRoles?.length) { - _scope = `${scope.join(' ')} ${customRoles.join(' ')}` - } - + const { customRoles, anonuserid, ...axiosOptions } = requestOptions const body = { grant_type: 'client_credentials', client_id: clientID, - scope: _scope, + scope: resolveAuthScope(scope, customRoles), } - if (requestOptions.anonuserid) { - body['anonuserid'] = requestOptions.anonuserid - delete requestOptions['anonuserid'] + if (anonuserid) { + body['anonuserid'] = anonuserid } const configuration = Configuration.Get() const response = await axios @@ -334,7 +302,7 @@ class Auth { Accept: 'application/json', }, adapter: configuration.axiosAdapter, - ...requestOptions, + ...axiosOptions, } ) .catch(e => { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 4a25e641..8588a802 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -16,182 +16,244 @@ const testdata = { } afterEach(() => { - // cleans up any tracked calls before the next test jest.clearAllMocks() }) const urlencode = encodeURIComponent -test('can auth with login, no custom roles', async () => { - await Auth.Login( - testdata.username, - testdata.password, - testdata.clientID, - testdata.scope as ApiRole[] - ) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=password&username=${urlencode( - testdata.username - )}&password=${urlencode(testdata.password)}&client_id=${ - testdata.clientID - }&scope=${urlencode(testdata.scope.join(' '))}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) -}) -test('can auth with login without scope or custom roles', async () => { - await Auth.Login(testdata.username, testdata.password, testdata.clientID) - expect(mockAxios.post).toHaveBeenCalledTimes(1) +type ScopeCase = { + name: string + scope?: ApiRole[] + customRoles?: string[] + expectedScope?: string +} - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=password&username=${urlencode( - testdata.username - )}&password=${urlencode(testdata.password)}&client_id=${testdata.clientID}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) -}) +const scopeCases: ScopeCase[] = [ + { + name: 'standard roles only', + scope: testdata.scope, + expectedScope: testdata.scope.join(' '), + }, + { + name: 'custom roles only', + customRoles: testdata.customRoles, + expectedScope: testdata.customRoles.join(' '), + }, + { + name: 'standard and custom roles', + scope: testdata.scope, + customRoles: testdata.customRoles, + expectedScope: `${testdata.scope.join(' ')} ${testdata.customRoles.join( + ' ' + )}`, + }, + { + name: 'neither standard nor custom roles', + }, +] -test('can auth with login with custom roles', async () => { - await Auth.Login( - testdata.username, - testdata.password, - testdata.clientID, - testdata.scope as ApiRole[], - testdata.customRoles - ) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=password&username=${urlencode( - testdata.username - )}&password=${urlencode(testdata.password)}&client_id=${ - testdata.clientID - }&scope=${urlencode( - `${testdata.scope.join(' ')} ${testdata.customRoles?.join?.(' ')}` - )}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) -}) +function withScope( + parts: string[], + scope?: string, + at = parts.length +): string[] { + const next = parts.slice() + if (scope) { + next.splice(at, 0, `scope=${urlencode(scope)}`) + } + return next +} -test('can auth with elevated login, no custom roles', async () => { - await Auth.ElevatedLogin( - testdata.clientSecret, - testdata.username, - testdata.password, - testdata.clientID, - testdata.scope - ) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=password&scope=${urlencode( - testdata.scope.join(' ') - )}&client_id=${testdata.clientID}&username=${urlencode( - testdata.username - )}&password=${urlencode(testdata.password)}&client_secret=${urlencode( - testdata.clientSecret - )}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) -}) +function loginBody(scope?: string): string { + return withScope( + [ + 'grant_type=password', + `username=${urlencode(testdata.username)}`, + `password=${urlencode(testdata.password)}`, + `client_id=${testdata.clientID}`, + ], + scope + ).join('&') +} -test('can auth with elevated login with custom roles', async () => { - await Auth.ElevatedLogin( - testdata.clientSecret, - testdata.username, - testdata.password, - testdata.clientID, - testdata.scope, - testdata.customRoles - ) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=password&scope=${urlencode( - `${testdata.scope.join(' ')} ${testdata.customRoles?.join?.(' ')}` - )}&client_id=${testdata.clientID}&username=${urlencode( - testdata.username - )}&password=${urlencode(testdata.password)}&client_secret=${urlencode( - testdata.clientSecret - )}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) -}) +function elevatedLoginBody(scope?: string): string { + return withScope( + [ + 'grant_type=password', + `client_id=${testdata.clientID}`, + `username=${urlencode(testdata.username)}`, + `password=${urlencode(testdata.password)}`, + `client_secret=${urlencode(testdata.clientSecret)}`, + ], + scope, + 1 + ).join('&') +} -test('can auth with client credentials, no custom roles', async () => { - await Auth.ClientCredentials( - testdata.clientSecret, - testdata.clientID, - testdata.scope as ApiRole[] +function clientCredentialsBody(scope?: string): string { + return withScope( + [ + 'grant_type=client_credentials', + `client_id=${testdata.clientID}`, + `client_secret=${testdata.clientSecret}`, + ], + scope, + 1 + ).join('&') +} + +function anonymousBody(scope?: string, anonuserid?: string): string { + const parts = withScope( + ['grant_type=client_credentials', `client_id=${testdata.clientID}`], + scope ) + if (anonuserid) { + parts.push(`anonuserid=${anonuserid}`) + } + return parts.join('&') +} + +function expectTokenRequest( + body: string, + config: Record = {} +) { expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=client_credentials&scope=${urlencode( - testdata.scope.join(' ') - )}&client_id=${testdata.clientID}&client_secret=${testdata.clientSecret}` expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { headers: testdata.authHeaders, + ...config, }) -}) + const axiosConfig = (mockAxios.post as jest.Mock).mock.calls[0][2] + expect(axiosConfig).not.toHaveProperty('customRoles') + expect(axiosConfig).not.toHaveProperty('anonuserid') +} -test('can auth with client credentials with custom roles', async () => { - await Auth.ClientCredentials( - testdata.clientSecret, - testdata.clientID, - testdata.scope as ApiRole[], - testdata.customRoles +describe('Login', () => { + test.each(scopeCases)( + '$name', + async ({ scope, customRoles, expectedScope }) => { + const requestOptions = { + customRoles, + requestType: 'login', + } + await Auth.Login( + testdata.username, + testdata.password, + testdata.clientID, + scope, + requestOptions + ) + expectTokenRequest(loginBody(expectedScope), { requestType: 'login' }) + expect(requestOptions.customRoles).toBe(customRoles) + } ) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=client_credentials&scope=${urlencode( - `${testdata.scope.join(' ')} ${testdata.customRoles?.join?.(' ')}` - )}&client_id=${testdata.clientID}&client_secret=${testdata.clientSecret}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, + + test('rejects a non-array scope', async () => { + await expect( + Auth.Login( + testdata.username, + testdata.password, + testdata.clientID, + 'BuyerAdmin' as any + ) + ).rejects.toThrow('scope must be a string array') + expect(mockAxios.post).not.toHaveBeenCalled() }) -}) -test('can auth with refresh token', async () => { - const refreshToken = 'mock-refresh-token' - await Auth.RefreshToken(refreshToken, testdata.clientID) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - const body = `grant_type=refresh_token&client_id=${testdata.clientID}&refresh_token=${refreshToken}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, + test('rejects non-array custom roles', async () => { + await expect( + Auth.Login( + testdata.username, + testdata.password, + testdata.clientID, + undefined, + { + customRoles: 'InventoryAdmin' as any, + } + ) + ).rejects.toThrow('custom roles must be defined as a string array') + expect(mockAxios.post).not.toHaveBeenCalled() }) }) -test('can auth anonymous, no custom roles', async () => { - await Auth.Anonymous(testdata.clientID, testdata.scope) - expect(mockAxios.post).toHaveBeenCalledTimes(1) +describe('ElevatedLogin', () => { + test.each(scopeCases)( + '$name', + async ({ scope, customRoles, expectedScope }) => { + await Auth.ElevatedLogin( + testdata.clientSecret, + testdata.username, + testdata.password, + testdata.clientID, + scope, + { customRoles, requestType: 'elevated' } + ) + expectTokenRequest(elevatedLoginBody(expectedScope), { + requestType: 'elevated', + }) + } + ) +}) - const body = `grant_type=client_credentials&client_id=${ - testdata.clientID - }&scope=${urlencode(testdata.scope.join(' '))}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, - }) +describe('ClientCredentials', () => { + test.each(scopeCases)( + '$name', + async ({ scope, customRoles, expectedScope }) => { + await Auth.ClientCredentials( + testdata.clientSecret, + testdata.clientID, + scope, + { customRoles, requestType: 'client' } + ) + expectTokenRequest(clientCredentialsBody(expectedScope), { + requestType: 'client', + }) + } + ) }) -test('handles auth anonymous with anonuserid', async () => { - await Auth.Anonymous(testdata.clientID, testdata.scope, undefined, { - anonuserid: 'myanonuserid', - }) +describe('Anonymous', () => { + test.each(scopeCases)( + '$name', + async ({ scope, customRoles, expectedScope }) => { + await Auth.Anonymous(testdata.clientID, scope, { + customRoles, + requestType: 'anonymous', + }) + expectTokenRequest(anonymousBody(expectedScope), { + requestType: 'anonymous', + }) + } + ) - const body = `grant_type=client_credentials&client_id=${ - testdata.clientID - }&scope=${urlencode(testdata.scope.join(' '))}&anonuserid=myanonuserid` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, + test('puts anonuserid on the token body', async () => { + const requestOptions = { + anonuserid: 'myanonuserid', + customRoles: testdata.customRoles, + requestType: 'anonymous', + } + await Auth.Anonymous(testdata.clientID, testdata.scope, requestOptions) + expectTokenRequest( + anonymousBody( + `${testdata.scope.join(' ')} ${testdata.customRoles.join(' ')}`, + 'myanonuserid' + ), + { requestType: 'anonymous' } + ) + expect(requestOptions).toEqual({ + anonuserid: 'myanonuserid', + customRoles: testdata.customRoles, + requestType: 'anonymous', + }) }) }) -test('can auth anonymous with custom roles', async () => { - await Auth.Anonymous(testdata.clientID, testdata.scope, testdata.customRoles) - expect(mockAxios.post).toHaveBeenCalledTimes(1) - - const body = `grant_type=client_credentials&client_id=${ - testdata.clientID - }&scope=${urlencode( - `${testdata.scope.join(' ')} ${testdata.customRoles?.join?.(' ')}` - )}` - expect(mockAxios.post).toHaveBeenCalledWith(testdata.authUrl, body, { - headers: testdata.authHeaders, +test('can auth with refresh token', async () => { + const refreshToken = 'mock-refresh-token' + await Auth.RefreshToken(refreshToken, testdata.clientID, { + requestType: 'refresh', }) + expectTokenRequest( + `grant_type=refresh_token&client_id=${testdata.clientID}&refresh_token=${refreshToken}`, + { requestType: 'refresh' } + ) })