@webex/webex-core 3.12.0-auth-prejoin-fetch.1 → 3.12.0-llmrefactor.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/config.js +14 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/index.js +7 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/interceptors/catalog-url.js +87 -0
  6. package/dist/interceptors/catalog-url.js.map +1 -0
  7. package/dist/interceptors/payload-transformer.js +25 -4
  8. package/dist/interceptors/payload-transformer.js.map +1 -1
  9. package/dist/lib/batcher.js +1 -1
  10. package/dist/lib/credentials/credentials.js +56 -1
  11. package/dist/lib/credentials/credentials.js.map +1 -1
  12. package/dist/lib/credentials/token.js +1 -1
  13. package/dist/lib/services/service-catalog.js +102 -8
  14. package/dist/lib/services/service-catalog.js.map +1 -1
  15. package/dist/lib/services/services.js +1 -1
  16. package/dist/lib/services-v2/service-catalog.js +8 -1
  17. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  18. package/dist/lib/services-v2/services-v2.js +3 -2
  19. package/dist/lib/services-v2/services-v2.js.map +1 -1
  20. package/dist/plugins/logger.js +1 -1
  21. package/dist/webex-core.js +11 -3
  22. package/dist/webex-core.js.map +1 -1
  23. package/package.json +13 -13
  24. package/src/config.js +15 -0
  25. package/src/index.js +1 -0
  26. package/src/interceptors/catalog-url.js +66 -0
  27. package/src/interceptors/payload-transformer.js +23 -1
  28. package/src/lib/credentials/credentials.js +43 -0
  29. package/src/lib/services/service-catalog.js +106 -6
  30. package/src/lib/services-v2/service-catalog.ts +10 -1
  31. package/src/lib/services-v2/services-v2.ts +5 -1
  32. package/src/webex-core.js +8 -0
  33. package/test/unit/spec/credentials/credentials.js +114 -1
  34. package/test/unit/spec/interceptors/catalog-url.js +224 -0
  35. package/test/unit/spec/interceptors/payload-transformer.js +119 -1
  36. package/test/unit/spec/services/service-catalog.js +275 -0
  37. package/test/unit/spec/services-v2/service-catalog.ts +41 -0
  38. package/test/unit/spec/services-v2/services-v2.ts +20 -0
  39. package/test/unit/spec/webex-core.js +77 -1
@@ -4,6 +4,7 @@ import {union} from 'lodash';
4
4
  import ServiceDetail from './service-detail';
5
5
  import {IServiceDetail, ServiceGroup} from './types';
6
6
  import {matchAllowedDomain, normalizeAllowedDomains} from '../domains';
7
+ import {matchesParsedCatalogUrl, parseCatalogUrl} from '../services/service-catalog';
7
8
 
8
9
  /**
9
10
  * @class
@@ -199,9 +200,17 @@ const ServiceCatalog = AmpState.extend({
199
200
  findServiceDetailFromUrl(url: string): IServiceDetail | undefined {
200
201
  const serviceDetails = this._getAllServiceDetails();
201
202
 
203
+ let candidateUrl: URL;
204
+
205
+ try {
206
+ candidateUrl = new URL(url);
207
+ } catch {
208
+ return undefined;
209
+ }
210
+
202
211
  return serviceDetails.find(({serviceUrls}) => {
203
212
  for (const serviceUrl of serviceUrls) {
204
- if (url.startsWith(serviceUrl.baseUrl)) {
213
+ if (matchesParsedCatalogUrl(candidateUrl, parseCatalogUrl(serviceUrl.baseUrl))) {
205
214
  return true;
206
215
  }
207
216
  }
@@ -7,6 +7,7 @@ import METRICS from '../metrics';
7
7
  import ServiceCatalog from './service-catalog';
8
8
  import fedRampServices from './service-fed-ramp';
9
9
  import {COMMERCIAL_ALLOWED_DOMAINS} from '../constants';
10
+ import {matchesCatalogUrl} from '../services/service-catalog';
10
11
  import {
11
12
  ActiveServices,
12
13
  IServiceCatalog,
@@ -15,6 +16,7 @@ import {
15
16
  ServiceHostmap,
16
17
  ServiceGroup,
17
18
  ServiceHost,
19
+ ServiceUrl,
18
20
  SelectionMeta,
19
21
  } from './types';
20
22
 
@@ -988,7 +990,9 @@ const Services = WebexPlugin.extend({
988
990
 
989
991
  const priorityUrl = service.get();
990
992
  const defaultUrl = new URL(
991
- service.serviceUrls.find((serviceUrl) => url.startsWith(serviceUrl.baseUrl)).baseUrl
993
+ service.serviceUrls.find((serviceUrl: ServiceUrl) =>
994
+ matchesCatalogUrl(url, serviceUrl.baseUrl)
995
+ ).baseUrl
992
996
  ).href;
993
997
 
994
998
  return {
package/src/webex-core.js CHANGED
@@ -17,6 +17,7 @@ import AmpState from 'ampersand-state';
17
17
  import uuid from 'uuid';
18
18
 
19
19
  import AuthInterceptor from './interceptors/auth';
20
+ import CatalogUrlInterceptor from './interceptors/catalog-url';
20
21
  import NetworkTimingInterceptor from './interceptors/network-timing';
21
22
  import PayloadTransformerInterceptor from './interceptors/payload-transformer';
22
23
  import RedirectInterceptor from './interceptors/redirect';
@@ -58,6 +59,7 @@ const interceptors = {
58
59
  /* eslint-enable no-extra-parens */
