From 7695ce0a33d53351d337aea58d8127666be02892 Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:08:53 -0400 Subject: [PATCH 1/7] feat: add custom user roles endpoints --- src/endpoints/custom-user-roles.js | 49 +++++++++++ src/index.d.ts | 3 + src/index.js | 2 + src/types/custom-user-roles.ts | 54 ++++++++++++ test/tests.ts | 1 + test/unit/custom-user-roles.ts | 137 +++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+) create mode 100644 src/endpoints/custom-user-roles.js create mode 100644 src/types/custom-user-roles.ts create mode 100644 test/unit/custom-user-roles.ts diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js new file mode 100644 index 0000000..f3e15bb --- /dev/null +++ b/src/endpoints/custom-user-roles.js @@ -0,0 +1,49 @@ +import CRUDExtend from '../extends/crud' + +import { buildURL } from '../utils/helpers' + +class CustomUserRolesEndpoint extends CRUDExtend { + constructor(endpoint) { + super(endpoint) + + this.endpoint = 'permissions' + } + + GetCustomUserRoles({ limit, offset } = {}) { + return this.request.send( + buildURL(`${this.endpoint}/custom-user-roles`, { + limit: limit !== undefined ? limit : this.limit, + offset: offset !== undefined ? offset : this.offset + }), + 'GET' + ) + } + + GetCustomUserRole(roleId) { + return this.request.send( + `${this.endpoint}/custom-user-roles/${roleId}`, + 'GET' + ) + } + + CreateCustomUserRole(body) { + return this.request.send(`${this.endpoint}/custom-user-roles`, 'POST', body) + } + + UpdateCustomUserRole(roleId, body) { + return this.request.send( + `${this.endpoint}/custom-user-roles/${roleId}`, + 'PUT', + body + ) + } + + DeleteCustomUserRole(roleId) { + return this.request.send( + `${this.endpoint}/custom-user-roles/${roleId}`, + 'DELETE' + ) + } +} + +export default CustomUserRolesEndpoint diff --git a/src/index.d.ts b/src/index.d.ts index 562cac5..31d72f1 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -71,6 +71,7 @@ import { SubscriptionInvoicesEndpoint } from './types/subscription-invoices' import { CustomRelationshipsEndpoint } from './types/custom-relationships' import { MultiLocationInventoriesEndpoint } from './types/multi-location-inventories' import { CustomApiRolePoliciesEndpoint } from './types/custom-api-role-policies' +import { CustomUserRolesEndpoint } from './types/custom-user-roles' import { AccountTagsEndpoint } from './types/account-tags' export * from './types/config' @@ -144,6 +145,7 @@ export * from './types/one-time-password-token-request' export * from './types/subscriptions' export * from './types/rule-promotions' export * from './types/custom-api-role-policies' +export * from './types/custom-user-roles' export * from './types/subscription-subscribers' export * from './types/subscription-jobs' export * from './types/subscription-schedules' @@ -219,6 +221,7 @@ export class ElasticPath { Subscriptions: SubscriptionsEndpoint RulePromotions: RulePromotionsEndpoint CustomApiRolePolicies: CustomApiRolePoliciesEndpoint + CustomUserRoles: CustomUserRolesEndpoint SubscriptionSubscribers: SubscriptionSubscribersEndpoint SubscriptionJobs: SubscriptionJobsEndpoint SubscriptionSchedules: SubscriptionSchedulesEndpoint diff --git a/src/index.js b/src/index.js index 0374292..8450c83 100644 --- a/src/index.js +++ b/src/index.js @@ -73,6 +73,7 @@ import CatalogsEndpoint from './endpoints/catalogs' import ShopperCatalogEndpoint from './endpoints/catalog' import CustomApisEndpoint from './endpoints/custom-apis' import CustomApiRolePoliciesEndpoint from './endpoints/custom-api-role-policies' +import CustomUserRolesEndpoint from './endpoints/custom-user-roles' export default class ElasticPath { constructor(config) { @@ -149,6 +150,7 @@ export default class ElasticPath { this.SubscriptionSchedules = new SubscriptionSchedulesEndpoint(config) this.CustomApis = new CustomApisEndpoint(config) this.CustomApiRolePolicies = new CustomApiRolePoliciesEndpoint(config) + this.CustomUserRoles = new CustomUserRolesEndpoint(config) this.SubscriptionDunningRules = new SubscriptionDunningRulesEndpoint(config) this.SubscriptionProrationPolicies = new SubscriptionProrationPoliciesEndpoint(config) diff --git a/src/types/custom-user-roles.ts b/src/types/custom-user-roles.ts new file mode 100644 index 0000000..584fdd1 --- /dev/null +++ b/src/types/custom-user-roles.ts @@ -0,0 +1,54 @@ +import { Resource, ResourcePage } from './core' + +export interface CustomUserRole { + id: string + type: 'custom_user_role' + name: string + description?: string + access_levels: Record + links: { self: string } + meta: { + timestamps: { + created_at: string + updated_at: string + } + owner: string + } +} + +// Create rejects a missing or null description; an empty string is accepted. +export interface CreateCustomUserRoleBody { + type: 'custom_user_role' + name: string + description: string + access_levels: Record +} + +export interface UpdateCustomUserRoleBody { + type: 'custom_user_role' + name?: string + description?: string + access_levels?: Record +} + +export interface CustomUserRolesEndpoint { + endpoint: 'permissions' + + GetCustomUserRoles(args?: { + limit?: number + offset?: number + }): Promise> + + GetCustomUserRole(roleId: string): Promise> + + CreateCustomUserRole( + body: CreateCustomUserRoleBody + ): Promise> + + UpdateCustomUserRole( + roleId: string, + body: UpdateCustomUserRoleBody + ): Promise> + + DeleteCustomUserRole(roleId: string): Promise +} diff --git a/test/tests.ts b/test/tests.ts index c4b6ae6..8878818 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -40,6 +40,7 @@ require('./unit/account-authentication-settings') require('./unit/account-membership-settings') require('./unit/account-memberships') require('./unit/application-keys') +require('./unit/custom-user-roles') require('./unit/one-time-password-token-request') // Utilities diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts new file mode 100644 index 0000000..514ecf4 --- /dev/null +++ b/test/unit/custom-user-roles.ts @@ -0,0 +1,137 @@ +import { assert } from 'chai' +import nock from 'nock' +import { gateway as ElasticPathGateway } from '../../src' + +const apiUrl = 'https://euwest.api.elasticpath.com/v2' + +const customUserRole = { + id: 'role-1', + type: 'custom_user_role', + name: 'Inventory Controller', + description: 'Manage all inventory operations.', + access_levels: { orders: 'view', inventories: 'manage' }, + links: { self: `${apiUrl}/permissions/custom-user-roles/role-1` }, + meta: { + timestamps: { + created_at: '2026-08-17T14:26:35.966Z', + updated_at: '2026-08-17T14:26:35.966Z' + }, + owner: 'store' + } +} + +describe('ElasticPath custom user roles', () => { + const ElasticPath = ElasticPathGateway({ + client_id: 'XXX' + }) + + it('should return a page of custom user roles with pagination params', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .query({ 'page[limit]': '25', 'page[offset]': '50' }) + .reply(200, { data: [customUserRole] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + limit: 25, + offset: 50 + }).then(response => { + assert.lengthOf(response.data, 1) + assert.propertyVal(response.data[0], 'name', 'Inventory Controller') + }) + }) + + it('should return all custom user roles without params', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .reply(200, { data: [customUserRole] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles().then(response => { + assert.lengthOf(response.data, 1) + }) + }) + + it('should return a single custom user role', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles/role-1') + .reply(200, { data: customUserRole }) + + return ElasticPath.CustomUserRoles.GetCustomUserRole('role-1').then( + response => { + assert.propertyVal(response.data, 'id', 'role-1') + } + ) + }) + + it('should create a custom user role passing the body through untouched', () => { + const body = { + type: 'custom_user_role' as const, + name: 'Inventory Controller', + description: 'Manage all inventory operations.', + access_levels: { orders: 'view', inventories: 'manage' } + } + + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .post('/permissions/custom-user-roles', { data: body }) + .reply(201, { data: customUserRole }) + + return ElasticPath.CustomUserRoles.CreateCustomUserRole(body).then( + response => { + assert.propertyVal(response.data, 'id', 'role-1') + } + ) + }) + + it('should update a custom user role with a sparse body', () => { + const body = { + type: 'custom_user_role' as const, + access_levels: { inventories: 'manage' } + } + + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .put('/permissions/custom-user-roles/role-1', { data: body }) + .reply(200, { data: customUserRole }) + + return ElasticPath.CustomUserRoles.UpdateCustomUserRole( + 'role-1', + body + ).then(response => { + assert.propertyVal(response.data, 'id', 'role-1') + }) + }) + + it('should delete a custom user role', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .delete('/permissions/custom-user-roles/role-1') + .reply(204) + + return ElasticPath.CustomUserRoles.DeleteCustomUserRole('role-1').then( + response => { + assert.equal(response as unknown as string, '{}') + } + ) + }) +}) From 77c8d1e2c0264206bbc7dad4cc780151ac244e38 Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:29:58 -0400 Subject: [PATCH 2/7] feat: filter custom user roles by name GetCustomUserRoles accepted only limit and offset, so a caller listing roles could not narrow by name and had to page through everything and match locally. The endpoint supports eq on name, and buildQueryParams already serialises a filter, so this just accepts one and passes it through. The filter type exposes eq on name alone. The endpoint also accepts eq over created_at, id and updated_at, but nothing calls for those yet and a narrower type is easier to widen later than to correct. Note for callers: filter values are not URI encoded, which is how every other filter in this SDK behaves. A space survives URL normalisation as %20, but an ampersand in a role name terminates the parameter, so "Ops & Fulfilment" reaches the API as eq(name,Ops . Encoding belongs in the shared query serialiser rather than in this endpoint. --- src/endpoints/custom-user-roles.js | 18 ++++++++- src/types/custom-user-roles.ts | 7 ++++ test/unit/custom-user-roles.ts | 59 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js index f3e15bb..9459204 100644 --- a/src/endpoints/custom-user-roles.js +++ b/src/endpoints/custom-user-roles.js @@ -2,6 +2,19 @@ import CRUDExtend from '../extends/crud' import { buildURL } from '../utils/helpers' +function withQuotedName(filter) { + const name = filter && filter.eq ? filter.eq.name : undefined + if (name === undefined || name === null) return filter + + return { + ...filter, + eq: { + ...filter.eq, + name: `"${encodeURIComponent(String(name).replace(/"/g, '\\"'))}"` + } + } +} + class CustomUserRolesEndpoint extends CRUDExtend { constructor(endpoint) { super(endpoint) @@ -9,11 +22,12 @@ class CustomUserRolesEndpoint extends CRUDExtend { this.endpoint = 'permissions' } - GetCustomUserRoles({ limit, offset } = {}) { + GetCustomUserRoles({ limit, offset, filter } = {}) { return this.request.send( buildURL(`${this.endpoint}/custom-user-roles`, { limit: limit !== undefined ? limit : this.limit, - offset: offset !== undefined ? offset : this.offset + offset: offset !== undefined ? offset : this.offset, + filter: withQuotedName(filter !== undefined ? filter : this.filter) }), 'GET' ) diff --git a/src/types/custom-user-roles.ts b/src/types/custom-user-roles.ts index 584fdd1..63c80ca 100644 --- a/src/types/custom-user-roles.ts +++ b/src/types/custom-user-roles.ts @@ -31,12 +31,19 @@ export interface UpdateCustomUserRoleBody { access_levels?: Record } +export interface CustomUserRoleFilter { + eq?: { + name?: string + } +} + export interface CustomUserRolesEndpoint { endpoint: 'permissions' GetCustomUserRoles(args?: { limit?: number offset?: number + filter?: CustomUserRoleFilter }): Promise> GetCustomUserRole(roleId: string): Promise> diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 514ecf4..429aedb 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -44,6 +44,65 @@ describe('ElasticPath custom user roles', () => { }) }) + it('should filter custom user roles by exact name', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .query({ filter: 'eq(name,"Inventory Controller")' }) + .reply(200, { data: [customUserRole] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + filter: { eq: { name: 'Inventory Controller' } } + }).then(response => { + assert.lengthOf(response.data, 1) + assert.propertyVal(response.data[0], 'name', 'Inventory Controller') + }) + }) + + it('should escape quotes and encode reserved characters in the name', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + // decodes server-side to eq(name,"Ops & \"Fulfilment\"") + .query({ filter: 'eq(name,"Ops & \\"Fulfilment\\"")' }) + .reply(200, { data: [] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + filter: { eq: { name: 'Ops & "Fulfilment"' } } + }).then(response => { + assert.lengthOf(response.data, 0) + }) + }) + + it('should send the name filter alongside pagination', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .query({ + 'page[limit]': '100', + 'page[offset]': '100', + filter: 'eq(name,"Inventory Controller")' + }) + .reply(200, { data: [customUserRole] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + limit: 100, + offset: 100, + filter: { eq: { name: 'Inventory Controller' } } + }).then(response => { + assert.lengthOf(response.data, 1) + }) + }) + it('should return all custom user roles without params', () => { nock(apiUrl, { reqheaders: { From e7fc452db561f2a2fff3afce9eb81c5149d88800 Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:36:20 -0400 Subject: [PATCH 3/7] fix: reset builder state after fetching roles and escape backslashes in name filter GetCustomUserRoles now passes the endpoint instance to request.send so resetProps clears Filter()/Limit()/Offset() state after each request, preventing a stale filter from leaking into later calls. withQuotedName escapes backslashes before quotes so names containing backslashes produce well-formed filter literals. --- src/endpoints/custom-user-roles.js | 11 ++++++-- test/unit/custom-user-roles.ts | 44 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js index 9459204..cf663f3 100644 --- a/src/endpoints/custom-user-roles.js +++ b/src/endpoints/custom-user-roles.js @@ -10,7 +10,11 @@ function withQuotedName(filter) { ...filter, eq: { ...filter.eq, - name: `"${encodeURIComponent(String(name).replace(/"/g, '\\"'))}"` + name: `"${encodeURIComponent( + String(name) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + )}"` } } } @@ -29,7 +33,10 @@ class CustomUserRolesEndpoint extends CRUDExtend { offset: offset !== undefined ? offset : this.offset, filter: withQuotedName(filter !== undefined ? filter : this.filter) }), - 'GET' + 'GET', + undefined, + undefined, + this ) } diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 429aedb..9b41a3d 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -80,6 +80,50 @@ describe('ElasticPath custom user roles', () => { }) }) + it('should escape backslashes in the name', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + // decodes server-side to eq(name,"Ops\\") — an escaped backslash + // before the closing quote, not an escaped quote + .query({ filter: 'eq(name,"Ops\\\\")' }) + .reply(200, { data: [] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + filter: { eq: { name: 'Ops\\' } } + }).then(response => { + assert.lengthOf(response.data, 0) + }) + }) + + it('should not reuse a Filter() from a previous request', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .query({ filter: 'eq(name,"Inventory Controller")' }) + .reply(200, { data: [customUserRole] }) + .get('/permissions/custom-user-roles') + .reply(200, { data: [customUserRole, customUserRole] }) + + const roles = ElasticPath.CustomUserRoles as unknown as { + Filter(filter: object): typeof ElasticPath.CustomUserRoles + } + + return roles + .Filter({ eq: { name: 'Inventory Controller' } }) + .GetCustomUserRoles() + .then(() => ElasticPath.CustomUserRoles.GetCustomUserRoles()) + .then(response => { + assert.lengthOf(response.data, 2) + }) + }) + it('should send the name filter alongside pagination', () => { nock(apiUrl, { reqheaders: { From b96f963cc1a9e39876419eef17472c8f83b7c90b Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:40:21 -0400 Subject: [PATCH 4/7] feat: support sort in GetCustomUserRoles Accepts an explicit sort argument (typed on the endpoint interface) and falls back to builder Sort() state, matching the limit/offset/filter handling. --- src/endpoints/custom-user-roles.js | 5 +++-- src/types/custom-user-roles.ts | 1 + test/unit/custom-user-roles.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js index cf663f3..6aa3ce2 100644 --- a/src/endpoints/custom-user-roles.js +++ b/src/endpoints/custom-user-roles.js @@ -26,12 +26,13 @@ class CustomUserRolesEndpoint extends CRUDExtend { this.endpoint = 'permissions' } - GetCustomUserRoles({ limit, offset, filter } = {}) { + GetCustomUserRoles({ limit, offset, filter, sort } = {}) { return this.request.send( buildURL(`${this.endpoint}/custom-user-roles`, { limit: limit !== undefined ? limit : this.limit, offset: offset !== undefined ? offset : this.offset, - filter: withQuotedName(filter !== undefined ? filter : this.filter) + filter: withQuotedName(filter !== undefined ? filter : this.filter), + sort: sort !== undefined ? sort : this.sort }), 'GET', undefined, diff --git a/src/types/custom-user-roles.ts b/src/types/custom-user-roles.ts index 63c80ca..8f4c08b 100644 --- a/src/types/custom-user-roles.ts +++ b/src/types/custom-user-roles.ts @@ -44,6 +44,7 @@ export interface CustomUserRolesEndpoint { limit?: number offset?: number filter?: CustomUserRoleFilter + sort?: string }): Promise> GetCustomUserRole(roleId: string): Promise> diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 9b41a3d..4f50aa1 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -80,6 +80,23 @@ describe('ElasticPath custom user roles', () => { }) }) + it('should sort custom user roles by name', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles') + .query({ sort: '-name' }) + .reply(200, { data: [customUserRole] }) + + return ElasticPath.CustomUserRoles.GetCustomUserRoles({ + sort: '-name' + }).then(response => { + assert.lengthOf(response.data, 1) + }) + }) + it('should escape backslashes in the name', () => { nock(apiUrl, { reqheaders: { From 304a62003c38a8e1cc073e0064e12ae3aaadcb65 Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:57:07 -0400 Subject: [PATCH 5/7] fix: reset builder state on all custom user role methods GetCustomUserRole/Create/Update/Delete now pass the endpoint instance to request.send so resetProps clears builder state after every call, preventing a stray Filter() from surviving a non-list call and leaking into a later GetCustomUserRoles. --- src/endpoints/custom-user-roles.js | 22 ++++++++++++++++++---- test/unit/custom-user-roles.ts | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js index 6aa3ce2..c4d8438 100644 --- a/src/endpoints/custom-user-roles.js +++ b/src/endpoints/custom-user-roles.js @@ -44,26 +44,40 @@ class CustomUserRolesEndpoint extends CRUDExtend { GetCustomUserRole(roleId) { return this.request.send( `${this.endpoint}/custom-user-roles/${roleId}`, - 'GET' + 'GET', + undefined, + undefined, + this ) } CreateCustomUserRole(body) { - return this.request.send(`${this.endpoint}/custom-user-roles`, 'POST', body) + return this.request.send( + `${this.endpoint}/custom-user-roles`, + 'POST', + body, + undefined, + this + ) } UpdateCustomUserRole(roleId, body) { return this.request.send( `${this.endpoint}/custom-user-roles/${roleId}`, 'PUT', - body + body, + undefined, + this ) } DeleteCustomUserRole(roleId) { return this.request.send( `${this.endpoint}/custom-user-roles/${roleId}`, - 'DELETE' + 'DELETE', + undefined, + undefined, + this ) } } diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 4f50aa1..19c0429 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -141,6 +141,30 @@ describe('ElasticPath custom user roles', () => { }) }) + it('should not reuse a Filter() after fetching a single role', () => { + nock(apiUrl, { + reqheaders: { + Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' + } + }) + .get('/permissions/custom-user-roles/role-1') + .reply(200, { data: customUserRole }) + .get('/permissions/custom-user-roles') + .reply(200, { data: [customUserRole, customUserRole] }) + + const roles = ElasticPath.CustomUserRoles as unknown as { + Filter(filter: object): typeof ElasticPath.CustomUserRoles + } + + roles.Filter({ eq: { name: 'Inventory Controller' } }) + + return ElasticPath.CustomUserRoles.GetCustomUserRole('role-1') + .then(() => ElasticPath.CustomUserRoles.GetCustomUserRoles()) + .then(response => { + assert.lengthOf(response.data, 2) + }) + }) + it('should send the name filter alongside pagination', () => { nock(apiUrl, { reqheaders: { From 956e84f97ce5d994fe5c76f606aed1d87170e1af Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:50:03 -0400 Subject: [PATCH 6/7] feat: narrow access_levels types for custom user roles Replaces Record with an exact set of the 28 permission groups the API validates, each typed to the levels it accepts. Composer and Legacy Catalogs take none/manage, Metrics none/view, and Custom Actions and Team only none. Create requires every group, matching the API's required validation; update takes Partial since the service merges the body into the stored role. --- src/types/custom-user-roles.ts | 50 ++++++++++++++++++++++-- test/unit/custom-user-roles.ts | 69 +++++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/types/custom-user-roles.ts b/src/types/custom-user-roles.ts index 8f4c08b..4f94a8e 100644 --- a/src/types/custom-user-roles.ts +++ b/src/types/custom-user-roles.ts @@ -1,11 +1,52 @@ import { Resource, ResourcePage } from './core' +export type AccessLevel = 'none' | 'view' | 'manage' + +/** + * The permission groups a custom user role grants access to. + * + * Every group is required on create. Composer, Legacy Catalogs, Metrics, + * Custom Actions and Team accept a narrower set of levels than the rest — + * Custom Actions and Team only ever 'none' for a custom user role, though + * standard user roles may hold higher levels for them. + */ +export type AccessLevels = { + accounts: AccessLevel + application_keys: AccessLevel + authentication: AccessLevel + catalog_releases: AccessLevel + catalog_search: AccessLevel + catalogs: AccessLevel + composer: 'none' | 'manage' + content_and_pages: AccessLevel + currencies: AccessLevel + custom_actions: 'none' + custom_apis: AccessLevel + flows: AccessLevel + inventories: AccessLevel + legacy_catalogs: 'none' | 'manage' + metrics: 'none' | 'view' + orders: AccessLevel + payment_gateways: AccessLevel + personal_data: AccessLevel + price_books: AccessLevel + products: AccessLevel + promotions: AccessLevel + settings: AccessLevel + subscription_billing: AccessLevel + subscription_jobs: AccessLevel + subscription_offerings: AccessLevel + subscription_subscribers: AccessLevel + team: 'none' + webhooks: AccessLevel +} + export interface CustomUserRole { id: string type: 'custom_user_role' name: string description?: string - access_levels: Record + access_levels: AccessLevels links: { self: string } meta: { timestamps: { @@ -17,18 +58,21 @@ export interface CustomUserRole { } // Create rejects a missing or null description; an empty string is accepted. +// Every permission group is required, so access_levels must be complete. export interface CreateCustomUserRoleBody { type: 'custom_user_role' name: string description: string - access_levels: Record + access_levels: AccessLevels } +// Update merges into the stored role, so any subset may be sent. Omitted +// permission groups keep their current level. export interface UpdateCustomUserRoleBody { type: 'custom_user_role' name?: string description?: string - access_levels?: Record + access_levels?: Partial } export interface CustomUserRoleFilter { diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 19c0429..4bcf570 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -1,15 +1,54 @@ import { assert } from 'chai' import nock from 'nock' import { gateway as ElasticPathGateway } from '../../src' +import type { + AccessLevels, + CreateCustomUserRoleBody, + CustomUserRole, + UpdateCustomUserRoleBody +} from '../../src/types/custom-user-roles' const apiUrl = 'https://euwest.api.elasticpath.com/v2' -const customUserRole = { +// The API requires every permission group on create, so a complete set is the +// only valid starting point for a create body. +const accessLevels: AccessLevels = { + accounts: 'none', + application_keys: 'none', + authentication: 'none', + catalog_releases: 'none', + catalog_search: 'none', + catalogs: 'none', + composer: 'none', + content_and_pages: 'none', + currencies: 'none', + custom_actions: 'none', + custom_apis: 'none', + flows: 'none', + inventories: 'manage', + legacy_catalogs: 'none', + metrics: 'none', + orders: 'view', + payment_gateways: 'none', + personal_data: 'none', + price_books: 'none', + products: 'none', + promotions: 'none', + settings: 'none', + subscription_billing: 'none', + subscription_jobs: 'none', + subscription_offerings: 'none', + subscription_subscribers: 'none', + team: 'none', + webhooks: 'none' +} + +const customUserRole: CustomUserRole = { id: 'role-1', type: 'custom_user_role', name: 'Inventory Controller', description: 'Manage all inventory operations.', - access_levels: { orders: 'view', inventories: 'manage' }, + access_levels: accessLevels, links: { self: `${apiUrl}/permissions/custom-user-roles/role-1` }, meta: { timestamps: { @@ -20,6 +59,18 @@ const customUserRole = { } } +// Compile-time coverage: the API rejects these bodies, so the types should too. +// @ts-expect-error 'read' is not a valid access level +const invalidLevel: AccessLevels = { ...accessLevels, orders: 'read' } +// @ts-expect-error a custom user role may only hold 'none' for team +const invalidTeamLevel: AccessLevels = { ...accessLevels, team: 'manage' } +// @ts-expect-error metrics has no 'manage' level +const invalidMetricsLevel: AccessLevels = { ...accessLevels, metrics: 'manage' } +// @ts-expect-error inventores is not a permission group +const unknownGroup: AccessLevels = { ...accessLevels, inventores: 'view' } + +void [invalidLevel, invalidTeamLevel, invalidMetricsLevel, unknownGroup] + describe('ElasticPath custom user roles', () => { const ElasticPath = ElasticPathGateway({ client_id: 'XXX' @@ -219,11 +270,11 @@ describe('ElasticPath custom user roles', () => { }) it('should create a custom user role passing the body through untouched', () => { - const body = { - type: 'custom_user_role' as const, + const body: CreateCustomUserRoleBody = { + type: 'custom_user_role', name: 'Inventory Controller', description: 'Manage all inventory operations.', - access_levels: { orders: 'view', inventories: 'manage' } + access_levels: accessLevels } nock(apiUrl, { @@ -231,7 +282,7 @@ describe('ElasticPath custom user roles', () => { Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' } }) - .post('/permissions/custom-user-roles', { data: body }) + .post('/permissions/custom-user-roles', { data: { ...body } }) .reply(201, { data: customUserRole }) return ElasticPath.CustomUserRoles.CreateCustomUserRole(body).then( @@ -242,8 +293,8 @@ describe('ElasticPath custom user roles', () => { }) it('should update a custom user role with a sparse body', () => { - const body = { - type: 'custom_user_role' as const, + const body: UpdateCustomUserRoleBody = { + type: 'custom_user_role', access_levels: { inventories: 'manage' } } @@ -252,7 +303,7 @@ describe('ElasticPath custom user roles', () => { Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' } }) - .put('/permissions/custom-user-roles/role-1', { data: body }) + .put('/permissions/custom-user-roles/role-1', { data: { ...body } }) .reply(200, { data: customUserRole }) return ElasticPath.CustomUserRoles.UpdateCustomUserRole( From 792db2a7d0ea8973a3773729f055e791a32272dd Mon Sep 17 00:00:00 2001 From: Ella Nan <38847123+ellanan@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:58:56 -0400 Subject: [PATCH 7/7] revert: drop backslash escaping from the name filter The filter grammar's double-quoted literal accepts a backslash only immediately before a quote, so \\ is not a valid escape and a name containing a backslash cannot be expressed at all. Escaping it produced a filter the platform lexer rejects, and the test asserting that behaviour was validating a request the platform would refuse. Quote escaping is unchanged; \" is the grammar's one supported escape. --- src/endpoints/custom-user-roles.js | 6 +----- test/unit/custom-user-roles.ts | 19 ------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/src/endpoints/custom-user-roles.js b/src/endpoints/custom-user-roles.js index c4d8438..c3415e3 100644 --- a/src/endpoints/custom-user-roles.js +++ b/src/endpoints/custom-user-roles.js @@ -10,11 +10,7 @@ function withQuotedName(filter) { ...filter, eq: { ...filter.eq, - name: `"${encodeURIComponent( - String(name) - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - )}"` + name: `"${encodeURIComponent(String(name).replace(/"/g, '\\"'))}"` } } } diff --git a/test/unit/custom-user-roles.ts b/test/unit/custom-user-roles.ts index 4bcf570..1b5907e 100644 --- a/test/unit/custom-user-roles.ts +++ b/test/unit/custom-user-roles.ts @@ -148,25 +148,6 @@ describe('ElasticPath custom user roles', () => { }) }) - it('should escape backslashes in the name', () => { - nock(apiUrl, { - reqheaders: { - Authorization: 'Bearer a550d8cbd4a4627013452359ab69694cd446615a' - } - }) - .get('/permissions/custom-user-roles') - // decodes server-side to eq(name,"Ops\\") — an escaped backslash - // before the closing quote, not an escaped quote - .query({ filter: 'eq(name,"Ops\\\\")' }) - .reply(200, { data: [] }) - - return ElasticPath.CustomUserRoles.GetCustomUserRoles({ - filter: { eq: { name: 'Ops\\' } } - }).then(response => { - assert.lengthOf(response.data, 0) - }) - }) - it('should not reuse a Filter() from a previous request', () => { nock(apiUrl, { reqheaders: {