fable 3.1.71 → 3.1.72

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fable",
3
- "version": "3.1.71",
3
+ "version": "3.1.72",
4
4
  "description": "A service dependency injection, configuration and logging library.",
5
5
  "main": "source/Fable.js",
6
6
  "scripts": {
@@ -52,7 +52,7 @@
52
52
  "homepage": "https://github.com/stevenvelozo/fable",
53
53
  "devDependencies": {
54
54
  "pict-docuserve": "^0.1.5",
55
- "quackage": "^1.1.2"
55
+ "quackage": "^1.2.3"
56
56
  },
57
57
  "dependencies": {
58
58
  "async.eachlimit": "^0.5.2",
@@ -65,7 +65,7 @@
65
65
  "fable-log": "^3.0.18",
66
66
  "fable-serviceproviderbase": "^3.0.19",
67
67
  "fable-settings": "^3.0.16",
68
- "fable-uuid": "^3.0.13",
68
+ "fable-uuid": "^3.0.14",
69
69
  "manyfest": "^1.0.49",
70
70
  "simple-get": "^4.0.1"
71
71
  }
@@ -90,13 +90,20 @@ class FableServiceRestClient extends libFableServiceBase
90
90
 
91
91
  this.prepareRequestOptions = (pOptions) =>
92
92
  {
93
- if (typeof pOptions.url === 'string' && pOptions.url.startsWith('http:'))
93
+ // Mirror simple-get's protocol decision exactly: it routes through
94
+ // the https module if and only if the parsed URL protocol is
95
+ // exactly 'https:'. Everything else — http: URLs, relative URLs
96
+ // (which simple-get treats as no-host http requests), and option
97
+ // objects with no .url at all — goes through the http module.
98
+ // Stamping an httpsAgent on an http request makes Node throw
99
+ // ERR_INVALID_PROTOCOL when http.request validates agent.protocol.
100
+ if (typeof pOptions.url === 'string' && pOptions.url.startsWith('https:'))
94
101
  {
95
- pOptions.agent = this.httpAgent;
102
+ pOptions.agent = this.httpsAgent;
96
103
  }
97
104
  else
98
105
  {
99
- pOptions.agent = this.httpsAgent;
106
+ pOptions.agent = this.httpAgent;
100
107
  }
101
108
  return tmpPreviousPrepareRequestOptions(pOptions);
102
109
  };
@@ -148,6 +155,196 @@ class FableServiceRestClient extends libFableServiceBase
148
155
  return this.prepareRequestOptions(tmpOptions);
149
156
  }
150
157
 
