From 9c7182e80dec216922d10177db57fb210a66855b Mon Sep 17 00:00:00 2001 From: Eric Sitarz Date: Sat, 26 Sep 2026 15:37:52 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20move=20customRoles=20onto=20auth?= =?UTF-8?q?=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers that pass customRoles as its own argument must update, so this stays unreleased until the next major. Custom roles alone are sent as the scope string instead of the literal "undefined". Refs #81 --- CHANGELOG.md | 3 + MIGRATION_GUIDE.md | 15 ++ README.md | 7 + codegen/templates/api/Auth.ts | 134 +++++-------- src/api/Auth.ts | 134 +++++-------- tests/auth.test.ts | 358 ++++++++++++++++++++-------------- 6 files changed, 337 insertions(+), 314 deletions(-) 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' } + ) })