59
60
  RequestTimingInterceptor: RequestTimingInterceptor.create,
60
61
  ServiceInterceptor: undefined,
62
+ CatalogUrlInterceptor: CatalogUrlInterceptor.create,
61
63
  UserAgentInterceptor: UserAgentInterceptor.create,
62
64
  ProxyInterceptor: ProxyInterceptor.create,
63
65
  WebexUserAgentInterceptor: WebexUserAgentInterceptor.create,
@@ -83,6 +85,7 @@ const preInterceptors = [
83
85
  'RequestEventInterceptor',
84
86
  'WebexTrackingIdInterceptor',
85
87
  'RateLimitInterceptor',
88
+ 'CatalogUrlInterceptor',
86
89
  ];
87
90
 
88
91
  const postInterceptors = [
@@ -393,6 +396,11 @@ const WebexCore = AmpState.extend({
393
396
  return ints;
394
397
  }
395
398
 
399
+ // CatalogUrlInterceptor is opt-in via config.services.validateCatalogUrls
400
+ if (key === 'CatalogUrlInterceptor' && this.config.services?.validateCatalogUrls !== true) {
401
+ return ints;
402
+ }
403
+
396
404
  ints.push(Reflect.apply(interceptor, this, []));
397
405
 
398
406
  return ints;
@@ -7,7 +7,7 @@ import {assert} from '@webex/test-helper-chai';
7
7
  import sinon from 'sinon';
8
8
  import MockWebex from '@webex/test-helper-mock-webex';
9
9
  import {Credentials, Token, grantErrors} from '@webex/webex-core';
10
- import {inBrowser} from '@webex/common';
10
+ import {inBrowser, base64} from '@webex/common';
11
11
  import FakeTimers from '@sinonjs/fake-timers';
12
12
  import {skipInBrowser} from '@webex/test-helper-mocha';
13
13
  import Logger from '@webex/plugin-logger';
@@ -506,6 +506,88 @@ describe('webex-core', () => {
506
506
  expect(() => credentials.extractOrgIdFromUserToken()).toThrow());
507
507
  });
508
508
 
509
+ describe('#extractUserIdFromToken()', () => {
510
+ let credentials;
511
+ let webex;
512
+
513
+ beforeEach(() => {
514
+ webex = new MockWebex();
515
+ credentials = new Credentials(undefined, {parent: webex});
516
+ });
517
+
518
+ const buildToken = (payload) =>
519
+ `header.${base64.toBase64Url(JSON.stringify(payload))}.signature`;
520
+
521
+ it('should return the cis_uuid from the provided token', () => {
522
+ const token = buildToken({cis_uuid: 'my-user-id'});
523
+
524
+ assert.equal(credentials.extractUserIdFromToken(token), 'my-user-id');
525
+ });
526
+
527
+ it('should throw if the token does not contain a cis_uuid', () => {
528
+ const token = buildToken({foo: 'bar'});
529
+
530
+ expect(() => credentials.extractUserIdFromToken(token)).toThrow(
531
+ 'the provided token does not contain a user ID'
532
+ );
533
+ });
534
+
535
+ it('should throw when provided an unparseable token', () =>
536
+ expect(() => credentials.extractUserIdFromToken('not-a-valid-token')).toThrow());
537
+
538
+ it('should throw when no token is provided', () =>
539
+ expect(() => credentials.extractUserIdFromToken()).toThrow());
540
+ });
541
+
542
+ describe('#getUserId()', () => {
543
+ let credentials;
544
+ let webex;
545
+
546
+ const buildToken = (userId) =>
547
+ `header.${base64.toBase64Url(JSON.stringify({cis_uuid: userId}))}.signature`;
548
+
549
+ beforeEach(() => {
550
+ webex = new MockWebex();
551
+ credentials = new Credentials(undefined, {parent: webex});
552
+ });
553
+
554
+ it('should return the userId from the supertoken', () => {
555
+ credentials.supertoken = makeToken(webex, {
556
+ access_token: buildToken('supertoken-user-id'),
557
+ });
558
+
559
+ assert.equal(credentials.getUserId(), 'supertoken-user-id');
560
+ });
561
+
562
+ it('should fall back to a user token when the supertoken has no userId', () => {
563
+ credentials.supertoken = makeToken(webex, {access_token: 'AT'});
564
+ credentials.userTokens.add(
565
+ makeToken(webex, {access_token: buildToken('user-token-user-id'), scope: 'scope1'})
566
+ );
567
+
568
+ assert.equal(credentials.getUserId(), 'user-token-user-id');
569
+ });
570
+
571
+ it('should prefer the supertoken over the user tokens', () => {
572
+ credentials.supertoken = makeToken(webex, {
573
+ access_token: buildToken('supertoken-user-id'),
574
+ });
575
+ credentials.userTokens.add(
576
+ makeToken(webex, {access_token: buildToken('user-token-user-id'), scope: 'scope1'})
577
+ );
578
+
579
+ assert.equal(credentials.getUserId(), 'supertoken-user-id');
580
+ });
581
+
582
+ it('should throw if no available token contains a userId', () => {
583
+ credentials.supertoken = makeToken(webex, {access_token: 'AT'});
584
+
585
+ expect(() => credentials.getUserId()).toThrow(
586
+ 'could not extract the user ID from any available token'
587
+ );
588
+ });
589
+ });
590
+
509
591
  describe('#initialize()', () => {
510
592
  it('turns a portal auth string into a configuration object', () => {
511
593
  const webex = new MockWebex();
@@ -630,6 +712,37 @@ describe('webex-core', () => {
630
712
  });
631
713
  });
632
714
 
715
+ describe('#getClientToken()', () => {
716
+ it('requests a client token without bypassing catalog URL validation', async () => {
717
+ const webex = new MockWebex();
718
+ const credentials = new Credentials(undefined, {parent: webex});
719
+ const uri = 'https://idbroker.webex.com/idb/oauth2/v1/access_token';
720
+ const scope = 'spark:all';
721
+
722
+ webex.request.resolves({body: {access_token: 'AT', token_type: 'Bearer'}});
723
+
724
+ const token = await credentials.getClientToken({uri, scope});
725
+
726
+ assert.calledOnceWithExactly(webex.request, {
727
+ method: 'POST',
728
+ uri,
729
+ form: {
730
+ grant_type: 'client_credentials',
731
+ scope,
732
+ self_contained_token: true,
733
+ },
734
+ auth: {
735
+ user: 'fake',
736
+ pass: 'fake',
737
+ sendImmediately: true,
738
+ },
739
+ shouldRefreshAccessToken: false,
740
+ });
741
+ assert.instanceOf(token, Token);
742
+ assert.equal(token.access_token, 'AT');
743
+ });
744
+ });
745
+
633
746
  describe('#getUserToken()', () => {
634
747
  it('resolves with the supertoken if the supertoken matches the requested scopes', () => {
635
748
  const webex = new MockWebex();
@@ -0,0 +1,224 @@
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import chai from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
7
+ import sinon from 'sinon';
8
+ import {CatalogUrlInterceptor} from '@webex/webex-core';
9
+
10
+ const {assert} = chai;
11
+
12
+ chai.use(chaiAsPromised);
13
+ sinon.assert.expose(chai.assert, {prefix: ''});
14
+
15
+ describe('webex-core', () => {
16
+ describe('Interceptors', () => {
17
+ describe('CatalogUrlInterceptor', () => {
18
+ let interceptor;
19
+ let webex;
20
+
21
+ beforeEach(() => {
22
+ webex = {
23
+ config: {
24
+ services: {},
25
+ },
26
+ internal: {
27
+ services: {
28
+ getServiceFromUrl: sinon.stub(),
29
+ },
30
+ },
31
+ logger: {
32
+ warn: sinon.stub(),
33
+ },
34
+ };
35
+
36
+ interceptor = Reflect.apply(CatalogUrlInterceptor.create, webex, []);
37
+ });
38
+
39
+ describe('#onRequest()', () => {
40
+ it('allows catalog URLs', async () => {
41
+ const options = {uri: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
42
+ webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
43
+
44
+ const result = await interceptor.onRequest(options);
45
+
46
+ assert.deepEqual(result, options);
47
+ assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.uri);
48
+ });
49
+
50
+ it('blocks non-catalog URLs with error', async () => {
51
+ const options = {uri: 'https://evil.attacker.com/steal-data'};
52
+ webex.internal.services.getServiceFromUrl.returns(undefined);
53
+
54
+ await assert.isRejected(
55
+ interceptor.onRequest(options),
56
+ /Request blocked: URL not in service catalog/
57
+ );
58
+ assert.calledOnce(webex.internal.services.getServiceFromUrl);
59
+ });
60
+
61
+ describe('service parameter bypass', () => {
62
+ it('skips validation when service has not been resolved to a URL', async () => {
63
+ const options = {
64
+ service: 'conversation',
65
+ resource: '/messages',
66
+ };
67
+
68
+ const result = await interceptor.onRequest(options);
69
+
70
+ assert.deepEqual(result, options);
71
+ assert.notCalled(webex.internal.services.getServiceFromUrl);
72
+ });
73
+
74
+ it('blocks a non-catalog URL when service is also present', async () => {
75
+ const options = {
76
+ service: 'conversation',
77
+ resource: '/messages',
78
+ uri: 'https://attacker.com/steal',
79
+ headers: {authorization: 'Bearer token'},
80
+ };
81
+ webex.internal.services.getServiceFromUrl.returns(undefined);
82
+
83
+ await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
84
+ assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.uri);
85
+ });
86
+ });
87
+
88
+ describe('url parameter support', () => {
89
+ it('validates options.url when options.uri is not present', async () => {
90
+ const options = {url: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
91
+ webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
92
+
93
+ const result = await interceptor.onRequest(options);
94
+
95
+ assert.deepEqual(result, options);
96
+ assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.url);
97
+ });
98
+ });
99
+
100
+ describe('edge cases', () => {
101
+ it('allows request when no URL is present', async () => {
102
+ const options = {method: 'GET'};
103
+
104
+ const result = await interceptor.onRequest(options);
105
+
106
+ assert.deepEqual(result, options);
107
+ assert.notCalled(webex.internal.services.getServiceFromUrl);
108
+ });
109
+
110
+ it('handles missing services plugin gracefully', async () => {
111
+ webex.internal = undefined;
112
+ const options = {uri: 'https://example.com/api'};
113
+
114
+ const result = await interceptor.onRequest(options);
115
+
116
+ assert.deepEqual(result, options);
117
+ });
118
+ });
119
+
120
+ describe('security scenarios', () => {
121
+ it('blocks Mercury-injected malicious URLs', async () => {
122
+ // This is the SSRF attack vector: Mercury event contains attacker URL
123
+ // that the flag plugin would POST to without validation
124
+ const options = {
125
+ method: 'POST',
126
+ uri: 'https://attacker-controlled.com/exfiltrate',
127
+ body: {sensitiveData: 'secrets'},
128
+ };
129
+ webex.internal.services.getServiceFromUrl.returns(undefined);
130
+
131
+ await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
132
+ });
133
+
134
+ it('allows legitimate activity service URLs', async () => {
135
+ const options = {
136
+ method: 'POST',
137
+ uri: 'https://conv-a.wbx2.com/conversation/api/v1/activities/12345',
138
+ body: {verb: 'flag'},
139
+ };
140
+ webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
141
+
142
+ const result = await interceptor.onRequest(options);
143
+
144
+ assert.deepEqual(result, options);
145
+ });
146
+
147
+ it('blocks URLs with similar-looking hostnames', async () => {
148
+ // Attacker might try to use a URL that looks similar to a catalog URL
149
+ const options = {
150
+ uri: 'https://conv-a.wbx2.com.attacker.com/api',
151
+ };
152
+ webex.internal.services.getServiceFromUrl.returns(undefined);
153
+
154
+ await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
155
+ });
156
+ });
157
+
158
+ describe('allowedDomains support', () => {
159
+ beforeEach(() => {
160
+ // Add allowedDomains methods to mock
161
+ webex.internal.services.validateDomains = true;
162
+ webex.internal.services.hasAllowedDomains = sinon.stub();
163
+ webex.internal.services.isAllowedDomainUrl = sinon.stub();
164
+ });
165
+
166
+ it('allows URLs in allowedDomains when not in catalog', async () => {
167
+ const options = {uri: 'https://cdn.example.com/files/encrypted-attachment.bin'};
168
+ webex.internal.services.getServiceFromUrl.returns(undefined);
169
+ webex.internal.services.hasAllowedDomains.returns(true);
170
+ webex.internal.services.isAllowedDomainUrl.returns(true);
171
+
172
+ const result = await interceptor.onRequest(options);
173
+
174
+ assert.deepEqual(result, options);
175
+ assert.calledOnceWithExactly(webex.internal.services.isAllowedDomainUrl, options.uri);
176
+ });
177
+
178
+ it('blocks URLs not in catalog and not in allowedDomains', async () => {
179
+ const options = {uri: 'https://attacker.com/steal'};
180
+ webex.internal.services.getServiceFromUrl.returns(undefined);
181
+ webex.internal.services.hasAllowedDomains.returns(true);
182
+ webex.internal.services.isAllowedDomainUrl.returns(false);
183
+
184
+ await assert.isRejected(
185
+ interceptor.onRequest(options),
186
+ /Request blocked: URL not in service catalog or allowed domains/
187
+ );
188
+ });
189
+
190
+ it('blocks when validateDomains is false', async () => {
191
+ const options = {uri: 'https://cdn.example.com/files/attachment.bin'};
192
+ webex.internal.services.validateDomains = false;
193
+ webex.internal.services.getServiceFromUrl.returns(undefined);
194
+ webex.internal.services.hasAllowedDomains.returns(true);
195
+ webex.internal.services.isAllowedDomainUrl.returns(true);
196
+
197
+ await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
198
+ assert.notCalled(webex.internal.services.isAllowedDomainUrl);
199
+ });
200
+
201
+ it('blocks when no allowedDomains configured', async () => {
202
+ const options = {uri: 'https://cdn.example.com/files/attachment.bin'};
203
+ webex.internal.services.getServiceFromUrl.returns(undefined);
204
+ webex.internal.services.hasAllowedDomains.returns(false);
205
+
206
+ await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
207
+ assert.notCalled(webex.internal.services.isAllowedDomainUrl);
208
+ });
209
+
210
+ it('skips allowedDomains check when URL is in catalog', async () => {
211
+ const options = {uri: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
212
+ webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
213
+
214
+ const result = await interceptor.onRequest(options);
215
+
216
+ assert.deepEqual(result, options);
217
+ assert.notCalled(webex.internal.services.hasAllowedDomains);
218
+ assert.notCalled(webex.internal.services.isAllowedDomainUrl);
219
+ });
220
+ });
221
+ });
222
+ });
223
+ });
224
+ });
@@ -4,7 +4,8 @@
4
4
 
5
5
  import {assert} from '@webex/test-helper-chai';
6
6
  import {capitalize} from 'lodash';
7
- import WebexCore from '@webex/webex-core';
7
+ import sinon from 'sinon';
8
+ import WebexCore, {PayloadTransformerInterceptor} from '@webex/webex-core';
8
9
  // TODO: fix circular dependency core->metrics->core https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-515520
9
10
  require('@webex/internal-plugin-metrics');
10
11
 
@@ -153,5 +154,122 @@ describe('webex-core', () => {
153
154
  ));
154
155
  });
155
156
  });
157
+
158
+ describe('PayloadTransformerInterceptor repeated inbound transforms', () => {
159
+ const createInterceptor = (skipRepeatedInboundTransforms) => {
160
+ const transform = sinon.stub().callsFake((direction, value) => Promise.resolve(value));
161
+ const interceptor = new PayloadTransformerInterceptor({
162
+ webex: {
163
+ config: {payloadTransformer: {skipRepeatedInboundTransforms}},
164
+ transform,
165
+ },
166
+ });
167
+
168
+ return {interceptor, transform};
169
+ };
170
+
171
+ describe('#onResponse()', () => {
172
+ it('transforms the same response every time when skipping repeated transforms is disabled', async () => {
173
+ const {interceptor, transform} = createInterceptor(false);
174
+ const response = {};
175
+
176
+ await interceptor.onResponse({}, response);
177
+ await interceptor.onResponse({}, response);
178
+
179
+ assert.calledTwice(transform);
180
+ });
181
+
182
+ it('transforms the same response only once when repeated transforms are skipped', async () => {
183
+ const {interceptor, transform} = createInterceptor(true);
184
+ const response = {};
185
+
186
+ assert.equal(await interceptor.onResponse({}, response), response);
187
+ assert.equal(await interceptor.onResponse({}, response), response);
188
+ assert.calledOnceWithExactly(transform, 'inbound', response);
189
+ });
190
+
191
+ it('shares an in-flight transform for concurrent calls with the same response', async () => {
192
+ const {interceptor, transform} = createInterceptor(true);
193
+ const response = {};
194
+ let resolveTransform;
195
+
196
+ transform.callsFake(
197
+ (direction, value) =>
198
+ new Promise((resolve) => {
199
+ resolveTransform = () => resolve(value);
200
+ })
201
+ );
202
+
203
+ const firstTransform = interceptor.onResponse({}, response);
204
+ const secondTransform = interceptor.onResponse({}, response);
205
+
206
+ assert.equal(firstTransform, secondTransform);
207
+ assert.calledOnceWithExactly(transform, 'inbound', response);
208
+
209
+ resolveTransform();
210
+
211
+ assert.equal(await firstTransform, response);
212
+ assert.equal(await secondTransform, response);
213
+ });
214
+
215
+ it('transforms distinct response objects independently', async () => {
216
+ const {interceptor, transform} = createInterceptor(true);
217
+ const firstResponse = {};
218
+ const secondResponse = {};
219
+
220
+ await interceptor.onResponse({}, firstResponse);
221
+ await interceptor.onResponse({}, secondResponse);
222
+
223
+ assert.calledTwice(transform);
224
+ assert.isTrue(transform.firstCall.calledWithExactly('inbound', firstResponse));
225
+ assert.isTrue(transform.secondCall.calledWithExactly('inbound', secondResponse));
226
+ });
227
+
228
+ it('retries transformation after a failed transform', async () => {
229
+ const {interceptor, transform} = createInterceptor(true);
230
+ const response = {};
231
+
232
+ transform.onFirstCall().rejects(new Error('transform failed'));
233
+
234
+ await assert.isRejected(interceptor.onResponse({}, response), 'transform failed');
235
+ assert.equal(await interceptor.onResponse({}, response), response);
236
+ assert.calledTwice(transform);
237
+ });
238
+
239
+ it('stores transform state as non-enumerable response metadata', async () => {
240
+ const {interceptor} = createInterceptor(true);
241
+ const response = {body: {value: true}};
242
+
243
+ await interceptor.onResponse({}, response);
244
+
245
+ const [marker] = Object.getOwnPropertySymbols(response);
246
+
247
+ assert.isDefined(marker);
248
+ assert.isFalse(Object.getOwnPropertyDescriptor(response, marker).enumerable);
249
+ assert.deepEqual(JSON.parse(JSON.stringify(response)), {body: {value: true}});
250
+ });
251
+
252
+ it('does not mark a response when transforms are disabled for that request', async () => {
253
+ const {interceptor, transform} = createInterceptor(true);
254
+ const response = {};
255
+
256
+ assert.equal(await interceptor.onResponse({disableTransform: true}, response), response);
257
+ assert.equal(await interceptor.onResponse({}, response), response);
258
+ assert.calledOnceWithExactly(transform, 'inbound', response);
259
+ });
260
+ });
261
+
262
+ describe('#onResponseError()', () => {
263
+ it('continues transforming repeated error objects independently', async () => {
264
+ const {interceptor, transform} = createInterceptor(true);
265
+ const reason = new Error('request failed');
266
+
267
+ await assert.isRejected(interceptor.onResponseError({}, reason), 'request failed');
268
+ await assert.isRejected(interceptor.onResponseError({}, reason), 'request failed');
269
+ assert.calledTwice(transform);
270
+ assert.alwaysCalledWithExactly(transform, 'inbound', reason);
271
+ });
272
+ });
273
+ });
156
274
  });
157
275
  });