158
+ /**
159
+ * Extract the hostname from a URL string. Returns null for relative URLs
160
+ * or anything else that doesn't parse cleanly.
161
+ *
162
+ * @private
163
+ * @param {string} pUrl
164
+ * @return {string|null}
165
+ */
166
+ _parseHostname(pUrl)
167
+ {
168
+ if (typeof pUrl !== 'string')
169
+ {
170
+ return null;
171
+ }
172
+ try
173
+ {
174
+ return new URL(pUrl).hostname || null;
175
+ }
176
+ catch (e)
177
+ {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Resolve a Location header against the URL of the request that produced
184
+ * it. Handles absolute Locations (returned as-is) and RFC 7231-compliant
185
+ * relative Locations (resolved against the current URL).
186
+ *
187
+ * @private
188
+ * @param {string} pCurrentURL - The URL of the request that produced the redirect.
189
+ * @param {string} pLocation - The Location header value.
190
+ * @return {string}
191
+ */
192
+ _resolveRedirectURL(pCurrentURL, pLocation)
193
+ {
194
+ if (typeof pLocation !== 'string' || pLocation.length === 0)
195
+ {
196
+ return pLocation;
197
+ }
198
+ try
199
+ {
200
+ return new URL(pLocation, pCurrentURL).toString();
201
+ }
202
+ catch (e)
203
+ {
204
+ // Either pCurrentURL is itself relative or pLocation is malformed.
205
+ // Fall back to passing it through verbatim — simple-get's parser
206
+ // will give the next hop a final say.
207
+ return pLocation;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Build the options object for the next hop of a redirect chain. Applies
213
+ * the same hop-rewrite rules simple-get does, plus an RFC-correct relative
214
+ * Location resolution that simple-get itself doesn't do:
215
+ * - Resolve the Location against the current URL (absolute or relative).
216
+ * - Strip simple-get's URL-derived state (protocol/hostname/port/path/auth)
217
+ * so the next hop re-parses the URL cleanly.
218
+ * - Drop the host header (re-derived from the new URL by simple-get).
219
+ * - Cross-origin: drop cookie + authorization to prevent leak.
220
+ * - 301/302 + POST: switch to GET, drop body and content headers.
221
+ *
222
+ * @private
223
+ * @param {Object} pOptions - The options used for the previous hop (post-simple-get mutation).
224
+ * @param {import('http').IncomingMessage} pResponse - The 3xx response.
225
+ * @param {string|undefined} pOriginalURL - The URL of the previous hop, captured before simple-get deleted it.
226
+ * @param {string|null} pOriginalHost - The hostname of the previous hop, for cross-origin detection.
227
+ * @return {Object}
228
+ */
229
+ _buildRedirectedOptions(pOptions, pResponse, pOriginalURL, pOriginalHost)
230
+ {
231
+ const tmpNew = Object.assign({}, pOptions);
232
+ tmpNew.url = this._resolveRedirectURL(pOriginalURL, pResponse.headers.location);
233
+
234
+ // Strip simple-get's own URL-derived fields so the next call re-parses cleanly.
235
+ delete tmpNew.protocol;
236
+ delete tmpNew.hostname;
237
+ delete tmpNew.port;
238
+ delete tmpNew.path;
239
+ delete tmpNew.auth;
240
+
241
+ // We set followRedirects=false on the previous hop to disable
242
+ // simple-get's auto-follow; that's our own internal flag, not caller
243
+ // intent. Drop it so the recursive _executeWithRedirects entry treats
244
+ // the next hop as another redirect-following call.
245
+ delete tmpNew.followRedirects;
246
+
247
+ // Headers — clone (don't mutate the caller's) and prune.
248
+ if (tmpNew.headers)
249
+ {
250
+ tmpNew.headers = Object.assign({}, tmpNew.headers);
251
+ delete tmpNew.headers.host;
252
+ }
253
+
254
+ // Cross-origin redirect: drop cookie and authorization to prevent leak (matches simple-get #73).
255
+ const tmpRedirectHost = this._parseHostname(tmpNew.url);
256
+ if (tmpRedirectHost !== null && tmpRedirectHost !== pOriginalHost && tmpNew.headers)
257
+ {
258
+ delete tmpNew.headers.cookie;
259
+ delete tmpNew.headers.authorization;
260
+ }
261
+
262
+ // 301/302 + POST → GET (matches simple-get #35 and RFC 7231 §6.4.2/6.4.3).
263
+ // Body and content headers come off; 307/308 preserve method/body so we leave them alone.
264
+ if (tmpNew.method === 'POST' && (pResponse.statusCode === 301 || pResponse.statusCode === 302))
265
+ {
266
+ tmpNew.method = 'GET';
267
+ if (tmpNew.headers)
268
+ {
269
+ delete tmpNew.headers['content-length'];
270
+ delete tmpNew.headers['content-type'];
271
+ }
272
+ delete tmpNew.body;
273
+ delete tmpNew.form;
274
+ }
275
+
276
+ return tmpNew;
277
+ }
278
+
279
+ /**
280
+ * Dispatch a request via simple-get, transparently following 3xx redirects
281
+ * until a non-redirect response or hard error.
282
+ *
283
+ * Why we drive the loop ourselves: simple-get's own redirect path
284
+ * (index.js lines 50-69) recurses with the original opts.agent intact, so
285
+ * an http→https redirect ends up calling https.request with an httpAgent
286
+ * (or vice versa) and Node throws ERR_INVALID_PROTOCOL synchronously.
287
+ * By disabling simple-get's auto-follow and running prepareRequestOptions
288
+ * on each hop, the agent gets re-picked to match the new URL's protocol.
289
+ *
290
+ * Caller can opt out by setting `followRedirects: false` on options
291
+ * (matches simple-get's contract) — in that case we hand the 3xx straight
292
+ * back without following.
293
+ *
294
+ * @private
295
+ * @param {Object} pOptions - Already passed through preRequest on the first call; on recursion, already passed through prepareRequestOptions.
296
+ * @param {(err?: Error, res?: import('http').IncomingMessage) => void} fCallback
297
+ */
298
+ _executeWithRedirects(pOptions, fCallback)
299
+ {
300
+ if (pOptions.followRedirects === false)
301
+ {
302
+ return libSimpleGet(pOptions, fCallback);
303
+ }
304
+
305
+ // Disable simple-get's own redirect loop — we own it from here.
306
+ pOptions.followRedirects = false;
307
+ const tmpOriginalURL = pOptions.url;
308
+ const tmpOriginalHost = this._parseHostname(tmpOriginalURL);
309
+
310
+ return libSimpleGet(pOptions, (pError, pResponse) =>
311
+ {
312
+ if (pError)
313
+ {
314
+ return fCallback(pError, pResponse);
315
+ }
316
+
317
+ if (pResponse.statusCode < 300 || pResponse.statusCode >= 400 || !pResponse.headers.location)
318
+ {
319
+ return fCallback(null, pResponse);
320
+ }
321
+
322
+ // 3xx with Location — drain and follow.
323
+ pResponse.resume();
324
+
325
+ let tmpRedirectsRemaining = (typeof pOptions.maxRedirects === 'number') ? pOptions.maxRedirects : 10;
326
+ if (tmpRedirectsRemaining <= 0)
327
+ {
328
+ return fCallback(new Error('too many redirects'));
329
+ }
330
+
331
+ const tmpNextOptions = this._buildRedirectedOptions(pOptions, pResponse, tmpOriginalURL, tmpOriginalHost);
332
+ tmpNextOptions.maxRedirects = tmpRedirectsRemaining - 1;
333
+
334
+ // Re-run the agent picker so the next hop's agent matches the
335
+ // (possibly different) protocol of the redirect target. We do
336
+ // NOT re-run preRequest in full — RestClientURLPrefix and
337
+ // prepareCookies are first-call-only.
338
+ const tmpPreparedNext = this.prepareRequestOptions(tmpNextOptions);
339
+
340
+ if (this.TraceLog)
341
+ {
342
+ this.fable.log.debug(`--> redirect ${pResponse.statusCode} to ${tmpPreparedNext.url}`);
343
+ }
344
+ return this._executeWithRedirects(tmpPreparedNext, fCallback);
345
+ });
346
+ }
347
+
151
348
  executeChunkedRequest(pOptions, fCallback)
152
349
  {
153
350
  let tmpOptions = this.preRequest(pOptions);
@@ -159,7 +356,7 @@ class FableServiceRestClient extends libFableServiceBase
159
356
  this.fable.log.debug(`Beginning ${tmpOptions.method} request to ${tmpOptions.url} at ${tmpOptions.RequestStartTime}`);
160
357
  }
161
358
 
162
- return libSimpleGet(tmpOptions,
359
+ return this._executeWithRedirects(tmpOptions,
163
360
  (pError, pResponse)=>
164
361
  {
165
362
  if (pError)
@@ -212,7 +409,7 @@ class FableServiceRestClient extends libFableServiceBase
212
409
  tmpOptions.json = false;
213
410
  tmpOptions.encoding = null;
214
411
 
215
- return libSimpleGet(tmpOptions,
412
+ return this._executeWithRedirects(tmpOptions,
216
413
  (pError, pResponse)=>
217
414
  {
218
415
  if (pError)
@@ -283,7 +480,7 @@ class FableServiceRestClient extends libFableServiceBase
283
480
  this.fable.log.debug(`Beginning ${tmpOptions.method} JSON request to ${tmpOptions.url} at ${tmpOptions.RequestStartTime}`);
284
481
  }
285
482
 
286
- return libSimpleGet(tmpOptions,
483
+ return this._executeWithRedirects(tmpOptions,
287
484
  (pError, pResponse)=>
288
485
  {
289
486
  if (pError)
@@ -461,7 +658,7 @@ class FableServiceRestClient extends libFableServiceBase
461
658
 
462
659
  tmpOptions.json = false;
463
660
 
464
- return libSimpleGet(tmpOptions,
661
+ return this._executeWithRedirects(tmpOptions,
465
662
  (pError, pResponse) =>
466
663
  {
467
664
  if (pError)
@@ -278,6 +278,73 @@ suite
278
278
  Expect(tmpHttp.agent).to.equal(tmpRestClient.httpAgent);
279
279
  }
280
280
  );
281
+ test
282
+ (
283
+ 'Selects the http agent for relative URLs and option objects without a URL.',
284
+ function ()
285
+ {
286
+ // simple-get's protocol decision is `opts.protocol === 'https:'
287
+ // ? https : http`, so a URL with no protocol — including a
288
+ // relative path or a pre-parsed options object — routes through
289
+ // the http module. The agent we stamp on must match, otherwise
290
+ // Node throws ERR_INVALID_PROTOCOL on dispatch.
291
+ let testFable = new libFable();
292
+ let tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-AgentInject-Relative');
293
+
294
+ let tmpRelative = tmpRestClient.prepareRequestOptions({ url: '/1.0/Users/Count' });
295
+ let tmpNoUrl = tmpRestClient.prepareRequestOptions({ hostname: 'localhost', port: 8086, path: '/1.0/Users' });
296
+
297
+ Expect(tmpRelative.agent).to.equal(tmpRestClient.httpAgent);
298
+ Expect(tmpNoUrl.agent).to.equal(tmpRestClient.httpAgent);
299
+ }
300
+ );
301
+ test
302
+ (
303
+ 'A relative URL is dispatched without throwing ERR_INVALID_PROTOCOL.',
304
+ function (fTestComplete)
305
+ {
306
+ // End-to-end regression: before the fix, fable stamped the
307
+ // httpsAgent on relative URLs while simple-get routed them
308
+ // through http.request, which made Node throw synchronously.
309
+ // We point fable at a real http server via RestClientURLPrefix
310
+ // so the relative URL becomes resolvable, and assert that the
311
+ // request completes (rather than throwing on dispatch).
312
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
313
+ {
314
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
315
+ pRes.end(JSON.stringify({ Path: pReq.url }));
316
+ });
317
+
318
+ tmpServer.listen(0, function ()
319
+ {
320
+ var tmpPort = tmpServer.address().port;
321
+ var testFable = new libFable({ RestClientURLPrefix: 'http://localhost:' + tmpPort });
322
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-RelativeURL-Dispatch');
323
+
324
+ var tmpOriginalPrepare = tmpRestClient.prepareRequestOptions;
325
+ var tmpCapturedAgent = null;
326
+ tmpRestClient.prepareRequestOptions = function (pOptions)
327
+ {
328
+ let tmpResult = tmpOriginalPrepare(pOptions);
329
+ if (tmpCapturedAgent === null)
330
+ {
331
+ tmpCapturedAgent = tmpResult.agent;
332
+ }
333
+ return tmpResult;
334
+ };
335
+
336
+ tmpRestClient.getJSON('/relative/path',
337
+ function (pError, pResponse, pBody)
338
+ {
339
+ Expect(pError).to.equal(null);
340
+ Expect(tmpCapturedAgent).to.equal(tmpRestClient.httpAgent);
341
+ Expect(pBody.Path).to.equal('/relative/path');
342
+ tmpServer.close();
343
+ fTestComplete();
344
+ });
345
+ });
346
+ }
347
+ );
281
348
  test
282
349
  (
283
350
  'Chains with previously set prepareRequestOptions.',
@@ -479,6 +546,281 @@ suite
479
546
  }
480
547
  );
481
548
 
549
+ suite
550
+ (
551
+ 'Redirect Following',
552
+ function ()
553
+ {
554
+ test
555
+ (
556
+ 'Follows a basic 301 redirect end-to-end.',
557
+ function (fTestComplete)
558
+ {
559
+ // Single server: /start returns 301 to /dest, /dest returns 200 + JSON.
560
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
561
+ {
562
+ if (pReq.url === '/start')
563
+ {
564
+ pRes.writeHead(301, { Location: '/dest' });
565
+ pRes.end();
566
+ return;
567
+ }
568
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
569
+ pRes.end(JSON.stringify({ Reached: pReq.url }));
570
+ });
571
+ tmpServer.listen(0, function ()
572
+ {
573
+ var tmpPort = tmpServer.address().port;
574
+ var testFable = new libFable();
575
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-Basic');
576
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/start',
577
+ function (pError, pResponse, pBody)
578
+ {
579
+ Expect(pError).to.equal(null);
580
+ Expect(pBody.Reached).to.equal('/dest');
581
+ tmpServer.close();
582
+ fTestComplete();
583
+ });
584
+ });
585
+ }
586
+ );
587
+ test
588
+ (
589
+ 'Re-picks the agent on a protocol-changing redirect.',
590
+ function (fTestComplete)
591
+ {
592
+ // http server redirects to an https URL. We don't need the https
593
+ // destination to be reachable — we just need to verify that
594
+ // prepareRequestOptions runs again on the redirect target and
595
+ // stamps the httpsAgent on the second hop.
596
+ var tmpAssignedAgents = [];
597
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
598
+ {
599
+ pRes.writeHead(302, { Location: 'https://nowhere.invalid/x' });
600
+ pRes.end();
601
+ });
602
+ tmpServer.listen(0, function ()
603
+ {
604
+ var tmpPort = tmpServer.address().port;
605
+ var testFable = new libFable();
606
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-ProtoSwitch');
607
+
608
+ var tmpOriginalPrepare = tmpRestClient.prepareRequestOptions;
609
+ tmpRestClient.prepareRequestOptions = function (pOptions)
610
+ {
611
+ var tmpResult = tmpOriginalPrepare(pOptions);
612
+ tmpAssignedAgents.push({ url: tmpResult.url, agent: tmpResult.agent });
613
+ return tmpResult;
614
+ };
615
+
616
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/start',
617
+ function (pError)
618
+ {
619
+ // The second hop will fail (DNS lookup of nowhere.invalid)
620
+ // but the agent re-pick must have already happened
621
+ // before that — and crucially, must NOT have thrown
622
+ // ERR_INVALID_PROTOCOL synchronously like the old code.
623
+ Expect(tmpAssignedAgents.length).to.be.at.least(2);
624
+ Expect(tmpAssignedAgents[0].agent).to.equal(tmpRestClient.httpAgent);
625
+ Expect(tmpAssignedAgents[1].agent).to.equal(tmpRestClient.httpsAgent);
626
+ Expect(tmpAssignedAgents[1].url).to.equal('https://nowhere.invalid/x');
627
+ tmpServer.close();
628
+ fTestComplete();
629
+ });
630
+ });
631
+ }
632
+ );
633
+ test
634
+ (
635
+ 'Bails with "too many redirects" when the budget is exceeded.',
636
+ function (fTestComplete)
637
+ {
638
+ // Self-redirecting server. Cap at 3 hops so the test stays quick.
639
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
640
+ {
641
+ pRes.writeHead(302, { Location: pReq.url });
642
+ pRes.end();
643
+ });
644
+ tmpServer.listen(0, function ()
645
+ {
646
+ var tmpPort = tmpServer.address().port;
647
+ var testFable = new libFable();
648
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-Loop');
649
+ tmpRestClient.getJSON({ url: 'http://localhost:' + tmpPort + '/loop', maxRedirects: 3 },
650
+ function (pError)
651
+ {
652
+ Expect(pError).to.be.an.instanceof(Error);
653
+ Expect(pError.message).to.equal('too many redirects');
654
+ tmpServer.close();
655
+ fTestComplete();
656
+ });
657
+ });
658
+ }
659
+ );
660
+ test
661
+ (
662
+ 'Drops cookie and authorization on cross-origin redirect.',
663
+ function (fTestComplete)
664
+ {
665
+ // Two servers on different ports. We use 127.0.0.1 vs localhost
666
+ // to make them count as different hostnames per
667
+ // _parseHostname's URL-string check.
668
+ var tmpReceivedAtA = null;
669
+ var tmpReceivedAtB = null;
670
+ var tmpServerB = libHTTP.createServer(function (pReq, pRes)
671
+ {
672
+ tmpReceivedAtB = pReq.headers;
673
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
674
+ pRes.end(JSON.stringify({ ok: true }));
675
+ });
676
+ tmpServerB.listen(0, function ()
677
+ {
678
+ var tmpPortB = tmpServerB.address().port;
679
+ var tmpServerA = libHTTP.createServer(function (pReq, pRes)
680
+ {
681
+ tmpReceivedAtA = pReq.headers;
682
+ pRes.writeHead(302, { Location: 'http://127.0.0.1:' + tmpPortB + '/dest' });
683
+ pRes.end();
684
+ });
685
+ tmpServerA.listen(0, function ()
686
+ {
687
+ var tmpPortA = tmpServerA.address().port;
688
+ var testFable = new libFable();
689
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-CrossOrigin');
690
+ tmpRestClient.getJSON({
691
+ url: 'http://localhost:' + tmpPortA + '/start',
692
+ headers: { cookie: 'session=abc', authorization: 'Bearer xyz' }
693
+ },
694
+ function (pError, pResponse, pBody)
695
+ {
696
+ Expect(pError).to.equal(null);
697
+ Expect(pBody.ok).to.equal(true);
698
+ Expect(tmpReceivedAtA.cookie).to.equal('session=abc');
699
+ Expect(tmpReceivedAtA.authorization).to.equal('Bearer xyz');
700
+ Expect(tmpReceivedAtB.cookie).to.equal(undefined);
701
+ Expect(tmpReceivedAtB.authorization).to.equal(undefined);
702
+ tmpServerA.close();
703
+ tmpServerB.close();
704
+ fTestComplete();
705
+ });
706
+ });
707
+ });
708
+ }
709
+ );
710
+ test
711
+ (
712
+ 'Converts POST to GET on a 301/302 redirect and drops the body.',
713
+ function (fTestComplete)
714
+ {
715
+ var tmpDestRequest = null;
716
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
717
+ {
718
+ if (pReq.url === '/start')
719
+ {
720
+ pRes.writeHead(301, { Location: '/dest' });
721
+ pRes.end();
722
+ return;
723
+ }
724
+ tmpDestRequest = { method: pReq.method, headers: pReq.headers };
725
+ var tmpBody = '';
726
+ pReq.on('data', function (pChunk) { tmpBody += pChunk; });
727
+ pReq.on('end', function ()
728
+ {
729
+ tmpDestRequest.body = tmpBody;
730
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
731
+ pRes.end(JSON.stringify({ ok: true }));
732
+ });
733
+ });
734
+ tmpServer.listen(0, function ()
735
+ {
736
+ var tmpPort = tmpServer.address().port;
737
+ var testFable = new libFable();
738
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-PostToGet');
739
+ tmpRestClient.postJSON({ url: 'http://localhost:' + tmpPort + '/start', body: { foo: 'bar' } },
740
+ function (pError, pResponse, pBody)
741
+ {
742
+ Expect(pError).to.equal(null);
743
+ Expect(pBody.ok).to.equal(true);
744
+ Expect(tmpDestRequest.method).to.equal('GET');
745
+ Expect(tmpDestRequest.body).to.equal('');
746
+ Expect(tmpDestRequest.headers['content-type']).to.equal(undefined);
747
+ Expect(tmpDestRequest.headers['content-length']).to.equal(undefined);
748
+ tmpServer.close();
749
+ fTestComplete();
750
+ });
751
+ });
752
+ }
753
+ );
754
+ test
755
+ (
756
+ 'Resolves a relative Location header against the current URL.',
757
+ function (fTestComplete)
758
+ {
759
+ // Server returns a relative Location ("dest" instead of "/dest").
760
+ // RFC 7231 allows this; the next hop's URL must be resolved
761
+ // against the URL that produced the redirect.
762
+ var tmpDestRequestURL = null;
763
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
764
+ {
765
+ if (pReq.url === '/section/start')
766
+ {
767
+ pRes.writeHead(302, { Location: 'dest' });
768
+ pRes.end();
769
+ return;
770
+ }
771
+ tmpDestRequestURL = pReq.url;
772
+ pRes.writeHead(200, { 'Content-Type': 'application/json' });
773
+ pRes.end(JSON.stringify({ ok: true }));
774
+ });
775
+ tmpServer.listen(0, function ()
776
+ {
777
+ var tmpPort = tmpServer.address().port;
778
+ var testFable = new libFable();
779
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-RelativeLocation');
780
+ tmpRestClient.getJSON('http://localhost:' + tmpPort + '/section/start',
781
+ function (pError, pResponse, pBody)
782
+ {
783
+ Expect(pError).to.equal(null);
784
+ Expect(pBody.ok).to.equal(true);
785
+ Expect(tmpDestRequestURL).to.equal('/section/dest');
786
+ tmpServer.close();
787
+ fTestComplete();
788
+ });
789
+ });
790
+ }
791
+ );
792
+ test
793
+ (
794
+ 'Honors followRedirects: false by handing the 3xx straight back.',
795
+ function (fTestComplete)
796
+ {
797
+ // When the caller opts out, we shouldn't follow the redirect —
798
+ // the JSON parser will fail because the 3xx body is empty,
799
+ // which is the expected pre-fix behavior.
800
+ var tmpServer = libHTTP.createServer(function (pReq, pRes)
801
+ {
802
+ pRes.writeHead(302, { Location: '/dest' });
803
+ pRes.end();
804
+ });
805
+ tmpServer.listen(0, function ()
806
+ {
807
+ var tmpPort = tmpServer.address().port;
808
+ var testFable = new libFable();
809
+ var tmpRestClient = testFable.instantiateServiceProvider('RestClient', {}, 'RestClient-Redirect-OptOut');
810
+ tmpRestClient.getJSON({ url: 'http://localhost:' + tmpPort + '/start', followRedirects: false },
811
+ function (pError, pResponse)
812
+ {
813
+ Expect(pResponse.statusCode).to.equal(302);
814
+ Expect(pResponse.headers.location).to.equal('/dest');
815
+ tmpServer.close();
816
+ fTestComplete();
817
+ });
818
+ });
819
+ }
820
+ );
821
+ }
822
+ );
823
+
482
824
  suite
483
825
  (
484
826
  'Error Handling',