diff --git a/.eslintignore b/.eslintignore index 86e9501ee..5675bac71 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,2 @@ lib - +docs/_build diff --git a/docs/data-structures.md b/docs/data-structures.md index d2f0d899c..524d38aa2 100644 --- a/docs/data-structures.md +++ b/docs/data-structures.md @@ -24,6 +24,9 @@ Transaction object is passed as a first argument to [hook functions](hooks.md) a - fullPath: `/message` (string) - expanded [URI Template][] with parameters (if any) used for the HTTP request Dredd performs to the tested server - request (object) - the HTTP request Dredd performs to the tested server, taken from the API description - body: `Hello world!\n` (string) + - bodyEncoding (enum) - can be manually set in [hooks](hooks.md) + - `utf-8` (string) - indicates `body` contains a textual content encoded in UTF-8 + - `base64` (string) - indicates `body` contains a binary content encoded in Base64 - headers (object) - keys are HTTP header names, values are HTTP header contents - uri: `/message` (string) - request URI as it was written in API description - method: `POST` (string) @@ -36,6 +39,9 @@ Transaction object is passed as a first argument to [hook functions](hooks.md) a - statusCode: `200` (string) - headers (object) - keys are HTTP header names, values are HTTP header contents - body (string) + - bodyEncoding (enum) + - `utf-8` (string) - indicates `body` contains a textual content encoded in UTF-8 + - `base64` (string) - indicates `body` contains a binary content encoded in Base64 - skip: `false` (boolean) - can be set to `true` and the transaction will be skipped - fail: `false` (enum) - can be set to `true` or string and the transaction will fail - (string) - failure message with details why the transaction failed diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 93e283a77..d342f5945 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -154,7 +154,7 @@ If there is no body example or schema specified for the response in your API des If you want to enforce the incoming body is empty, you can use [hooks](hooks.md): -```js +```javascript :[hooks example](../test/fixtures/response/empty-body-hooks.js) ``` diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index b5d5a2268..ade55a791 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -523,6 +523,62 @@ Most of the authentication schemes use HTTP header for carrying the authenticati :[Swagger example](../test/fixtures/request/application-x-www-form-urlencoded.yaml) ``` +## Working with Images and other Binary Bodies + +The API description formats generally do not provide a way to describe binary content. The easiest solution is to describe only the media type, to [leave out the body](how-it-works.md#empty-response-body), and to handle the rest using [hooks](hooks.md). + +### Binary Request Body + +#### API Blueprint + +```apiblueprint +:[API Blueprint example](../test/fixtures/request/image-png.apib) +``` + +#### Swagger + +```yaml +:[Swagger example](../test/fixtures/request/image-png.yaml) +``` + +#### Hooks + +In hooks, you can populate the request body with real binary data. The data must be in a form of a [Base64-encoded](https://en.wikipedia.org/wiki/Base64) string. + +```javascript +:[Hooks example](../test/fixtures/request/image-png-hooks.js) +``` + +### Binary Response Body + +#### API Blueprint + +```apiblueprint +:[API Blueprint example](../test/fixtures/response/binary.apib) +``` + +#### Swagger + +```yaml +:[Swagger example](../test/fixtures/response/binary.yaml) +``` + +> **Note:** Do not use the explicit `binary` or `bytes` formats with response bodies, as Dredd is not able to properly work with those ([fury-adapter-swagger#193](https://github.com/apiaryio/fury-adapter-swagger/issues/193)). + +### Hooks + +In hooks, you can either assert the body: + +```javascript +:[Hooks example](../test/fixtures/response/binary-assert-body-hooks.js) +``` + +Or you can ignore it: + +```javascript +:[Hooks example](../test/fixtures/response/binary-ignore-body-hooks.js) +``` + ## Multiple Requests and Responses > **Note:** For details on this topic see also [How Dredd Works With HTTP Transactions](how-it-works.md#choosing-http-transactions). diff --git a/package-lock.json b/package-lock.json index 781ab2138..f016f2bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2624,9 +2624,9 @@ "integrity": "sha512-bve7maXvfKW+vcsRpP8gzEDzkTg8O6AoCGvi/52pnllzhl/nmex8XLrHOUEQ42Z8GshcyftvG+E4s5vcd/qo0Q==" }, "dredd-transactions": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/dredd-transactions/-/dredd-transactions-6.1.3.tgz", - "integrity": "sha512-cNuoU83aYpFd47dwcUaqqaZo8cEU7C/sDlJaLERxU27XsI1YoqDrh+5p9EmzsPIzRu836GCdhESm0RBVbfHMxA==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/dredd-transactions/-/dredd-transactions-6.1.5.tgz", + "integrity": "sha512-DWiqzXx5nAqBYSf/tv4ha0LQoFOTknhgkI53tgSV/xIUnpz+S3ZFu7rgkTVXBrHpcfsg87B0aieIfE95mCki0w==", "requires": { "clone": "2.1.1", "fury": "3.0.0-beta.7", @@ -7629,9 +7629,9 @@ }, "dependencies": { "escodegen": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.10.0.tgz", - "integrity": "sha512-fjUOf8johsv23WuIKdNQU4P9t9jhQ4Qzx6pC2uW890OloK3Zs1ZAoCNpg/2larNF501jLl3UNy0kIRcF6VI22g==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.11.0.tgz", + "integrity": "sha512-IeMV45ReixHS53K/OmfKAIztN/igDHzTJUhZM3k1jMhIZWjk45SMwAtBsEXiJp3vSPmTcu6CXn7mDvFHRN66fw==", "requires": { "esprima": "^3.1.3", "estraverse": "^4.2.0", diff --git a/package.json b/package.json index b42ef84c1..16c113f53 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "clone": "2.1.1", "coffeescript": "1.12.7", "cross-spawn": "6.0.5", - "dredd-transactions": "6.1.3", + "dredd-transactions": "6.1.5", "file": "0.2.2", "fs-extra": "6.0.1", "gavel": "2.1.2", diff --git a/src/performRequest.js b/src/performRequest.js new file mode 100644 index 000000000..a4f7af8b6 --- /dev/null +++ b/src/performRequest.js @@ -0,0 +1,162 @@ +const defaultRequest = require('request'); +const caseless = require('caseless'); + +const defaultLogger = require('./logger'); + + +/** + * Performs the HTTP request as described in the 'transaction.request' object. + * + * In future we should introduce a 'real' request object as well so user has + * access to the modifications made on the way. + * + * @param {string} uri + * @param {Object} transactionReq + * @param {Object} [options] + * @param {Object} [options.logger] Custom logger + * @param {Object} [options.request] Custom 'request' library implementation + * @param {Object} [options.http] Custom default 'request' library options + * @param {Function} callback + */ +function performRequest(uri, transactionReq, options, callback) { + if (typeof options === 'function') { + [options, callback] = [{}, options]; + } + const logger = options.logger || defaultLogger; + const request = options.request || defaultRequest; + + const httpOptions = Object.assign({}, options.http || {}); + httpOptions.proxy = false; + httpOptions.followRedirect = false; + httpOptions.encoding = null; + httpOptions.method = transactionReq.method; + httpOptions.uri = uri; + + try { + httpOptions.body = getBodyAsBuffer(transactionReq.body, transactionReq.bodyEncoding); + httpOptions.headers = normalizeContentLengthHeader(transactionReq.headers, httpOptions.body); + + const protocol = httpOptions.uri.split(':')[0].toUpperCase(); + logger.debug(`Performing ${protocol} request to the server under test: ` + + `${httpOptions.method} ${httpOptions.uri}`); + + request(httpOptions, (error, res, resBody) => { + logger.debug(`Handling ${protocol} response from the server under test`); + if (error) { + callback(error); + } else { + callback(null, createTransactionRes(res, resBody)); + } + }); + } catch (error) { + process.nextTick(() => callback(error)); + } +} + + +/** + * Coerces the HTTP request body to a Buffer + * + * @param {string|Buffer} body + * @param {*} encoding + */ +function getBodyAsBuffer(body, encoding) { + return body instanceof Buffer + ? body + : Buffer.from(`${body || ''}`, normalizeBodyEncoding(encoding)); +} + + +/** + * Returns the encoding as either 'utf-8' or 'base64'. Throws + * an error in case any other encoding is provided. + * + * @param {string} encoding + */ +function normalizeBodyEncoding(encoding) { + if (!encoding) { return 'utf-8'; } + + switch (encoding.toLowerCase()) { + case 'utf-8': + case 'utf8': + return 'utf-8'; + case 'base64': + return 'base64'; + default: + throw new Error(`Unsupported encoding: '${encoding}' (only UTF-8 and ` + + 'Base64 are supported)'); + } +} + + +/** + * Detects an existing Content-Length header and overrides the user-provided + * header value in case it's out of sync with the real length of the body. + * + * @param {Object} headers HTTP request headers + * @param {Buffer} body HTTP request body + * @param {Object} [options] + * @param {Object} [options.logger] Custom logger + */ +function normalizeContentLengthHeader(headers, body, options = {}) { + const logger = options.logger || defaultLogger; + + const modifiedHeaders = Object.assign({}, headers); + const calculatedValue = Buffer.byteLength(body); + const name = caseless(modifiedHeaders).has('Content-Length'); + if (name) { + const value = parseInt(modifiedHeaders[name], 10); + if (value !== calculatedValue) { + modifiedHeaders[name] = `${calculatedValue}`; + logger.warn(`Specified Content-Length header is ${value}, but the real ` + + `body length is ${calculatedValue}. Using ${calculatedValue} instead.`); + } + } else { + modifiedHeaders['Content-Length'] = `${calculatedValue}`; + } + return modifiedHeaders; +} + + +/** + * Real transaction response object factory. Serializes binary responses + * to string using Base64 encoding. + * + * @param {Object} res Node.js HTTP response + * @param {Buffer} body HTTP response body as Buffer + */ +function createTransactionRes(res, body) { + const transactionRes = { + statusCode: res.statusCode, + headers: Object.assign({}, res.headers) + }; + if (Buffer.byteLength(body || '')) { + transactionRes.bodyEncoding = detectBodyEncoding(body); + transactionRes.body = body.toString(transactionRes.bodyEncoding); + } + return transactionRes; +} + + +/** + * @param {Buffer} body + */ +function detectBodyEncoding(body) { + // U+FFFD is a replacement character in UTF-8 and indicates there + // are some bytes which could not been translated as UTF-8. Therefore + // let's assume the body is in binary format. Dredd encodes binary as + // Base64 to be able to transfer it wrapped in JSON over the TCP to non-JS + // hooks implementations. + return body.toString().includes('\ufffd') ? 'base64' : 'utf-8'; +} + + +// only for the purpose of unit tests +performRequest._normalizeBodyEncoding = normalizeBodyEncoding; +performRequest._getBodyAsBuffer = getBodyAsBuffer; +performRequest._normalizeContentLengthHeader = normalizeContentLengthHeader; +performRequest._createTransactionRes = createTransactionRes; +performRequest._detectBodyEncoding = detectBodyEncoding; + + +module.exports = performRequest; diff --git a/src/transaction-runner.js b/src/transaction-runner.js index cbdf1cc71..8db8036c1 100644 --- a/src/transaction-runner.js +++ b/src/transaction-runner.js @@ -1,10 +1,8 @@ const async = require('async'); -const caseless = require('caseless'); const chai = require('chai'); const clone = require('clone'); const gavel = require('gavel'); const os = require('os'); -const requestLib = require('request'); const url = require('url'); const { Pitboss } = require('pitboss-ng'); @@ -12,6 +10,8 @@ const addHooks = require('./add-hooks'); const logger = require('./logger'); const packageData = require('./../package.json'); const sortTransactions = require('./sort-transactions'); +const performRequest = require('./performRequest'); + function headersArrayToObject(arr) { return Array.from(arr).reduce((result, currentItem) => { @@ -549,23 +549,6 @@ Interface of the hooks functions will be unified soon across all hook functions: this.error = this.error || error; } - getRequestOptionsFromTransaction(transaction) { - const urlObject = { - protocol: transaction.protocol, - hostname: transaction.host, - port: transaction.port - }; - - const options = clone(this.configuration.http || {}); - options.uri = url.format(urlObject) + transaction.fullPath; - options.method = transaction.request.method; - options.headers = transaction.request.headers; - options.body = transaction.request.body; - options.proxy = false; - options.followRedirect = false; - return options; - } - // This is actually doing more some pre-flight and conditional skipping of // the transcation based on the configuration or hooks. TODO rename executeTransaction(transaction, hooks, callback) { @@ -621,69 +604,28 @@ Not performing HTTP request for '${transaction.name}'.\ this.performRequestAndValidate(test, transaction, hooks, callback); } - // Sets the Content-Length header. Overrides user-provided Content-Length - // header value in case it's out of sync with the real length of the body. - setContentLength(transaction) { - const { headers } = transaction.request; - const { body } = transaction.request; - - const contentLengthHeaderName = caseless(headers).has('Content-Length'); - if (contentLengthHeaderName) { - const contentLengthValue = parseInt(headers[contentLengthHeaderName], 10); - - if (body) { - const calculatedContentLengthValue = Buffer.byteLength(body); - if (contentLengthValue !== calculatedContentLengthValue) { - logger.warn(`\ -Specified Content-Length header is ${contentLengthValue}, but \ -the real body length is ${calculatedContentLengthValue}. Using \ -${calculatedContentLengthValue} instead.\ -`); - headers[contentLengthHeaderName] = calculatedContentLengthValue; - } - } else if (contentLengthValue !== 0) { - logger.warn(`\ -Specified Content-Length header is ${contentLengthValue}, but \ -the real body length is 0. Using 0 instead.\ -`); - headers[contentLengthHeaderName] = 0; - } - } else { - headers['Content-Length'] = body ? Buffer.byteLength(body) : 0; - } - } - // An actual HTTP request, before validation hooks triggering // and the response validation is invoked here performRequestAndValidate(test, transaction, hooks, callback) { - if (transaction.request.body && this.isMultipart(transaction.request.headers)) { - transaction.request.body = this.fixApiBlueprintMultipartBody(transaction.request.body); - } - - this.setContentLength(transaction); - const requestOptions = this.getRequestOptionsFromTransaction(transaction); + const uri = url.format({ + protocol: transaction.protocol, + hostname: transaction.host, + port: transaction.port + }) + transaction.fullPath; + const options = { http: this.configuration.http }; - const handleRequest = (err, res, body) => { - if (err) { - logger.debug('Requesting tested server errored:', `${err}` || err.code); + performRequest(uri, transaction.request, options, (error, real) => { + if (error) { + logger.debug('Requesting tested server errored:', error); test.title = transaction.id; test.expected = transaction.expected; test.request = transaction.request; - this.emitError(err, test); + this.emitError(error, test); return callback(); } + transaction.real = real; - logger.verbose('Handling HTTP response from tested server'); - - // The data models as used here must conform to Gavel.js as defined in 'http-response.coffee' - transaction.real = { - statusCode: res.statusCode, - headers: res.headers - }; - - if (body) { - transaction.real.body = body; - } else if (transaction.expected.body) { + if (!transaction.real.body && transaction.expected.body) { // Leaving body as undefined skips its validation completely. In case // there is no real body, but there is one expected, the empty string // ensures Gavel does the validation. @@ -701,27 +643,7 @@ the real body length is 0. Using 0 instead.\ this.validateTransaction(test, transaction, callback); }); }); - }; - - try { - this.performRequest(requestOptions, handleRequest); - } catch (error) { - logger.debug('Requesting tested server errored:', error); - test.title = transaction.id; - test.expected = transaction.expected; - test.request = transaction.request; - this.emitError(error, test); - callback(); - } - } - - performRequest(options, callback) { - const protocol = options.uri.split(':')[0].toUpperCase(); - logger.verbose(`\ -About to perform an ${protocol} request to the server \ -under test: ${options.method} ${options.uri}\ -`); - requestLib(options, callback); + }); } validateTransaction(test, transaction, callback) { @@ -821,22 +743,6 @@ include a message body: https://tools.ietf.org/html/rfc7231#section-6.3\ }); }); } - - isMultipart(headers) { - const contentType = caseless(headers).get('Content-Type'); - if (contentType) { - return contentType.indexOf('multipart') > -1; - } - return false; - } - - // Finds newlines not preceeded by carriage returns and replaces them by - // newlines preceeded by carriage returns. - // - // See https://github.com/apiaryio/api-blueprint/issues/401 - fixApiBlueprintMultipartBody(body) { - return body.replace(/\r?\n/g, '\r\n'); - } } module.exports = TransactionRunner; diff --git a/test/fixtures/image.png b/test/fixtures/image.png new file mode 100644 index 000000000..4b8991ea7 Binary files /dev/null and b/test/fixtures/image.png differ diff --git a/test/fixtures/request/application-octet-stream-hooks.js b/test/fixtures/request/application-octet-stream-hooks.js new file mode 100644 index 000000000..9000f2733 --- /dev/null +++ b/test/fixtures/request/application-octet-stream-hooks.js @@ -0,0 +1,7 @@ +const hooks = require('hooks'); + +hooks.beforeEach((transaction, done) => { + transaction.request.body = Buffer.from([0xFF, 0xEF, 0xBF, 0xBE]).toString('base64'); + transaction.request.bodyEncoding = 'base64'; + done(); +}); diff --git a/test/fixtures/request/application-octet-stream.apib b/test/fixtures/request/application-octet-stream.apib new file mode 100644 index 000000000..b4687f14a --- /dev/null +++ b/test/fixtures/request/application-octet-stream.apib @@ -0,0 +1,14 @@ +FORMAT: 1A + +# Binary API + +## Resource [/binary] + +### Send Binary Data [POST] + ++ Request (application/octet-stream) + ++ Response 200 (application/json; charset=utf-8) + + Body + + {"test": "OK"} diff --git a/test/fixtures/request/application-octet-stream.yaml b/test/fixtures/request/application-octet-stream.yaml new file mode 100644 index 000000000..cbea921c4 --- /dev/null +++ b/test/fixtures/request/application-octet-stream.yaml @@ -0,0 +1,26 @@ +swagger: "2.0" +info: + version: "1.0" + title: Binary API +schemes: + - http +consumes: + - application/octet-stream +produces: + - application/json +paths: + /binary: + post: + parameters: + - name: binary + in: body + required: true + schema: + type: string + format: binary + responses: + 200: + description: 'Test OK' + examples: + application/json; charset=utf-8: + test: 'OK' diff --git a/test/fixtures/request/image-png-hooks.js b/test/fixtures/request/image-png-hooks.js new file mode 100644 index 000000000..a58146b94 --- /dev/null +++ b/test/fixtures/request/image-png-hooks.js @@ -0,0 +1,10 @@ +const hooks = require('hooks'); +const fs = require('fs'); +const path = require('path'); + +hooks.beforeEach((transaction, done) => { + const buffer = fs.readFileSync(path.join(__dirname, '../image.png')); + transaction.request.body = buffer.toString('base64'); + transaction.request.bodyEncoding = 'base64'; + done(); +}); diff --git a/test/fixtures/request/image-png.apib b/test/fixtures/request/image-png.apib new file mode 100644 index 000000000..d4e0b0400 --- /dev/null +++ b/test/fixtures/request/image-png.apib @@ -0,0 +1,15 @@ +FORMAT: 1A + +# Images API + +## Resource [/image.png] + +### Send an Image [PUT] + ++ Request (image/png) + ++ Response 200 (application/json; charset=utf-8) + + Body + + {"test": "OK"} + diff --git a/test/fixtures/request/image-png.yaml b/test/fixtures/request/image-png.yaml new file mode 100644 index 000000000..8d6765a7c --- /dev/null +++ b/test/fixtures/request/image-png.yaml @@ -0,0 +1,26 @@ +swagger: "2.0" +info: + version: "1.0" + title: Images API +schemes: + - http +consumes: + - image/png +produces: + - application/json +paths: + /image.png: + put: + parameters: + - name: binary + in: body + required: true + schema: + type: string + format: binary + responses: + 200: + description: 'Test OK' + examples: + application/json; charset=utf-8: + test: 'OK' diff --git a/test/fixtures/response/binary-assert-body-hooks.js b/test/fixtures/response/binary-assert-body-hooks.js new file mode 100644 index 000000000..5ecb43267 --- /dev/null +++ b/test/fixtures/response/binary-assert-body-hooks.js @@ -0,0 +1,9 @@ +const hooks = require('hooks'); +const fs = require('fs'); +const path = require('path'); + +hooks.beforeEachValidation((transaction, done) => { + const bytes = fs.readFileSync(path.join(__dirname, '../image.png')); + transaction.expected.body = bytes.toString('base64'); + done(); +}); diff --git a/test/fixtures/response/binary-ignore-body-hooks.js b/test/fixtures/response/binary-ignore-body-hooks.js new file mode 100644 index 000000000..eb5c5a5b2 --- /dev/null +++ b/test/fixtures/response/binary-ignore-body-hooks.js @@ -0,0 +1,6 @@ +const hooks = require('hooks'); + +hooks.beforeEachValidation((transaction, done) => { + transaction.real.body = ''; + done(); +}); diff --git a/test/fixtures/response/binary.apib b/test/fixtures/response/binary.apib new file mode 100644 index 000000000..e0d03006c --- /dev/null +++ b/test/fixtures/response/binary.apib @@ -0,0 +1,9 @@ +FORMAT: 1A + +# Images API + +## Resource [/image.png] + +### Retrieve Representation [GET] + ++ Response 200 (image/png) diff --git a/test/fixtures/response/binary.yaml b/test/fixtures/response/binary.yaml new file mode 100644 index 000000000..4b69f8ad8 --- /dev/null +++ b/test/fixtures/response/binary.yaml @@ -0,0 +1,16 @@ +swagger: "2.0" +info: + version: "1.0" + title: Images API +schemes: + - http +produces: + - image/png +paths: + /image.png: + get: + responses: + 200: + description: Representation + examples: + "image/png": "" diff --git a/test/integration/request-test.js b/test/integration/request-test.js index 92f1064cf..a58c4de0b 100644 --- a/test/integration/request-test.js +++ b/test/integration/request-test.js @@ -1,5 +1,7 @@ const bodyParser = require('body-parser'); const { assert } = require('chai'); +const fs = require('fs'); +const path = require('path'); const { runDreddWithServer, createServer } = require('./helpers'); const Dredd = require('../../src/dredd'); @@ -12,8 +14,7 @@ describe('Sending \'application/json\' request', () => { const app = createServer({ bodyParser: bodyParser.text({ type: contentType }) }); app.post('/data', (req, res) => res.json({ test: 'OK' })); - const path = './test/fixtures/request/application-json.apib'; - const dredd = new Dredd({ options: { path } }); + const dredd = new Dredd({ options: { path: './test/fixtures/request/application-json.apib' } }); runDreddWithServer(dredd, app, (err, info) => { runtimeInfo = info; @@ -132,11 +133,9 @@ describe('Sending \'text/plain\' request', () => { const contentType = 'text/plain'; before((done) => { - const path = './test/fixtures/request/text-plain.apib'; - const app = createServer({ bodyParser: bodyParser.text({ type: contentType }) }); app.post('/data', (req, res) => res.json({ test: 'OK' })); - const dredd = new Dredd({ options: { path } }); + const dredd = new Dredd({ options: { path: './test/fixtures/request/text-plain.apib' } }); runDreddWithServer(dredd, app, (err, info) => { runtimeInfo = info; @@ -152,3 +151,101 @@ describe('Sending \'text/plain\' request', () => { assert.equal(runtimeInfo.dredd.stats.passes, 1); }); }); + +[ + { + name: 'API Blueprint', + path: './test/fixtures/request/application-octet-stream.apib' + }, + { + name: 'Swagger', + path: './test/fixtures/request/application-octet-stream.yaml' + } +].forEach(apiDescription => + describe(`Sending 'application/octet-stream' request described in ${apiDescription.name}`, () => { + let runtimeInfo; + const contentType = 'application/octet-stream'; + + before((done) => { + const app = createServer({ bodyParser: bodyParser.raw({ type: contentType }) }); + app.post('/binary', (req, res) => res.json({ test: 'OK' })); + + const dredd = new Dredd({ + options: { + path: apiDescription.path, + hookfiles: './test/fixtures/request/application-octet-stream-hooks.js' + } + }); + runDreddWithServer(dredd, app, (err, info) => { + runtimeInfo = info; + done(err); + }); + }); + + it('results in one request being delivered to the server', () => + assert.isTrue(runtimeInfo.server.requestedOnce) + ); + it('the request has the expected Content-Type', () => + assert.equal(runtimeInfo.server.lastRequest.headers['content-type'], contentType) + ); + it('the request has the expected format', () => + assert.equal( + runtimeInfo.server.lastRequest.body.toString('base64'), + Buffer.from([0xFF, 0xEF, 0xBF, 0xBE]).toString('base64') + ) + ); + it('results in one passing test', () => { + assert.equal(runtimeInfo.dredd.stats.tests, 1); + assert.equal(runtimeInfo.dredd.stats.passes, 1); + }); + }) +); + +[ + { + name: 'API Blueprint', + path: './test/fixtures/request/image-png.apib' + }, + { + name: 'Swagger', + path: './test/fixtures/request/image-png.yaml' + } +].forEach(apiDescription => + describe(`Sending 'image/png' request described in ${apiDescription.name}`, () => { + let runtimeInfo; + const contentType = 'image/png'; + + before((done) => { + const app = createServer({ bodyParser: bodyParser.raw({ type: contentType }) }); + app.put('/image.png', (req, res) => res.json({ test: 'OK' })); + + const dredd = new Dredd({ + options: { + path: apiDescription.path, + hookfiles: './test/fixtures/request/image-png-hooks.js' + } + }); + runDreddWithServer(dredd, app, (err, info) => { + runtimeInfo = info; + done(err); + }); + }); + + it('results in one request being delivered to the server', () => + assert.isTrue(runtimeInfo.server.requestedOnce) + ); + it('the request has the expected Content-Type', () => + assert.equal(runtimeInfo.server.lastRequest.headers['content-type'], contentType) + ); + it('the request has the expected format', () => + assert.equal( + runtimeInfo.server.lastRequest.body.toString('base64'), + fs.readFileSync(path.join(__dirname, '../fixtures/image.png')).toString('base64') + ) + ); + it('results in one passing test', () => { + assert.equal(runtimeInfo.dredd.stats.tests, 1); + assert.equal(runtimeInfo.dredd.stats.passes, 1); + }); + }) +); diff --git a/test/integration/response-test.js b/test/integration/response-test.js index fd32509c0..4aea1cfb7 100644 --- a/test/integration/response-test.js +++ b/test/integration/response-test.js @@ -1,4 +1,5 @@ const { assert } = require('chai'); +const path = require('path'); const { runDreddWithServer, createServer } = require('./helpers'); const Dredd = require('../../src/dredd'); @@ -288,3 +289,64 @@ const Dredd = require('../../src/dredd'); }); }) ); + +[ + { + name: 'API Blueprint', + path: './test/fixtures/response/binary.apib' + }, + { + name: 'Swagger', + path: './test/fixtures/response/binary.yaml' + } +].forEach(apiDescription => + describe(`Working with binary responses in the ${apiDescription.name}`, () => { + const imagePath = path.join(__dirname, '../fixtures/image.png'); + const app = createServer(); + app.get('/image.png', (req, res) => + res.type('image/png').sendFile(imagePath) + ); + + describe('when the body is described as empty and there are hooks to remove the real body', () => { + let runtimeInfo; + + before((done) => { + const dredd = new Dredd({ + options: { + path: apiDescription.path, + hookfiles: './test/fixtures/response/binary-ignore-body-hooks.js' + } + }); + runDreddWithServer(dredd, app, (err, info) => { + runtimeInfo = info; + done(err); + }); + }); + + it('evaluates the response as valid', () => + assert.deepInclude(runtimeInfo.dredd.stats, { tests: 1, passes: 1 }) + ); + }); + + describe('when the body is described as empty and there are hooks to assert the real body', () => { + let runtimeInfo; + + before((done) => { + const dredd = new Dredd({ + options: { + path: apiDescription.path, + hookfiles: './test/fixtures/response/binary-assert-body-hooks.js' + } + }); + runDreddWithServer(dredd, app, (err, info) => { + runtimeInfo = info; + done(err); + }); + }); + + it('evaluates the response as valid', () => + assert.deepInclude(runtimeInfo.dredd.stats, { tests: 1, passes: 1 }) + ); + }); + }) +); diff --git a/test/unit/performRequest/createTransactionRes-test.js b/test/unit/performRequest/createTransactionRes-test.js new file mode 100644 index 000000000..8c03c7d9c --- /dev/null +++ b/test/unit/performRequest/createTransactionRes-test.js @@ -0,0 +1,50 @@ +const { assert } = require('chai'); + +const { + _createTransactionRes: createTransactionRes +} = require('../../../src/performRequest'); + + +describe('performRequest.createTransactionRes()', () => { + const res = { statusCode: 200, headers: {} }; + + it('sets the status code', () => + assert.deepEqual( + createTransactionRes(res), + { statusCode: 200, headers: {} } + ) + ); + it('copies the headers', () => { + const headers = { 'Content-Type': 'application/json' }; + const transactionRes = createTransactionRes({ statusCode: 200, headers }); + headers['X-Header'] = 'abcd'; + + assert.deepEqual( + transactionRes, + { statusCode: 200, headers: { 'Content-Type': 'application/json' } } + ); + }); + it('does not set empty body', () => + assert.deepEqual( + createTransactionRes(res, Buffer.from([])), + { statusCode: 200, headers: {} } + ) + ); + it('sets textual body as a string with UTF-8 encoding', () => + assert.deepEqual( + createTransactionRes(res, Buffer.from('řeřicha')), + { statusCode: 200, headers: {}, body: 'řeřicha', bodyEncoding: 'utf-8' } + ) + ); + it('sets binary body as a string with Base64 encoding', () => + assert.deepEqual( + createTransactionRes(res, Buffer.from([0xFF, 0xBE])), + { + statusCode: 200, + headers: {}, + body: Buffer.from([0xFF, 0xBE]).toString('base64'), + bodyEncoding: 'base64' + } + ) + ); +}); diff --git a/test/unit/performRequest/detectBodyEncoding-test.js b/test/unit/performRequest/detectBodyEncoding-test.js new file mode 100644 index 000000000..06bd10517 --- /dev/null +++ b/test/unit/performRequest/detectBodyEncoding-test.js @@ -0,0 +1,27 @@ +const { assert } = require('chai'); + +const { + _detectBodyEncoding: detectBodyEncoding +} = require('../../../src/performRequest'); + + +describe('performRequest.detectBodyEncoding()', () => { + it('detects binary content as Base64', () => + assert.equal( + detectBodyEncoding(Buffer.from([0xFF, 0xEF, 0xBF, 0xBE])), + 'base64' + ) + ); + it('detects textual content as UTF-8', () => + assert.equal( + detectBodyEncoding(Buffer.from('řeřicha')), + 'utf-8' + ) + ); + it('detects no content as UTF-8', () => + assert.equal( + detectBodyEncoding(Buffer.from([])), + 'utf-8' + ) + ); +}); diff --git a/test/unit/performRequest/getBodyAsBuffer-test.js b/test/unit/performRequest/getBodyAsBuffer-test.js new file mode 100644 index 000000000..c4341d2f1 --- /dev/null +++ b/test/unit/performRequest/getBodyAsBuffer-test.js @@ -0,0 +1,53 @@ +const { assert } = require('chai'); + +const { + _getBodyAsBuffer: getBodyAsBuffer +} = require('../../../src/performRequest'); + + +describe('performRequest.getBodyAsBuffer()', () => { + describe('when the body is a Buffer', () => { + it('returns the body unmodified', () => { + const body = Buffer.from([0xFF, 0xEF, 0xBF, 0xBE]); + assert.equal(getBodyAsBuffer(body), body); + }); + it('ignores encoding', () => { + const body = Buffer.from([0xFF, 0xEF, 0xBF, 0xBE]); + assert.equal(getBodyAsBuffer(body, 'utf-8'), body); + }); + }); + + [undefined, null, ''].forEach((body) => { + describe(`when the body is ${JSON.stringify(body)}`, () => { + it('returns empty Buffer without encoding', () => + assert.deepEqual(getBodyAsBuffer(body), Buffer.from([])) + ); + it('returns empty Buffer with encoding set to UTF-8', () => + assert.deepEqual(getBodyAsBuffer(body, 'utf-8'), Buffer.from([])) + ); + it('returns empty Buffer with encoding set to Base64', () => + assert.deepEqual(getBodyAsBuffer(body, 'base64'), Buffer.from([])) + ); + }); + }); + + describe('when the body is neither Buffer or string', () => { + it('gracefully stringifies the input', () => { + const body = new Error('Ouch!'); + assert.deepEqual(getBodyAsBuffer(body), Buffer.from('Error: Ouch!')); + }); + }); + + describe('when the body is a string', () => { + it('assumes UTF-8 without encoding', () => + assert.deepEqual(getBodyAsBuffer('abc'), Buffer.from('abc')) + ); + it('respects encoding set to UTF-8', () => + assert.deepEqual(getBodyAsBuffer('abc', 'utf-8'), Buffer.from('abc')) + ); + it('respects encoding set to Base64', () => { + const body = Buffer.from('abc').toString('base64'); + assert.deepEqual(getBodyAsBuffer(body, 'base64'), Buffer.from('abc')); + }); + }); +}); diff --git a/test/unit/performRequest/normalizeBodyEncoding-test.js b/test/unit/performRequest/normalizeBodyEncoding-test.js new file mode 100644 index 000000000..89f0f2257 --- /dev/null +++ b/test/unit/performRequest/normalizeBodyEncoding-test.js @@ -0,0 +1,29 @@ +const { assert } = require('chai'); + +const { + _normalizeBodyEncoding: normalizeBodyEncoding +} = require('../../../src/performRequest'); + + +describe('performRequest.normalizeBodyEncoding()', () => { + ['utf-8', 'utf8', 'UTF-8', 'UTF8'].forEach(value => + it(`normalizes ${JSON.stringify(value)} to utf-8`, () => + assert.equal(normalizeBodyEncoding(value), 'utf-8') + ) + ); + ['base64', 'Base64'].forEach(value => + it(`normalizes ${JSON.stringify(value)} to base64`, () => + assert.equal(normalizeBodyEncoding(value), 'base64') + ) + ); + [undefined, null, '', false].forEach(value => + it(`defaults ${JSON.stringify(value)} to utf-8`, () => + assert.equal(normalizeBodyEncoding(value), 'utf-8') + ) + ); + it('throws an error on "latin2"', () => + assert.throws(() => { + normalizeBodyEncoding('latin2'); + }, /^unsupported encoding/i) + ); +}); diff --git a/test/unit/performRequest/normalizeContentLengthHeader-test.js b/test/unit/performRequest/normalizeContentLengthHeader-test.js new file mode 100644 index 000000000..a5fb7e910 --- /dev/null +++ b/test/unit/performRequest/normalizeContentLengthHeader-test.js @@ -0,0 +1,125 @@ +const sinon = require('sinon'); +const { assert } = require('chai'); + +const { + _normalizeContentLengthHeader: normalizeContentLengthHeader +} = require('../../../src/performRequest'); + + +describe('performRequest.normalizeContentLengthHeader()', () => { + let headers; + + const logger = { warn: sinon.spy() }; + beforeEach(() => logger.warn.reset()); + + describe('when there is no body and no Content-Length', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({}, Buffer.from(''), { logger }); + }); + + it('does not warn', () => + assert.isFalse(logger.warn.called) + ); + it('has the Content-Length header set to 0', () => + assert.deepPropertyVal(headers, 'Content-Length', '0') + ); + }); + + describe('when there is no body and the Content-Length is set to 0', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({ + 'Content-Length': '0' + }, Buffer.from(''), { logger }); + }); + + it('does not warn', () => + assert.isFalse(logger.warn.called) + ); + it('has the Content-Length header set to 0', () => + assert.deepPropertyVal(headers, 'Content-Length', '0') + ); + }); + + describe('when there is body and the Content-Length is not set', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({}, Buffer.from('abcd'), { logger }); + }); + + it('does not warn', () => + assert.isFalse(logger.warn.called) + ); + it('has the Content-Length header set to 4', () => + assert.deepPropertyVal(headers, 'Content-Length', '4') + ); + }); + + describe('when there is body and the Content-Length is correct', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({ + 'Content-Length': '4' + }, Buffer.from('abcd'), { logger }); + }); + + it('does not warn', () => + assert.isFalse(logger.warn.called) + ); + it('has the Content-Length header set to 4', () => + assert.deepPropertyVal(headers, 'Content-Length', '4') + ); + }); + + describe('when there is no body and the Content-Length is wrong', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({ + 'Content-Length': '42' + }, Buffer.from(''), { logger }); + }); + + it('warns about the discrepancy', () => + assert.match(logger.warn.lastCall.args[0], /but the real body length is/) + ); + it('has the Content-Length header set to 0', () => + assert.deepPropertyVal(headers, 'Content-Length', '0') + ); + }); + + describe('when there is body and the Content-Length is wrong', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({ + 'Content-Length': '42' + }, Buffer.from('abcd'), { logger }); + }); + + it('warns about the discrepancy', () => + assert.match(logger.warn.lastCall.args[0], /but the real body length is/) + ); + it('has the Content-Length header set to 4', () => + assert.deepPropertyVal(headers, 'Content-Length', '4') + ); + }); + + describe('when the existing header name has unusual casing', () => { + beforeEach(() => { + headers = normalizeContentLengthHeader({ + 'CoNtEnT-lEnGtH': '4' + }, Buffer.from('abcd'), { logger }); + }); + + it('has the CoNtEnT-lEnGtH header set to 4', () => + assert.deepEqual(headers, { 'CoNtEnT-lEnGtH': '4' }) + ); + }); + + describe('when there are modifications to the headers', () => { + const originalHeaders = {}; + + beforeEach(() => { + headers = normalizeContentLengthHeader(originalHeaders, Buffer.from('abcd'), { logger }); + }); + + it('does not modify the original headers object', () => { + assert.deepEqual(originalHeaders, {}); + assert.deepEqual(headers, { 'Content-Length': '4' }); + }); + }); +}); diff --git a/test/unit/performRequest/performRequest-test.js b/test/unit/performRequest/performRequest-test.js new file mode 100644 index 000000000..a73bf372b --- /dev/null +++ b/test/unit/performRequest/performRequest-test.js @@ -0,0 +1,137 @@ +const sinon = require('sinon'); +const { assert } = require('chai'); + +const performRequest = require('../../../src/performRequest'); + + +describe('performRequest()', () => { + const uri = 'http://example.com/42'; + const uriS = 'https://example.com/42'; + const transactionReq = { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'Hello' + }; + const res = { statusCode: 200, headers: { 'Content-Type': 'text/plain' } }; + const request = sinon.stub().callsArgWithAsync(1, null, res, Buffer.from('Bye')); + const logger = { debug: sinon.spy() }; + + beforeEach(() => { logger.debug.reset(); }); + + it('does not modify the original HTTP options object', (done) => { + const httpOptions = { json: true }; + performRequest(uri, transactionReq, { http: httpOptions, request }, () => { + assert.deepEqual(httpOptions, { json: true }); + done(); + }); + }); + it('does not allow to override the hardcoded HTTP options', (done) => { + performRequest(uri, transactionReq, { http: { proxy: true }, request }, () => { + assert.isFalse(request.firstCall.args[0].proxy); + done(); + }); + }); + it('forbids the HTTP client library to respect proxy settings', (done) => { + performRequest(uri, transactionReq, { request }, () => { + assert.isFalse(request.firstCall.args[0].proxy); + done(); + }); + }); + it('forbids the HTTP client library to follow redirects', (done) => { + performRequest(uri, transactionReq, { request }, () => { + assert.isFalse(request.firstCall.args[0].followRedirect); + done(); + }); + }); + it('propagates the HTTP method to the HTTP client library', (done) => { + performRequest(uri, transactionReq, { request }, () => { + assert.equal(request.firstCall.args[0].method, transactionReq.method); + done(); + }); + }); + it('propagates the URI to the HTTP client library', (done) => { + performRequest(uri, transactionReq, { request }, () => { + assert.equal(request.firstCall.args[0].uri, uri); + done(); + }); + }); + it('propagates the HTTP request body as a Buffer', (done) => { + performRequest(uri, transactionReq, { request }, () => { + assert.deepEqual(request.firstCall.args[0].body, Buffer.from('Hello')); + done(); + }); + }); + it('handles exceptions when preparing the HTTP request body', (done) => { + const invalidTransactionReq = Object.assign( + { bodyEncoding: 'latin2' }, + transactionReq + ); + performRequest(uri, invalidTransactionReq, { request }, (err) => { + assert.instanceOf(err, Error); + done(); + }); + }); + it('logs before performing the HTTP request', (done) => { + performRequest(uri, transactionReq, { request, logger }, () => { + assert.equal( + logger.debug.firstCall.args[0], + `Performing HTTP request to the server under test: POST ${uri}` + ); + done(); + }); + }); + it('logs before performing the HTTPS request', (done) => { + performRequest(uriS, transactionReq, { request, logger }, () => { + assert.equal( + logger.debug.firstCall.args[0], + `Performing HTTPS request to the server under test: POST ${uriS}` + ); + done(); + }); + }); + it('logs on receiving the HTTP response', (done) => { + performRequest(uri, transactionReq, { request, logger }, () => { + assert.equal( + logger.debug.lastCall.args[0], + 'Handling HTTP response from the server under test' + ); + done(); + }); + }); + it('logs on receiving the HTTPS response', (done) => { + performRequest(uriS, transactionReq, { request, logger }, () => { + assert.equal( + logger.debug.lastCall.args[0], + 'Handling HTTPS response from the server under test' + ); + done(); + }); + }); + it('handles exceptions when requesting the server under test', (done) => { + const error = new Error('Ouch!'); + const invalidRequest = sinon.stub().throws(error); + performRequest(uri, transactionReq, { request: invalidRequest }, (err) => { + assert.deepEqual(err, error); + done(); + }); + }); + it('handles errors when requesting the server under test', (done) => { + const error = new Error('Ouch!'); + const invalidRequest = sinon.stub().callsArgWithAsync(1, error); + performRequest(uri, transactionReq, { request: invalidRequest }, (err) => { + assert.deepEqual(err, error); + done(); + }); + }); + it('provides the real HTTP response object', (done) => { + performRequest(uri, transactionReq, { request }, (err, real) => { + assert.deepEqual(real, { + statusCode: 200, + headers: { 'Content-Type': 'text/plain' }, + body: 'Bye', + bodyEncoding: 'utf-8' + }); + done(); + }); + }); +}); diff --git a/test/unit/transaction-runner-test.js b/test/unit/transaction-runner-test.js index b4c100a14..ee3041a4a 100644 --- a/test/unit/transaction-runner-test.js +++ b/test/unit/transaction-runner-test.js @@ -1,5 +1,4 @@ const bodyParser = require('body-parser'); -const caseless = require('caseless'); const clone = require('clone'); const express = require('express'); const htmlStub = require('html'); @@ -428,18 +427,18 @@ describe('TransactionRunner', () => { beforeEach(() => { configuration.options['dry-run'] = true; runner = new Runner(configuration); - sinon.spy(runner, 'performRequest'); + sinon.spy(runner, 'performRequestAndValidate'); }); afterEach(() => { configuration.options['dry-run'] = false; - runner.performRequest.restore(); + runner.performRequestAndValidate.restore(); }); it('should skip the tests', done => runner.executeTransaction(transaction, () => { - assert.isOk(runner.performRequest.notCalled); + assert.isOk(runner.performRequestAndValidate.notCalled); done(); }) ); @@ -731,151 +730,6 @@ describe('TransactionRunner', () => { }); }); - describe('setContentLength(transaction)', () => { - const bodyFixture = JSON.stringify({ - type: 'bulldozer', - name: 'willy', - id: '5229c6e8e4b0bd7dbb07e29c' - }, null, 2); - - const transactionFixture = { - name: 'Group Machine > Machine > Delete Message > Bogus example name', - id: 'POST /machines', - host: '127.0.0.1', - port: '3000', - request: { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'Dredd/0.2.1 (Darwin 13.0.0; x64)' - }, - uri: '/machines', - method: 'POST' - }, - expected: { - headers: { 'content-type': 'application/json' - }, - status: '202', - body: bodyFixture - }, - origin: { - resourceGroupName: 'Group Machine', - resourceName: 'Machine', - actionName: 'Delete Message', - exampleName: 'Bogus example name' - }, - fullPath: '/machines', - protocol: 'http:' - }; - - const scenarios = [{ - name: 'Content-Length is not set, body is not set', - headers: {}, - body: '', - warning: false - }, - { - name: 'Content-Length is set, body is not set', - headers: { 'Content-Length': 0 }, - body: '', - warning: false - }, - { - name: 'Content-Length is not set, body is set', - headers: {}, - body: bodyFixture, - warning: false - }, - { - name: 'Content-Length is set, body is set', - headers: { 'Content-Length': bodyFixture.length }, - body: bodyFixture, - warning: false - }, - { - name: 'Content-Length has wrong value, body is not set', - headers: { 'Content-Length': bodyFixture.length }, - body: '', - warning: true - }, - { - name: 'Content-Length has wrong value, body is set', - headers: { 'Content-Length': 0 }, - body: bodyFixture, - warning: true - }, - { - name: 'case of the header name does not matter', - headers: { 'CoNtEnT-lEnGtH': bodyFixture.length }, - body: bodyFixture, - warning: false - } - ]; - - scenarios.forEach(scenario => - describe(scenario.name, () => { - const expectedContentLength = scenario.body.length; - let realRequest; - let loggerSpy; - - beforeEach((done) => { - loggerSpy = sinon.spy(loggerStub, 'warn'); - - transaction = clone(transactionFixture); - transaction.request.body = scenario.body; - - Object.keys(scenario.headers).forEach((name) => { - const value = scenario.headers[name]; - transaction.request.headers[name] = value; - }); - - nock('http://127.0.0.1:3000') - .post('/machines') - .reply(transaction.expected.status, function () { - realRequest = this.req; - return scenario.body; - }); - - runner.executeTransaction(transaction, done); - }); - afterEach(() => { - nock.cleanAll(); - loggerSpy.restore(); - }); - - if (scenario.warning) { - it('warns about discrepancy between provided Content-Length and real body length', () => { - assert.isTrue(loggerSpy.calledOnce); - - const message = loggerSpy.getCall(0).args[0].toLowerCase(); - assert.include(message, `the real body length is ${expectedContentLength}`); - assert.include(message, `using ${expectedContentLength} instead`); - }); - } else { - it('does not warn', () => assert.isFalse(loggerSpy.called)); - } - - context('the real request', () => { - it('has the Content-Length header', () => assert.isOk(caseless(realRequest.headers).has('Content-Length'))); - it(`has the Content-Length header set to ${expectedContentLength}`, () => - assert.equal( - caseless(realRequest.headers).get('Content-Length'), - expectedContentLength - ) - ); - }); - context('the transaction object', () => { - it('has the Content-Length header', () => assert.isOk(caseless(transaction.request.headers).has('Content-Length'))); - it(`has the Content-Length header set to ${expectedContentLength}`, () => - assert.equal( - caseless(transaction.request.headers).get('Content-Length'), - expectedContentLength - ) - ); - }); - }) - ); - }); - describe('exceuteAllTransactions(transactions, hooks, callback)', () => { runner = null; let hooks; @@ -1386,162 +1240,6 @@ describe('TransactionRunner', () => { }); }); - describe('executeTransaction(transaction, callback) multipart', () => { - let multiPartTransaction; - let notMultiPartTransaction; - runner = null; - beforeEach(() => { - runner = new Runner(configuration); - multiPartTransaction = { - name: 'Group Machine > Machine > Post Message> Bogus example name', - id: 'POST /machines/message', - host: '127.0.0.1', - port: '3000', - request: { - body: '\n--BOUNDARY \ncontent-disposition: form-data; name="mess12"\n\n{"message":"mess1"}\n--BOUNDARY\n\nContent-Disposition: form-data; name="mess2"\n\n{"message":"mess1"}\n--BOUNDARY--', - headers: { - 'Content-Type': 'multipart/form-data; boundary=BOUNDARY', - 'User-Agent': 'Dredd/0.2.1 (Darwin 13.0.0; x64)', - 'Content-Length': 180 - }, - uri: '/machines/message', - method: 'POST' - }, - expected: { - headers: { - 'content-type': 'text/htm' - } - }, - body: '', - status: '204', - origin: { - resourceGroupName: 'Group Machine', - resourceName: 'Machine', - actionName: 'Post Message', - exampleName: 'Bogus example name' - }, - fullPath: '/machines/message', - protocol: 'http:' - }; - - notMultiPartTransaction = { - name: 'Group Machine > Machine > Post Message> Bogus example name', - id: 'POST /machines/message', - host: '127.0.0.1', - port: '3000', - request: { - body: '\n--BOUNDARY \ncontent-disposition: form-data; name="mess12"\n\n{"message":"mess1"}\n--BOUNDARY\n\nContent-Disposition: form-data; name="mess2"\n\n{"message":"mess1"}\n--BOUNDARY--', - headers: { - 'Content-Type': 'text/plain', - 'User-Agent': 'Dredd/0.2.1 (Darwin 13.0.0; x64)', - 'Content-Length': 180 - }, - uri: '/machines/message', - method: 'POST' - }, - expected: { - headers: { - 'content-type': 'text/htm' - } - }, - body: '', - status: '204', - origin: { - resourceGroupName: 'Group Machine', - resourceName: 'Machine', - actionName: 'Post Message', - exampleName: 'Bogus example name' - }, - fullPath: '/machines/message', - protocol: 'http:' - }; - }); - - describe('when multipart header in request', () => { - const parsedBody = '\r\n--BOUNDARY \r\ncontent-disposition: form-data; name="mess12"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY\r\n\r\nContent-Disposition: form-data; name="mess2"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY--'; - beforeEach(() => { - server = nock('http://127.0.0.1:3000') - .post('/machines/message') - .reply(204); - configuration.server = 'http://127.0.0.1:3000'; - }); - - afterEach(() => nock.cleanAll()); - - it('should replace line feed in body', done => - runner.executeTransaction(multiPartTransaction, () => { - assert.isOk(server.isDone()); - assert.equal(multiPartTransaction.request.body, parsedBody, 'Body'); - assert.include(multiPartTransaction.request.body, '\r\n'); - done(); - }) - ); - }); - - describe('when multipart header in request is with lowercase key', () => { - const parsedBody = '\r\n--BOUNDARY \r\ncontent-disposition: form-data; name="mess12"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY\r\n\r\nContent-Disposition: form-data; name="mess2"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY--'; - beforeEach(() => { - server = nock('http://127.0.0.1:3000') - .post('/machines/message') - .reply(204); - configuration.server = 'http://127.0.0.1:3000'; - - delete multiPartTransaction.request.headers['Content-Type']; - multiPartTransaction.request.headers['content-type'] = 'multipart/form-data; boundary=BOUNDARY'; - }); - - afterEach(() => nock.cleanAll()); - - it('should replace line feed in body', done => - runner.executeTransaction(multiPartTransaction, () => { - assert.isOk(server.isDone()); - assert.equal(multiPartTransaction.request.body, parsedBody, 'Body'); - assert.include(multiPartTransaction.request.body, '\r\n'); - done(); - }) - ); - }); - - describe('when multipart header in request, but body already has some CR (added in hooks e.g.s)', () => { - beforeEach(() => { - server = nock('http://127.0.0.1:3000') - .post('/machines/message') - .reply(204); - configuration.server = 'http://127.0.0.1:3000'; - multiPartTransaction.request.body = '\r\n--BOUNDARY \r\ncontent-disposition: form-data; name="mess12"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY\r\n\r\nContent-Disposition: form-data; name="mess2"\r\n\r\n{"message":"mess1"}\r\n--BOUNDARY--'; - }); - - afterEach(() => nock.cleanAll()); - - it('should not add CR again', done => - runner.executeTransaction(multiPartTransaction, () => { - assert.isOk(server.isDone()); - assert.notInclude(multiPartTransaction.request.body, '\r\r'); - done(); - }) - ); - }); - - describe('when multipart header is not in request', () => { - beforeEach(() => { - server = nock('http://127.0.0.1:3000') - .post('/machines/message') - .reply(204); - configuration.server = 'http://127.0.0.1:3000'; - }); - - afterEach(() => nock.cleanAll()); - - it('should not include any line-feed in body', done => - runner.executeTransaction(notMultiPartTransaction, () => { - assert.isOk(server.isDone()); - assert.notInclude(multiPartTransaction.request.body, '\r\n'); - done(); - }) - ); - }); - }); - describe('#executeAllTransactions', () => { configuration = { server: 'http://127.0.0.1:3000',