diff --git a/.eslintrc b/.eslintrc index 090e8620c..68cd14521 100644 --- a/.eslintrc +++ b/.eslintrc @@ -89,7 +89,7 @@ "no-throw-literal": 2, "no-trailing-spaces": 2, "no-undef-init": 2, - "no-underscore-dangle": [2, { "allow": ["_links", "_behaviors", "_mode", "_proxyResponseTime"] }], + "no-underscore-dangle": [2, { "allow": ["_links", "_behaviors", "_proxyResponseTime"] }], "no-unneeded-ternary": 2, "no-unused-expressions": 2, "no-useless-call": 2, diff --git a/mbTest/api/http/httpImposterTest.js b/mbTest/api/http/httpImposterTest.js index 65be34894..1a0c15303 100644 --- a/mbTest/api/http/httpImposterTest.js +++ b/mbTest/api/http/httpImposterTest.js @@ -140,11 +140,11 @@ const assert = require('assert'), assert.strictEqual(response.statusCode, 200); assert.deepEqual(response.body.stubs, [ { - responses: [{ is: { body: '1' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: '1' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: '2' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: '2' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } } ]); @@ -214,7 +214,7 @@ const assert = require('assert'), port: port + 1, name: imposter.name, recordRequests: false, - stubs: [{ responses: [{ is: { body: 'Hello, World!' } }] }] + stubs: [{ responses: [{ is: { bodyEncoding: 'utf8', body: 'Hello, World!' } }] }] }); }); }); diff --git a/mbTest/api/http/httpProxyStubTest.js b/mbTest/api/http/httpProxyStubTest.js index ca9f307ae..a10d2fa02 100644 --- a/mbTest/api/http/httpProxyStubTest.js +++ b/mbTest/api/http/httpProxyStubTest.js @@ -172,7 +172,7 @@ describe('http proxy stubs', function () { }); } - ['application/octet-stream', 'audio/mpeg', 'audio/mp4', 'image/gif', 'image/jpeg', 'video/avi', 'video/mpeg'].forEach(mimeType => { + ['application/octet-stream', 'audio/mpeg', 'audio/mp4', 'image/gif', 'image/jpeg', 'video/avi', 'video/mpeg', ''].forEach(mimeType => { it(`should treat ${mimeType} as binary`, async function () { const buffer = Buffer.from([0, 1, 2, 3]), origin = { @@ -183,7 +183,7 @@ describe('http proxy stubs', function () { is: { body: buffer.toString('base64'), headers: { 'content-type': mimeType }, - _mode: 'binary' + bodyEncoding: null } }] }] @@ -298,6 +298,8 @@ describe('http proxy stubs', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${request.path}` }; }, @@ -335,6 +337,8 @@ describe('http proxy stubs', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${request.path}` }; }, @@ -378,6 +382,8 @@ describe('http proxy stubs', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${JSON.stringify(request.query)}` }; }, @@ -417,6 +423,8 @@ describe('http proxy stubs', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${JSON.stringify(request.query)}` }; }, @@ -452,7 +460,12 @@ describe('http proxy stubs', function () { it('should persist behaviors from origin server', async function () { const originServerPort = port + 1, - originServerStub = { responses: [{ is: { body: '${SALUTATION} ${NAME}' } }] }, + originServerStub = { responses: [{ + is: { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 + body: '${SALUTATION} ${NAME}' + } }] }, originServerRequest = { protocol: 'http', port: originServerPort, @@ -528,6 +541,8 @@ describe('http proxy stubs', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${request.path}` }; }, @@ -580,10 +595,11 @@ describe('http proxy stubs', function () { headers: { Connection: 'close', Date: 'NOW', + 'Content-Type': 'text/plain; charset=utf-8', 'Transfer-Encoding': 'chunked' }, body: '1. /first', - _mode: 'text' + bodyEncoding: 'utf8' } }, { @@ -592,10 +608,11 @@ describe('http proxy stubs', function () { headers: { Connection: 'close', Date: 'NOW', + 'Content-Type': 'text/plain; charset=utf-8', 'Transfer-Encoding': 'chunked' }, body: '3. /first', - _mode: 'text' + bodyEncoding: 'utf8' } } ] @@ -615,10 +632,11 @@ describe('http proxy stubs', function () { headers: { Connection: 'close', Date: 'NOW', + 'Content-Type': 'text/plain; charset=utf-8', 'Transfer-Encoding': 'chunked' }, body: '2. /second', - _mode: 'text' + bodyEncoding: 'utf8' } } ] @@ -636,7 +654,7 @@ describe('http proxy stubs', function () { is: { body: buffer.toString('base64'), headers: { 'content-encoding': 'gzip' }, - _mode: 'binary' + bodyEncoding: null } }, originServerStub = { responses: [originServerResponse] }, @@ -652,14 +670,19 @@ describe('http proxy stubs', function () { await api.createImposter(originServerRequest); await api.createImposter(proxyRequest); - const response = await client.responseFor({ method: 'GET', port, path: '/', mode: 'binary' }); + const response = await client.responseFor({ method: 'GET', port, path: '/', responseEncoding: null }); - assert.deepEqual(response.body.toJSON().data, [0, 1, 2, 3]); + assert.deepEqual(response.body, Buffer.from([0, 1, 2, 3])); }); it('should persist decorated proxy responses and only run decorator once', async function () { const originServerPort = port + 1, - originServerStub = { responses: [{ is: { body: 'origin server' } }] }, + originServerStub = { responses: [{ + is: { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 + body: 'origin server' + } }] }, originServerRequest = { protocol: 'http', port: originServerPort, @@ -746,6 +769,7 @@ describe('http proxy stubs', function () { encoding = 'content-length'; } return { + bodyEncoding: 'utf8', body: `Encoding: ${encoding}` }; }, @@ -784,7 +808,11 @@ describe('http proxy stubs', function () { it('should add decorate behaviors to newly created response', async function () { const originServerPort = port + 1, - originServerStub = { responses: [{ is: { body: 'origin server' } }] }, + originServerStub = { responses: [{ + is: { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 + body: 'origin server' } }] }, originServerRequest = { protocol: 'http', port: originServerPort, @@ -818,11 +846,11 @@ describe('http proxy stubs', function () { name: 'origin' }, firstStaticStub = { - responses: [{ is: { body: 'first stub' } }], - predicates: [{ equals: { body: 'fail match so we fall through to proxy' } }] + responses: [{ is: { bodyEncoding: 'utf8', body: 'first stub' } }], + predicates: [{ equals: { bodyEncoding: 'utf8', body: 'fail match so we fall through to proxy' } }] }, proxyStub = { responses: [{ proxy: { to: `http://localhost:${originServerPort}`, mode: 'proxyAlways' } }] }, - secondStaticStub = { responses: [{ is: { body: 'second stub' } }] }, + secondStaticStub = { responses: [{ is: { bodyEncoding: 'utf8', body: 'second stub' } }] }, proxyRequest = { protocol: 'http', port, @@ -880,7 +908,12 @@ describe('http proxy stubs', function () { it('should save JSON bodies as JSON instead of text (issue #656)', async function () { const originServerPort = port + 1, - originServerStub = { responses: [{ is: { body: { json: true } } }] }, + originServerStub = { responses: [{ + is: { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 + body: { json: true } + } }] }, originServerRequest = { protocol: 'http', port: originServerPort, diff --git a/mbTest/api/http/httpStubTest.js b/mbTest/api/http/httpStubTest.js index 68cf9c9e4..4cf58d2ef 100644 --- a/mbTest/api/http/httpStubTest.js +++ b/mbTest/api/http/httpStubTest.js @@ -1,6 +1,7 @@ 'use strict'; const assert = require('assert'), + buffer = require('buffer'), api = require('../../api').create(), BaseHttpClient = require('../../baseHttpClient'), port = api.port + 1, @@ -161,14 +162,19 @@ function merge (defaults, overrides) { }); it('should support sending binary response', async function () { - const buffer = Buffer.from([0, 1, 2, 3]), - stub = { responses: [{ is: { body: buffer.toString('base64'), _mode: 'binary' } }] }, + const bodyBuffer = Buffer.from([0, 1, 2, 3]), + stub = { responses: [{ + is: { + headers: { 'Content-Type': 'application/octetstream' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: false, // Tell the stub that the provided body is utf8 + body: bodyBuffer.toString('base64') + } }] }, request = { protocol, port, stubs: [stub] }; await api.createImposter(request); - const response = await client.responseFor({ method: 'GET', port, path: '/', mode: 'binary' }); + const response = await client.responseFor({ method: 'GET', port, path: '/', responseEncoding: false }); - assert.deepEqual(response.body.toJSON().data, [0, 1, 2, 3]); + assert.deepEqual(response.body, bodyBuffer); }); it('should support JSON bodies', async function () { @@ -228,15 +234,69 @@ function merge (defaults, overrides) { assert.strictEqual(response.body, 'SUCCESS'); }); + it('should support treating the body as binary for predicate matching', async function () { + const stub = { + responses: [{ is: { body: 'SUCCESS' } }], + predicates: [ + { equals: { bodyEncoding: 'latin1', body: 'æøå' } }, + { equals: { bodyEncoding: null, body: '5vjl' } } + ] + }, + request = { protocol, port, stubs: [stub] }; + await api.createImposter(request); + + const spec = { + method: 'POST', + path: '/', + port, + requestEncoding: 'latin1', + headers: { 'Content-Type': 'text/plain; charset=latin1' }, + body: 'æøå' + }; + const response = await client.responseFor(spec); + + assert.strictEqual(response.body, 'SUCCESS'); + }); + + it('should support treating the body as non-utf8 text for predicate matching', async function () { + const stub = { + responses: [{ is: { body: 'SUCCESS' } }], + predicates: [ + { equals: { bodyEncoding: 'latin1', body: 'æøå' } }, + { equals: { bodyEncoding: null, body: '5vjl' } } + ] + }, + request = { protocol, port, stubs: [stub] }; + await api.createImposter(request); + + const spec = { + method: 'POST', + path: '/', + port, + requestEncoding: null, + headers: { 'Content-Type': 'text/plain; charset=latin1' }, + body: buffer.transcode(Buffer.from('æøå'), 'utf8', 'latin1') + }; + const response = await client.responseFor(spec); + + assert.strictEqual(response.body, 'SUCCESS'); + }); + it('should support changing default response for stub', async function () { const stub = { responses: [ - { is: { body: 'Wrong address' } }, + { + is: { + body: 'Wrong address' + } + }, { is: { statusCode: 500 } } ], predicates: [{ equals: { path: '/' } }] }, - defaultResponse = { statusCode: 404, body: 'Not found' }, + defaultResponse = { statusCode: 404, + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 + body: 'Not found' }, request = { protocol, port, defaultResponse: defaultResponse, stubs: [stub] }; await api.createImposter(request); @@ -298,7 +358,7 @@ function merge (defaults, overrides) { is: { headers: { 'Content-Length': 852 }, body: 'H4sIAAAAAAAEAO29B2AcSZYlJi9tynt/SvVK1+B0oQiAYBMk2JBAEOzBiM3mkuwdaUcjKasqgcplVmVdZhZAzO2dvPfee++999577733ujudTif33/8/XGZkAWz2zkrayZ4hgKrIHz9+fB8/In7xR8Xso0cfzab3p/vn053t/NPZbHt/cn5/++D+5N72pwefTnd2JtP8/CD7aPRR02btuqH2zXo6zZuGPpplbfbRo1/80arMlviZXWZFmU2Ksmiv8XdbLPIfVMucXsqb9vfPZy29VC3LAh/94o8WFb91XlcLarFz/9HODn3fVvTH3h7++CX015qbbmxzldMwbmjTztc3tjmvixvbNFnrt3mIj02bXfyBNutgXJE2v4RagWi//zRftnVWonlZXVALmpNFdpH//uuaPvxo3rar5tHdu9NFsz3LL8fL7JJIOivejafV4m5z3bT54u55UebN3d27/GIzXi0vqDei8VsPCMEI3gWadd5U65qm8qNH3/vFHy2zBVH6o5d1dVnM8jp9WtT5tK3qawLWg7PM2zH9n6C4F+dZvcim1+//YlUW0yJv0l+YUufTfLYmxG757vG6nVd18YOsLapl+rxowGC3efFZVS/WZXZ7JA1ZXuXneZ0vpzmh+0oJmH6+pu9ui/MJTU0xzUqM9qLOFrd9z9I1rc7Tb+dZ2c4BgtFq0mKZvsiv0t+nqt9uhPd9YnMa+8Cc5wugtJoX0/RsiXZC2Ff5L1qTAKeg2kboDtuTqqpnxVLeJ4Sf5Mv8vGib94JRZsXivd54WefbJ3ndFudEYe76xpeJINNqdV0XF3OSbPd72rR1sbxIdyGtv+T/AdOWKsArBQAA', - _mode: 'binary' + bodyEncoding: false } }] }, @@ -316,7 +376,7 @@ function merge (defaults, overrides) { is: { headers: { 'Content-Length': 274 }, body: 'iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyBAMAAABYG2ONAAAAFVBMVEUAAAD///9/f39fX1+fn58f\nHx8/Pz8rYiDqAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAo0lEQVRIie2Qyw7CIBBFb2DwO5q+1o0L\n1w0NrrHRPQnV//8EAUl0ga1ujIs5CZAz4YYZAIZhmN/QQOkjzq3LLuv6xUrQHmTJGphcEGE9rUQ3\nY4bqqrDjAlgQoJK9Z8YBmFy8Gp8DeSeTfRSBCf2I6/JN5ORiRfrNiIfqh9S9SVPL27A1C0G4EX2e\nJR7J1iI7rbG0Vf4x0UwPW0Uh3i0bwzD/yR11mBj1DIKiVwAAAABJRU5ErkJggg==\n', - _mode: 'binary' + bodyEncoding: false } }] }, @@ -364,7 +424,7 @@ function merge (defaults, overrides) { request = { protocol, port, stubs: [stub] }; await api.createImposter(request); - const response = await client.post('/', xml, port); + const response = await client.post('/', xml, port, { 'Content-Type': 'application/xml; charset=utf-8' }); assert.strictEqual(response.body, 'SUCCESS'); }); @@ -426,7 +486,7 @@ function merge (defaults, overrides) { headers: { 'Content-Encoding': 'gzip' }, - mode: 'binary', + requestEncoding: false, body: zlib.gzipSync('{"key": "value", "arr": [3,2,1]}') }, stub = { @@ -461,15 +521,15 @@ function merge (defaults, overrides) { assert.strictEqual(putResponse.statusCode, 200); assert.deepEqual(putResponse.body.stubs, [ { - responses: [{ is: { body: 'FIRST' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'FIRST' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: 'ORIGINAL' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'ORIGINAL' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } }, { - responses: [{ is: { body: 'THIRD' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'THIRD' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/2` } } } ]); @@ -495,16 +555,16 @@ function merge (defaults, overrides) { assert.strictEqual(putResponse.statusCode, 200, JSON.stringify(putResponse.body)); assert.deepEqual(putResponse.body.stubs, [ { - responses: [{ is: { body: 'first' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'first' } }], predicates: [{ equals: { path: '/first' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: 'CHANGED' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'CHANGED' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } }, { - responses: [{ is: { body: 'third' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'third' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/2` } } } ]); @@ -529,11 +589,11 @@ function merge (defaults, overrides) { assert.strictEqual(deleteResponse.statusCode, 200); assert.deepEqual(deleteResponse.body.stubs, [ { - responses: [{ is: { body: 'first' } }], predicates: [{ equals: { path: '/first' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'first' } }], predicates: [{ equals: { path: '/first' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: 'third' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'third' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } } ]); @@ -558,15 +618,15 @@ function merge (defaults, overrides) { assert.strictEqual(postResponse.statusCode, 200); assert.deepEqual(postResponse.body.stubs, [ { - responses: [{ is: { body: 'first' } }], predicates: [{ equals: { path: '/first' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'first' } }], predicates: [{ equals: { path: '/first' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: 'SECOND' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'SECOND' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } }, { - responses: [{ is: { body: 'third' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'third' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/2` } } } ]); @@ -607,15 +667,15 @@ function merge (defaults, overrides) { assert.strictEqual(postResponse.statusCode, 200); assert.deepEqual(postResponse.body.stubs, [ { - responses: [{ is: { body: 'first' } }], predicates: [{ equals: { path: '/first' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'first' } }], predicates: [{ equals: { path: '/first' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/0` } } }, { - responses: [{ is: { body: 'third' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'third' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/1` } } }, { - responses: [{ is: { body: 'LAST' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'LAST' } }], _links: { self: { href: `${api.url}/imposters/${port}/stubs/2` } } } ]); diff --git a/mbTest/api/impostersControllerTest.js b/mbTest/api/impostersControllerTest.js index 77910050b..6f1b9f526 100644 --- a/mbTest/api/impostersControllerTest.js +++ b/mbTest/api/impostersControllerTest.js @@ -129,7 +129,7 @@ describe('DELETE /imposters', function () { numberOfRequests: 0, requests: [], stubs: [{ - responses: [{ is: { body: 'Hello, World!' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: 'Hello, World!' } }], _links: { self: { href: `${api.url}/imposters/${isImposter.port}/stubs/0` } } }], _links: { diff --git a/mbTest/api/tcp/tcpStubTest.js b/mbTest/api/tcp/tcpStubTest.js index 3b56a776d..502bfeb70 100644 --- a/mbTest/api/tcp/tcpStubTest.js +++ b/mbTest/api/tcp/tcpStubTest.js @@ -82,15 +82,15 @@ describe('tcp imposter', function () { it('should return 400 if uses matches predicate with binary mode', async function () { const stub = { - responses: [{ is: { data: 'dGVzdA==' } }], - predicates: [{ matches: { data: 'dGVzdA==' } }] + responses: [{ is: { bodyEncoding: null, body: 'dGVzdA==' } }], + predicates: [{ matches: { bodyEncoding: null, body: 'dGVzdA==' } }] }, - request = { protocol: 'tcp', port, mode: 'binary', stubs: [stub] }; + request = { protocol: 'tcp', port, stubs: [stub] }; const response = await api.post('/imposters', request); assert.strictEqual(response.statusCode, 400, JSON.stringify(response.body, null, 4)); - assert.strictEqual(response.body.errors[0].message, 'the matches predicate is not allowed in binary mode'); + assert.strictEqual(response.body.errors[0].message, 'the matches predicate is not allowed for binary bodies'); }); it('should allow proxy stubs', async function () { diff --git a/mbTest/baseHttpClient.js b/mbTest/baseHttpClient.js index 67fd77d95..c56761342 100644 --- a/mbTest/baseHttpClient.js +++ b/mbTest/baseHttpClient.js @@ -15,6 +15,12 @@ function create (protocol) { if (!Object.keys(spec.headers).some(key => key.toLowerCase() === 'accept')) { spec.headers.accept = 'application/json'; } + if (!('responseEncoding' in spec)) { + spec.responseEncoding = 'utf8'; + } + if (!('requestEncoding' in spec)) { + spec.requestEncoding = 'utf8'; + } spec.rejectUnauthorized = false; return spec; } @@ -44,9 +50,9 @@ function create (protocol) { const buffer = Buffer.concat(packets), contentType = response.headers['content-type'] || ''; - response.body = spec.mode === 'binary' ? buffer : buffer.toString('utf8'); + response.body = spec.responseEncoding ? buffer.toString(spec.responseEncoding) : buffer; - if (contentType.indexOf('application/json') === 0) { + if (contentType.indexOf('application/json') === 0 && spec.responseEncoding === 'utf8') { response.body = JSON.parse(response.body); } resolve(response); @@ -56,14 +62,12 @@ function create (protocol) { request.on('error', reject); if (spec.body) { - if (spec.mode === 'binary') { - request.write(spec.body); - } - else if (typeof spec.body === 'object') { + if (spec.headers['Content-Type'] === 'application/json' && spec.requestEncoding === 'utf8' && typeof spec.body === 'object') { request.write(JSON.stringify(spec.body)); } else { - request.write(spec.body); + // This covers both the Buffer situation and the non-utf8 string encoding. + request.write(spec.body, spec.requestEncoding); } } request.end(); diff --git a/mbTest/cli/configFileTest.js b/mbTest/cli/configFileTest.js index b4f9d84e7..6ae7e6714 100644 --- a/mbTest/cli/configFileTest.js +++ b/mbTest/cli/configFileTest.js @@ -7,7 +7,7 @@ const assert = require('assert'), path = require('path'), isWindows = require('os').platform().indexOf('win') === 0, BaseHttpClient = require('../baseHttpClient'), - baseTimeout = parseInt(process.env.MB_SLOW_TEST_TIMEOUT || 3000), + baseTimeout = parseInt(process.env.MB_SLOW_TEST_TIMEOUT || 3000000), timeout = isWindows ? 2 * baseTimeout : baseTimeout, smtp = require('../api/smtp/smtpClient'), http = BaseHttpClient.create('http'), @@ -129,8 +129,7 @@ describe('--configfile', function () { method: 'POST', path: '/', port: 4542, - headers: { 'Content-Encoding': 'gzip' }, - mode: 'binary', + headers: { 'Content-Encoding': 'gzip', 'Content-Type': 'text/plain' }, body: buffer }); diff --git a/mbTest/cli/debugTest.js b/mbTest/cli/debugTest.js index ec7c0a777..2a7813457 100644 --- a/mbTest/cli/debugTest.js +++ b/mbTest/cli/debugTest.js @@ -75,7 +75,7 @@ describe('--debug', function () { requestHeaders = { accept: 'application/json', Host: `localhost:${serverPort}`, Connection: 'keep-alive' }; assert.deepEqual(actualWithoutEphemeralData, [{ - responses: [{ is: { body: '1' } }, { is: { body: '2' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: '1' } }, { is: { bodyEncoding: 'utf8', body: '2' } }], matches: [ { timestamp: 'NOW', @@ -86,13 +86,18 @@ describe('--debug', function () { method: 'GET', headers: requestHeaders, ip: '::1', + bodyEncoding: false, body: '' }, response: { + bodyEncoding: 'utf8', body: '1' }, responseConfig: { - is: { body: '1' } + is: { + bodyEncoding: 'utf8', + body: '1' + } }, processingTime: 0 }, @@ -105,13 +110,18 @@ describe('--debug', function () { method: 'GET', headers: requestHeaders, ip: '::1', + bodyEncoding: false, body: '' }, response: { + bodyEncoding: 'utf8', body: '2' }, responseConfig: { - is: { body: '2' } + is: { + bodyEncoding: 'utf8', + body: '2' + } }, processingTime: 0 } @@ -132,14 +142,14 @@ describe('--debug', function () { const response = await mb.get(`/imposters/${serverPort}`); assert.deepEqual(response.body.stubs, [{ - responses: [{ is: { body: '1' } }, { is: { body: '2' } }], + responses: [{ is: { bodyEncoding: 'utf8', body: '1' } }, { is: { bodyEncoding: 'utf8', body: '2' } }], _links: { self: { href: `${mb.url}/imposters/${serverPort}/stubs/0` } } }]); }); it('should record final response from out of process proxy', async function () { const originServerPort = api.port + 2, - originServerStub = { responses: [{ is: { body: 'ORIGIN' } }] }, + originServerStub = { responses: [{ is: { headers: { 'Content-Type': 'text/plain' }, body: 'ORIGIN' } }] }, originServerRequest = { protocol: 'http', port: originServerPort, stubs: [originServerStub] }, proxyServerPort = api.port + 3, proxyServerStub = { responses: [{ proxy: { to: `http://localhost:${originServerPort}` } }] }, diff --git a/mbTest/cli/formatterTest.js b/mbTest/cli/formatterTest.js index e47c7cf53..55bd8c9c3 100644 --- a/mbTest/cli/formatterTest.js +++ b/mbTest/cli/formatterTest.js @@ -22,7 +22,7 @@ describe('--formatter', function () { protocol: 'http', port: 3000, recordRequests: false, - stubs: [{ responses: [{ is: { body: 'SUCCESS' } }] }] + stubs: [{ responses: [{ is: { bodyEncoding: 'utf8', body: 'SUCCESS' } }] }] }, formatter = `${__dirname}/formatters/base64Formatter`; await mb.start([]); @@ -51,7 +51,7 @@ describe('--formatter', function () { protocol: 'http', port: 3000, recordRequests: false, - stubs: [{ responses: [{ is: { body: 'SUCCESS' } }] }] + stubs: [{ responses: [{ is: { bodyEncoding: 'utf8', body: 'SUCCESS' } }] }] }, formatter = `${__dirname}/formatters/asyncBase64Formatter`; await mb.start([]); @@ -80,7 +80,7 @@ describe('--formatter', function () { protocol: 'http', port: 3000, recordRequests: false, - stubs: [{ responses: [{ is: { body: 'SUCCESS' } }] }] + stubs: [{ responses: [{ is: { bodyEncoding: 'utf8', body: 'SUCCESS' } }] }] }, formatter = `${__dirname}/formatters/base64Formatter`; await mb.start([]); diff --git a/mbTest/cli/hostTest.js b/mbTest/cli/hostTest.js index fb3638f52..a026431d7 100644 --- a/mbTest/cli/hostTest.js +++ b/mbTest/cli/hostTest.js @@ -86,7 +86,7 @@ describe('--host', function () { } const originServerPort = mb.port + 1, - originServerStub = { responses: [{ is: { body: 'ORIGIN' } }] }, + originServerStub = { responses: [{ is: { headers: { 'Content-Type': 'text/plain' }, body: 'ORIGIN' } }] }, originServerRequest = { protocol: 'http', port: originServerPort, stubs: [originServerStub] }, proxyPort = mb.port + 2, proxyDefinition = { to: `http://${hostname}:${originServerPort}`, mode: 'proxyAlways' }, diff --git a/mbTest/cli/replayTest.js b/mbTest/cli/replayTest.js index 1ca452e50..6c3d49d72 100644 --- a/mbTest/cli/replayTest.js +++ b/mbTest/cli/replayTest.js @@ -15,6 +15,8 @@ describe('mb replay', function () { state.count = state.count || 0; state.count += 1; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `${state.count}. ${request.path}` }; }, diff --git a/mbTest/cli/templates/counter.ejs b/mbTest/cli/templates/counter.ejs index 196a58822..12dad4ab7 100644 --- a/mbTest/cli/templates/counter.ejs +++ b/mbTest/cli/templates/counter.ejs @@ -2,6 +2,8 @@ const count = state.requests ? Object.keys(state.requests).length : 0; return { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, // Tell the client (the proxy recorder) what kind of content. + bodyEncoding: 'utf8', // Tell the stub that the provided body is utf8 body: `There have been ${count} proxied calls` }; } diff --git a/mbTest/cli/templates/originServerResponse.ejs b/mbTest/cli/templates/originServerResponse.ejs index 4f9c3b77e..a5a8992e2 100644 --- a/mbTest/cli/templates/originServerResponse.ejs +++ b/mbTest/cli/templates/originServerResponse.ejs @@ -6,6 +6,7 @@ headers: { 'Content-Type': 'application/json' }, + bodyEncoding: 'utf8', body: JSON.stringify({ count: state.requests }) }; } diff --git a/mbTest/cli/templates/proxy.ejs b/mbTest/cli/templates/proxy.ejs index 47af1a14f..837feaeac 100644 --- a/mbTest/cli/templates/proxy.ejs +++ b/mbTest/cli/templates/proxy.ejs @@ -28,6 +28,7 @@ const stubResponse = { statusCode: response.statusCode, headers: response.headers, + bodyEncoding: 'utf8', body }; logger.info('Successfully proxied: ' + JSON.stringify(stubResponse)); diff --git a/src/models/compatibility.js b/src/models/compatibility.js index 3662c4424..234e6e212 100644 --- a/src/models/compatibility.js +++ b/src/models/compatibility.js @@ -10,6 +10,38 @@ const helpers = require('../util/helpers'); * @module */ + +/** + * While the new response structure uses bodyEncoding. Inject default old encoding 'utf8'. + * @param {Object} response - the reponse structure + */ +function upcastResponseEncoding (response) { + if (helpers.isObject(response) && response && 'body' in response && !('bodyEncoding' in response)) { + response.bodyEncoding = 'utf8'; + } +} + +function upcastStaticStubs (request) { + (request.stubs || []).forEach(stub => { + (stub.responses || []).forEach(response => { + if (response.is) { + upcastResponseEncoding(response.is); + } + }); + }); +} + +function upcastStaticPredicates (request) { + (request.stubs || []).forEach(stub => { + (stub.predicates || []).forEach(predicate => { + Object.entries(predicate).forEach(entry => { + if (helpers.isObject(entry[1]) && 'body' in entry[1] && !('bodyEncoding' in entry[1])) { + entry[1].bodyEncoding = 'utf8'; + } + }); + }); + }); +} /** * The original shellTransform only accepted one command * The new syntax expects an array, creating a shell pipeline @@ -109,6 +141,8 @@ function upcast (request) { upcastShellTransformToArray(request); upcastTcpProxyDestinationToUrl(request); upcastBehaviorsToArray(request); + upcastStaticStubs(request); + upcastStaticPredicates(request); } /** @@ -129,5 +163,6 @@ function downcastInjectionConfig (config) { module.exports = { upcast, + upcastResponseEncoding, downcastInjectionConfig }; diff --git a/src/models/dryRunValidator.js b/src/models/dryRunValidator.js index b551e3901..355ce9af4 100644 --- a/src/models/dryRunValidator.js +++ b/src/models/dryRunValidator.js @@ -63,10 +63,10 @@ function create (options) { return list.map(predicate).every(result => result); } - function findFirstMatch (stubRepository, request, encoding, logger) { + function findFirstMatch (stubRepository, request, logger) { const filter = stubPredicates => { return trueForAll(stubPredicates, - predicate => predicates.evaluate(predicate, request, encoding, logger, {})); + predicate => predicates.evaluate(predicate, request, logger, {})); }; return stubRepository.first(filter); @@ -93,14 +93,14 @@ function create (options) { } } - async function dryRunSingleRepo (stubRepository, encoding, dryRunLogger) { - const match = await findFirstMatch(stubRepository, options.testRequest, encoding, dryRunLogger), + async function dryRunSingleRepo (stubRepository, dryRunLogger) { + const match = await findFirstMatch(stubRepository, options.testRequest, dryRunLogger), responseConfig = await match.stub.nextResponse(); return resolverFor(stubRepository).resolve(responseConfig, options.testRequest, dryRunLogger, {}); } - async function dryRun (stub, encoding, logger) { + async function dryRun (stub, logger) { options.testRequest = options.testRequest || {}; options.testRequest.isDryRun = true; @@ -111,14 +111,14 @@ function create (options) { error: logger.error }, dryRunRepositories = await reposToTestFor(stub), - dryRuns = dryRunRepositories.map(stubRepository => dryRunSingleRepo(stubRepository, encoding, dryRunLogger)); + dryRuns = dryRunRepositories.map(stubRepository => dryRunSingleRepo(stubRepository, dryRunLogger)); return Promise.all(dryRuns); } - async function addDryRunErrors (stub, encoding, errors, logger) { + async function addDryRunErrors (stub, errors, logger) { try { - await dryRun(stub, encoding, logger); + await dryRun(stub, logger); } catch (reason) { reason.source = reason.source || JSON.stringify(stub); @@ -199,7 +199,7 @@ function create (options) { }); } - async function errorsForStub (stub, encoding, logger) { + async function errorsForStub (stub, logger) { const errors = []; if (!Array.isArray(stub.responses) || stub.responses.length === 0) { @@ -215,7 +215,7 @@ function create (options) { if (errors.length === 0) { // no sense in dry-running if there are already problems; // it will just add noise to the errors - await addDryRunErrors(stub, encoding, errors, logger); + await addDryRunErrors(stub, errors, logger); } return errors; @@ -242,8 +242,7 @@ function create (options) { */ async function validate (request, logger) { const stubs = request.stubs || [], - encoding = request.mode === 'binary' ? 'base64' : 'utf8', - validations = stubs.map(stub => errorsForStub(stub, encoding, logger)); + validations = stubs.map(stub => errorsForStub(stub, logger)); validations.push(Promise.resolve(errorsForRequest(request))); if (typeof options.additionalValidation === 'function') { diff --git a/src/models/http/baseHttpServer.js b/src/models/http/baseHttpServer.js index 603a1a840..c82e6306a 100644 --- a/src/models/http/baseHttpServer.js +++ b/src/models/http/baseHttpServer.js @@ -25,10 +25,9 @@ module.exports = function (createBaseServer) { statusCode: stubResponse.statusCode || defaultResponse.statusCode || 200, headers: stubResponse.headers || defaultHeaders, body: stubResponse.body || defaultResponse.body || '', - _mode: stubResponse._mode || defaultResponse._mode || 'text' + bodyEncoding: stubResponse.bodyEncoding || defaultResponse.bodyEncoding }, responseHeaders = headersMap.of(response.headers), - encoding = response._mode === 'binary' ? 'base64' : 'utf8', isObject = helpers.isObject; if (isObject(response.body)) { @@ -50,7 +49,7 @@ module.exports = function (createBaseServer) { } } - if (encoding === 'base64') { + if (!response.bodyEncoding) { // ensure the base64 has no newlines or other non // base64 chars that will cause the body to be garbled. response.body = response.body.replace(/[^A-Za-z0-9=+/]+/g, ''); @@ -61,7 +60,7 @@ module.exports = function (createBaseServer) { } if (responseHeaders.has('Content-Length')) { - responseHeaders.set('Content-Length', Buffer.byteLength(response.body, encoding)); + responseHeaders.set('Content-Length', Buffer.byteLength(response.body, response.bodyEncoding || 'base64')); } return response; @@ -128,8 +127,7 @@ module.exports = function (createBaseServer) { logger.debug('%s => %s', clientName, JSON.stringify(simplifiedRequest)); const mbResponse = await responseFn(simplifiedRequest, { rawUrl: request.url }), - stubResponse = postProcess(mbResponse, simplifiedRequest), - encoding = stubResponse._mode === 'binary' ? 'base64' : 'utf8'; + stubResponse = postProcess(mbResponse, simplifiedRequest); if (mbResponse.blocked) { request.socket.destroy(); @@ -141,7 +139,7 @@ module.exports = function (createBaseServer) { } response.writeHead(stubResponse.statusCode, stubResponse.headers); - response.end(stubResponse.body.toString(), encoding); + response.end(stubResponse.body.toString(), stubResponse.bodyEncoding || 'base64'); if (stubResponse) { logger.debug('%s <= %s', clientName, JSON.stringify(stubResponse)); @@ -179,8 +177,7 @@ module.exports = function (createBaseServer) { connections[socket].destroy(); }); }, - proxy: httpProxy.create(logger), - encoding: 'utf8' + proxy: httpProxy.create(logger) }); }); }); diff --git a/src/models/http/httpProxy.js b/src/models/http/httpProxy.js index 258a65bf5..653e0e3d2 100644 --- a/src/models/http/httpProxy.js +++ b/src/models/http/httpProxy.js @@ -26,27 +26,26 @@ function create (logger) { const BINARY_CONTENT_ENCODINGS = [ 'gzip', 'br', 'compress', 'deflate' ]; - const BINARY_MIME_TYPES = [ - 'audio/', - 'application/epub+zip', - 'application/gzip', - 'application/java-archive', - 'application/msword', - 'application/octet-stream', - 'application/pdf', - 'application/rtf', - 'application/vnd.ms-excel', - 'application/vnd.ms-fontobject', - 'application/vnd.ms-powerpoint', - 'application/vnd.visio', - 'application/x-shockwave-flash', - 'application/x-tar', - 'application/zip', - 'font/', - 'image/', - 'model/', - 'video/' - ]; + + /* eslint-disable quote-props */ + const CHARSET_TO_ENCODING = { + 'utf8': 'utf8', + 'utf-8': 'utf8', + 'iso88591': 'latin1', + 'iso-8859-1': 'latin1', + '8859-1': 'latin1', + 'iso-latin-1': 'latin1', + 'latin-1': 'latin1', + 'latin1': 'latin1', + 'UTF8': 'utf8', + 'UTF-8': 'utf8', + 'ISO88591': 'latin1', + 'ISO-8859-1': 'latin1', + 'ISO-LATIN-1': 'latin1', + 'LATIN-1': 'latin1', + 'LATIN1': 'latin1' + }; + /* eslint-enable quote-props */ function addInjectedHeadersTo (request, headersToInject) { Object.keys(headersToInject || {}).forEach(key => { @@ -125,15 +124,24 @@ function create (logger) { return proxiedRequest; } - function isBinaryResponse (headers) { + function guessEncoding (headers) { const contentEncoding = headers['content-encoding'] || '', contentType = headers['content-type'] || ''; if (BINARY_CONTENT_ENCODINGS.some(binEncoding => contentEncoding.indexOf(binEncoding) >= 0)) { - return true; + return false; } - - return BINARY_MIME_TYPES.some(typeName => contentType.indexOf(typeName) >= 0); + const [type, charset] = contentType.match(/^([^;]*)(?:.*?charset=([^;]+))?/).slice(1, 3); + if (charset) { + return CHARSET_TO_ENCODING[charset]; + } + else if (type.match(/json/)) { + return 'utf8'; + } + else if (type.match(/^text\//)) { + return 'latin1'; + } + return false; } function maybeJSON (text) { @@ -158,13 +166,12 @@ function create (logger) { response.on('end', () => { const body = Buffer.concat(packets), - mode = isBinaryResponse(response.headers) ? 'binary' : 'text', - encoding = mode === 'binary' ? 'base64' : 'utf8', + encoding = guessEncoding(response.headers), stubResponse = { statusCode: response.statusCode, headers: headersMap.ofRaw(response.rawHeaders).all(), - body: maybeJSON(body.toString(encoding)), - _mode: mode + body: encoding ? maybeJSON(body.toString(encoding)) : body.toString('base64'), + bodyEncoding: encoding }; resolve(stubResponse); }); diff --git a/src/models/http/httpRequest.js b/src/models/http/httpRequest.js index 307873d5d..c81b5a08d 100644 --- a/src/models/http/httpRequest.js +++ b/src/models/http/httpRequest.js @@ -23,6 +23,7 @@ function transform (request) { query: queryString.parse(search), headers: headersMap.all(), body: request.body, + bodyEncoding: request.bodyEncoding, ip: request.socket.remoteAddress }, contentType = headersMap.get('Content-Type'); @@ -51,29 +52,71 @@ function isUrlEncodedForm (contentType) { * @returns {Object} - Promise resolving to the simplified request */ function createFrom (request) { + /* eslint-disable quote-props */ + const HTTPCHARSET_TO_BUFFERCHARSET = { + 'utf8': 'utf8', + 'utf-8': 'utf8', + 'iso88591': 'latin1', + 'iso-8859-1': 'latin1', + '8859-1': 'latin1', + 'iso-latin-1': 'latin1', + 'latin-1': 'latin1', + 'latin1': 'latin1', + 'UTF8': 'utf8', + 'UTF-8': 'utf8', + 'ISO88591': 'latin1', + 'ISO-8859-1': 'latin1', + 'ISO-LATIN-1': 'latin1', + 'LATIN-1': 'latin1', + 'LATIN1': 'latin1' + }; + /* eslint-enable quote-props */ + + function guessEncoding (contentType = '') { + const [type, charset] = contentType.match(/^([^;]*)(?:.*?charset=([^;]+))?/).slice(1, 4); + if (charset) { + return HTTPCHARSET_TO_BUFFERCHARSET[charset]; + } + else if (type === 'application/x-www-form-urlencoded') { + return 'utf8'; + } + else if (type.match(/json/)) { + return 'utf8'; + } + else if (type.match(/^text\//)) { + return 'latin1'; + } + return false; + } + return new Promise(resolve => { const chunks = []; request.on('data', chunk => { chunks.push(Buffer.from(chunk)); }); request.on('end', () => { const headersMap = headersMapModule.ofRaw(request.rawHeaders), contentEncoding = headersMap.get('Content-Encoding'), + contentType = headersMap.get('Content-Type'), buffer = Buffer.concat(chunks); - + let decodedBuffer; if (contentEncoding === 'gzip') { try { - request.body = zlib.gunzipSync(buffer).toString(); + decodedBuffer = zlib.gunzipSync(buffer); } catch (error) { /* do nothing */ } } else if (contentEncoding === 'br') { try { - request.body = zlib.brotliDecompressSync(buffer).toString(); + decodedBuffer = zlib.brotliDecompressSync(buffer); } catch (error) { /* do nothing */ } } else { - request.body = buffer.toString(); + decodedBuffer = buffer; } + request.bodyEncoding = guessEncoding(contentType); + // TODO: check if decodedBuffer matches encoding (latin1), if not resort to binary. + // TODO: support more text encodings, i.e. use iconv + request.body = decodedBuffer.toString(request.bodyEncoding || 'base64'); resolve(transform(request)); }); }); diff --git a/src/models/imposter.js b/src/models/imposter.js index 9139ae1a1..d339f90df 100644 --- a/src/models/imposter.js +++ b/src/models/imposter.js @@ -48,7 +48,6 @@ async function create (Protocol, creationRequest, baseLogger, config, isAllowedC let stubs; let resolver; - let encoding; let numberOfRequests = 0; compatibility.upcast(creationRequest); @@ -59,7 +58,7 @@ async function create (Protocol, creationRequest, baseLogger, config, isAllowedC async function findFirstMatch (request) { const filter = stubPredicates => { return stubPredicates.every(predicate => - predicates.evaluate(predicate, request, encoding, logger, imposterState)); + predicates.evaluate(predicate, request, logger, imposterState)); }, observePredicateMatchDuration = metrics.predicateMatchDuration.startTimer(), match = await stubs.first(filter); @@ -204,7 +203,6 @@ async function create (Protocol, creationRequest, baseLogger, config, isAllowedC stubs = server.stubs; resolver = server.resolver; - encoding = server.encoding; function stop () { return new Promise(closed => { diff --git a/src/models/predicates.js b/src/models/predicates.js index 6736b0b2a..8ad839f65 100644 --- a/src/models/predicates.js +++ b/src/models/predicates.js @@ -1,6 +1,7 @@ 'use strict'; const stringify = require('safe-stable-stringify'), + buffer = require('buffer'), safeRegex = require('safe-regex'), jsonpath = require('./jsonpath.js'), helpers = require('../util/helpers.js'), @@ -39,7 +40,7 @@ function forceStrings (value) { else if (Array.isArray(value)) { return value.map(forceStrings); } - else if (isObject(value)) { + else if (isObject(value) && !Buffer.isBuffer(value)) { return Object.keys(value).reduce((accumulator, key) => { accumulator[key] = forceStrings(value[key]); return accumulator; @@ -53,11 +54,7 @@ function forceStrings (value) { } } -function select (type, selectFn, encoding) { - if (encoding === 'base64') { - throw errors.ValidationError(`the ${type} predicate parameter is not allowed in binary mode`); - } - +function select (type, selectFn) { const nodeValues = selectFn(); // Return either a string if one match or array if multiple @@ -89,10 +86,10 @@ function transformObject (obj, transform) { return obj; } -function selectXPath (config, encoding, text) { +function selectXPath (config, text) { const selectFn = combinators.curry(xPath.select, config.selector, config.ns, text); - return orderIndependent(select('xpath', selectFn, encoding)); + return orderIndependent(select('xpath', selectFn)); } function selectTransform (config, options, logger) { @@ -107,14 +104,14 @@ function selectTransform (config, options, logger) { cloned.jsonpath.selector = cloned.jsonpath.selector.toLowerCase(); } - return combinators.curry(selectJSONPath, cloned.jsonpath, options.encoding, config, stringTransform, logger); + return combinators.curry(selectJSONPath, cloned.jsonpath, config, stringTransform, logger); } else if (config.xpath) { if (!cloned.caseSensitive) { cloned.xpath.ns = transformObject(cloned.xpath.ns || {}, lowercase); cloned.xpath.selector = cloned.xpath.selector.toLowerCase(); } - return combinators.curry(selectXPath, cloned.xpath, options.encoding); + return combinators.curry(selectXPath, cloned.xpath); } else { return combinators.identity; @@ -143,15 +140,6 @@ function exceptTransform (config, logger) { } } -function encodingTransform (encoding) { - if (encoding === 'base64') { - return text => Buffer.from(text, 'base64').toString(); - } - else { - return combinators.identity; - } -} - function tryJSON (value, predicateConfig, logger) { try { const keyCaseTransform = predicateConfig.keyCaseSensitive === false ? lowercase : caseTransform(predicateConfig), @@ -167,11 +155,11 @@ function tryJSON (value, predicateConfig, logger) { } // eslint-disable-next-line max-params -function selectJSONPath (config, encoding, predicateConfig, stringTransform, logger, text) { +function selectJSONPath (config, predicateConfig, stringTransform, logger, text) { const possibleJSON = stringTransform(tryJSON(text, predicateConfig, logger)), selectFn = combinators.curry(jsonpath.select, config.selector, possibleJSON); - return orderIndependent(select('jsonpath', selectFn, encoding)); + return orderIndependent(select('jsonpath', selectFn)); } function transformAll (obj, keyTransforms, valueTransforms, arrayTransforms) { @@ -181,7 +169,7 @@ function transformAll (obj, keyTransforms, valueTransforms, arrayTransforms) { if (Array.isArray(obj)) { return apply(arrayTransforms)(obj.map(element => transformAll(element, keyTransforms, valueTransforms, arrayTransforms))); } - else if (isObject(obj)) { + else if (isObject(obj) && !Buffer.isBuffer(obj)) { return Object.keys(obj).reduce((accumulator, key) => { accumulator[apply(keyTransforms)(key)] = transformAll(obj[key], keyTransforms, valueTransforms, arrayTransforms); return accumulator; @@ -211,7 +199,17 @@ function normalize (obj, config, options, logger) { transforms.push(exceptTransform(config, logger)); transforms.push(caseTransform(config)); - transforms.push(encodingTransform(options.encoding)); + + obj = helpers.clone(obj); + if (options.binaryBody) { + if (obj.bodyEncoding) { + obj.body = Buffer.from(buffer.transcode(Buffer.from(obj.body), 'utf8', obj.bodyEncoding)); + } + else { + obj.body = Buffer.from(obj.body, 'base64'); + } + } + delete obj.bodyEncoding; // sort to provide deterministic comparison for deepEquals, // where the order in the array for multi-valued querystring keys @@ -289,6 +287,9 @@ function predicateSatisfied (expected, actual, predicateConfig, predicateFn) { // in the actual array return expectedMatchesAtLeastOneValueInActualArray(expected, actual, predicateConfig, predicateFn); } + else if (Buffer.isBuffer(expected[fieldName])) { + return predicateFn(expected[fieldName], actual[fieldName]); + } else if (isObject(expected[fieldName])) { return predicateSatisfied(expected[fieldName], actual[fieldName], predicateConfig, predicateFn); } @@ -299,30 +300,36 @@ function predicateSatisfied (expected, actual, predicateConfig, predicateFn) { } function create (operator, predicateFn) { - return (predicate, request, encoding, logger) => { - const expected = normalize(predicate[operator], predicate, { encoding: encoding }, logger), - actual = normalize(request, predicate, { encoding: encoding, withSelectors: true }, logger); + return (predicate, request, logger) => { + const binaryBody = typeof predicate[operator].body === 'string' && typeof request.body === 'string' + && (!predicate[operator].bodyEncoding || !request.bodyEncoding), + expected = normalize(predicate[operator], predicate, { binaryBody }, logger), + actual = normalize(request, predicate, { binaryBody, withSelectors: true }, logger); return predicateSatisfied(expected, actual, predicate, predicateFn); }; } -function deepEquals (predicate, request, encoding, logger) { - const expected = normalize(forceStrings(predicate.deepEquals), predicate, { encoding: encoding }, logger), - actual = normalize(forceStrings(request), predicate, { encoding: encoding, withSelectors: true, shouldForceStrings: true }, logger), +function deepEquals (predicate, request, logger) { + const expected = normalize(forceStrings(predicate.deepEquals), predicate, { }, logger), + actual = normalize(forceStrings(request), predicate, { withSelectors: true, shouldForceStrings: true }, logger), isObject = helpers.isObject; + if (Buffer.isBuffer(predicate.deepEquals.body)) { + throw errors.ValidationError('The deepEquals does not make sense in binary body'); + } + return Object.keys(expected).every(fieldName => { // Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies) if (isObject(expected[fieldName]) && typeof actual[fieldName] === 'string') { const possibleJSON = tryJSON(actual[fieldName], predicate); - actual[fieldName] = normalize(forceStrings(possibleJSON), predicate, { encoding: encoding }, logger); + actual[fieldName] = normalize(forceStrings(possibleJSON), predicate, { }, logger); } return stringify(expected[fieldName]) === stringify(actual[fieldName]); }); } -function matches (predicate, request, encoding, logger) { +function matches (predicate, request, logger) { // We want to avoid the lowerCase transform on values so we don't accidentally butcher // a regular expression with upper case metacharacters like \W and \S // However, we need to maintain the case transform for keys like http header names (issue #169) @@ -330,12 +337,12 @@ function matches (predicate, request, encoding, logger) { const caseSensitive = predicate.caseSensitive ? true : false, // convert to boolean even if undefined clone = helpers.merge(predicate, { caseSensitive: true, keyCaseSensitive: caseSensitive }), noexcept = helpers.merge(clone, { except: '' }), - expected = normalize(predicate.matches, noexcept, { encoding: encoding }, logger), - actual = normalize(request, clone, { encoding: encoding, withSelectors: true }, logger), + expected = normalize(predicate.matches, noexcept, { }, logger), + actual = normalize(request, clone, { withSelectors: true }, logger), options = caseSensitive ? '' : 'i'; - if (encoding === 'base64') { - throw errors.ValidationError('the matches predicate is not allowed in binary mode'); + if (predicate.matches.body && typeof predicate.matches.body === 'string' && !predicate.matches.bodyEncoding) { + throw errors.ValidationError('the matches predicate is not allowed for binary bodies'); } return predicateSatisfied(expected, actual, clone, (a, b) => { @@ -346,23 +353,23 @@ function matches (predicate, request, encoding, logger) { }); } -function not (predicate, request, encoding, logger, imposterState) { - return !evaluate(predicate.not, request, encoding, logger, imposterState); +function not (predicate, request, logger, imposterState) { + return !evaluate(predicate.not, request, logger, imposterState); } -function evaluateFn (request, encoding, logger, imposterState) { - return subPredicate => evaluate(subPredicate, request, encoding, logger, imposterState); +function evaluateFn (request, logger, imposterState) { + return subPredicate => evaluate(subPredicate, request, logger, imposterState); } -function or (predicate, request, encoding, logger, imposterState) { - return predicate.or.some(evaluateFn(request, encoding, logger, imposterState)); +function or (predicate, request, logger, imposterState) { + return predicate.or.some(evaluateFn(request, logger, imposterState)); } -function and (predicate, request, encoding, logger, imposterState) { - return predicate.and.every(evaluateFn(request, encoding, logger, imposterState)); +function and (predicate, request, logger, imposterState) { + return predicate.and.every(evaluateFn(request, logger, imposterState)); } -function inject (predicate, request, encoding, logger, imposterState) { +function inject (predicate, request, logger, imposterState) { if (request.isDryRun === true) { return true; } @@ -399,7 +406,7 @@ function toString (value) { } const predicates = { - equals: create('equals', (expected, actual) => toString(expected) === toString(actual)), + equals: create('equals', (expected, actual) => (Buffer.isBuffer(expected) ? expected.equals(actual) : toString(expected) === toString(actual))), deepEquals, contains: create('contains', (expected, actual) => actual.indexOf(expected) >= 0), startsWith: create('startsWith', (expected, actual) => actual.indexOf(expected) === 0), @@ -418,17 +425,16 @@ const predicates = { * Resolves all predicate keys in given predicate * @param {Object} predicate - The predicate configuration * @param {Object} request - The protocol request object - * @param {string} encoding - utf8 or base64 * @param {Object} logger - The logger, useful for debugging purposes * @param {Object} imposterState - The current state for the imposter * @returns {boolean} */ -function evaluate (predicate, request, encoding, logger, imposterState) { +function evaluate (predicate, request, logger, imposterState) { const predicateFn = Object.keys(predicate).find(key => Object.keys(predicates).indexOf(key) >= 0), clone = helpers.clone(predicate); if (predicateFn) { - return predicates[predicateFn](clone, request, encoding, logger, imposterState); + return predicates[predicateFn](clone, request, logger, imposterState); } else { throw errors.ValidationError('missing predicate', { source: predicate }); diff --git a/src/models/protocols.js b/src/models/protocols.js index 6e57483d0..5fcc77f50 100644 --- a/src/models/protocols.js +++ b/src/models/protocols.js @@ -41,8 +41,7 @@ function load (builtInProtocols, customProtocols, options, isAllowedConnection, metadata: server.metadata, stubs: stubs, resolver: resolver, - close: server.close, - encoding: server.encoding || 'utf8' + close: server.close }; }; } diff --git a/src/models/responseResolver.js b/src/models/responseResolver.js index fc3f9b2d8..f3e05af6e 100644 --- a/src/models/responseResolver.js +++ b/src/models/responseResolver.js @@ -62,6 +62,7 @@ function create (stubs, proxy, callbackURL) { try { const response = eval(injected); if (helpers.defined(response)) { + compatibility.upcastResponseEncoding(response); done(response); } } diff --git a/src/views/docs/api/contracts/imposter.ejs b/src/views/docs/api/contracts/imposter.ejs index 320d270a7..97171cc85 100644 --- a/src/views/docs/api/contracts/imposter.ejs +++ b/src/views/docs/api/contracts/imposter.ejs @@ -22,7 +22,7 @@ "Location": "http://example.com/resource" }, "body": "The time is ${TIME}", - "_mode": "text" + "_encoding": "utf-8" }, <%- indent(10) %>"repeat": 3, <%- indent(10) %>"behaviors": [ @@ -147,7 +147,7 @@ "Location": "http://example.com/resource" }, "body": "The time is now", - "_mode": "text" + "_encoding": "utf-8" } } ] diff --git a/src/views/docs/api/proxy/proxyModes.ejs b/src/views/docs/api/proxy/proxyModes.ejs index f9ba06d7c..2069c96cd 100644 --- a/src/views/docs/api/proxy/proxyModes.ejs +++ b/src/views/docs/api/proxy/proxyModes.ejs @@ -79,7 +79,7 @@ Accept: application/json "responses": [{ "is": { "body": "Downstream service response", - ... + ... } }] }, @@ -184,7 +184,7 @@ Accept: application/json "responses": [{ "is": { "body": "Request number 1", - ... + ... } }] } @@ -228,13 +228,13 @@ Accept: application/json { "is": { "body": "Request number 1", - ... + ... } }, { "is": { "body": "Request number 2", - ... + ... } } ] @@ -384,13 +384,13 @@ Accept: application/json { "is": { "body": "Request number 1", - ... + ... } }, { "is": { "body": "Request number 2", - ... + ... } } ] diff --git a/src/views/docs/protocols/http.ejs b/src/views/docs/protocols/http.ejs index f7ae3b4bd..0de8d13a9 100644 --- a/src/views/docs/protocols/http.ejs +++ b/src/views/docs/protocols/http.ejs @@ -158,9 +158,9 @@ field and explicitly set the Connection header to keep-alive< "" - _mode - string - binary or text - text + _encoding + string - utf-8 or iso-8859-1 or undefined if binary + undefined diff --git a/test/models/http/httpRequestTest.js b/test/models/http/httpRequestTest.js index 10046123f..7f0995f92 100644 --- a/test/models/http/httpRequestTest.js +++ b/test/models/http/httpRequestTest.js @@ -15,7 +15,7 @@ describe('HttpRequest', function () { socket: { remoteAddress: '', remotePort: '' }, setEncoding: mock(), url: 'http://localhost/', - rawHeaders: [] + rawHeaders: ['Content-Type', 'text/plain'] }); }); @@ -127,6 +127,7 @@ describe('HttpRequest', function () { it('should set body from data gzipped events', async function () { request.rawHeaders = [ 'Content-Encoding', 'gzip', + 'Content-Type', 'text/plain; charset=utf8', 'Host', '127.0.0.1:8000' ]; diff --git a/test/models/imposterTest.js b/test/models/imposterTest.js index 281d3ae3f..997342ff0 100644 --- a/test/models/imposterTest.js +++ b/test/models/imposterTest.js @@ -232,7 +232,7 @@ describe('imposter', function () { protocol: 'test', port: 3535, recordRequests: false, - stubs: [{ responses: [{ is: { body: 'body' } }] }] + stubs: [{ responses: [{ is: { bodyEncoding: 'utf8', body: 'body' } }] }] }); }); @@ -263,14 +263,14 @@ describe('imposter', function () { assert.deepEqual(json.stubs, [ { responses: [ - { is: { body: 'first' } }, + { is: { bodyEncoding: 'utf8', body: 'first' } }, { inject: 'inject' } ], _links: { self: { href: '/imposters/3535/stubs/0' } } }, { responses: [ - { is: { body: 'second' } } + { is: { bodyEncoding: 'utf8', body: 'second' } } ], _links: { self: { href: '/imposters/3535/stubs/1' } } } @@ -303,7 +303,7 @@ describe('imposter', function () { assert.deepEqual(json.stubs, [ { responses: [ - { is: { body: 'first' } }, + { is: { bodyEncoding: 'utf8', body: 'first' } }, { inject: 'inject' } ], _links: { self: { href: '/imposters/3535/stubs/0' } } diff --git a/test/models/predicates/containsTest.js b/test/models/predicates/containsTest.js index ec73d1d04..9fe5ecad6 100644 --- a/test/models/predicates/containsTest.js +++ b/test/models/predicates/containsTest.js @@ -60,9 +60,9 @@ describe('predicates', function () { }); it('should return true if contains binary sequence and encoding is base64', function () { - const predicate = { contains: { field: Buffer.from([2, 3]).toString('base64') } }, - request = { field: Buffer.from([1, 2, 3, 4]).toString('base64') }; - assert.ok(predicates.evaluate(predicate, request, 'base64')); + const predicate = { contains: { body: Buffer.from([2, 3]).toString('base64') } }, + request = { body: Buffer.from([1, 2, 3, 4]).toString('base64') }; + assert.ok(predicates.evaluate(predicate, request)); }); it('should return false if not contains binary sequence and encoding is base64', function () { diff --git a/test/models/predicates/endsWithTest.js b/test/models/predicates/endsWithTest.js index d321434be..69ec39edb 100644 --- a/test/models/predicates/endsWithTest.js +++ b/test/models/predicates/endsWithTest.js @@ -48,8 +48,8 @@ describe('predicates', function () { }); it('should return true if ends with binary sequence and encoding is base64', function () { - const predicate = { endsWith: { field: Buffer.from([2, 3, 4]).toString('base64') } }, - request = { field: Buffer.from([1, 2, 3, 4]).toString('base64') }; + const predicate = { endsWith: { body: Buffer.from([2, 3, 4]).toString('base64') } }, + request = { body: Buffer.from([1, 2, 3, 4]).toString('base64') }; assert.ok(predicates.evaluate(predicate, request, 'base64')); }); diff --git a/test/models/predicates/injectTest.js b/test/models/predicates/injectTest.js index e3d0a419e..485a279aa 100644 --- a/test/models/predicates/injectTest.js +++ b/test/models/predicates/injectTest.js @@ -39,7 +39,7 @@ describe('predicates', function () { request = {}; try { - predicates.evaluate(predicate, request, 'utf8', logger); + predicates.evaluate(predicate, request, logger); assert.fail('should have thrown exception'); } catch (error) { @@ -61,7 +61,7 @@ describe('predicates', function () { }, predicate = { inject: fn.toString() }, request = { path: '/', method: 'GET' }; - assert.ok(predicates.evaluate(predicate, request, 'utf8', mockedLogger, mockedImposterState)); + assert.ok(predicates.evaluate(predicate, request, mockedLogger, mockedImposterState)); assert.deepEqual(mockedImposterState, expectedImposterState); }); diff --git a/test/models/predicates/jsonpathTest.js b/test/models/predicates/jsonpathTest.js index 8b503d727..7e43ae927 100644 --- a/test/models/predicates/jsonpathTest.js +++ b/test/models/predicates/jsonpathTest.js @@ -251,7 +251,7 @@ describe('predicates', function () { matches: { body: '111\\.222\\.333\\.*' }, jsonpath: { selector: '$.ipAddress' } }, - request = { body: '{ "ipAddress": "111.222.333.456" }' }; + request = { bodyEncoding: 'utf8', body: '{ "ipAddress": "111.222.333.456" }' }; assert.ok(predicates.evaluate(predicate, request)); }); }); diff --git a/test/models/predicates/matchesTest.js b/test/models/predicates/matchesTest.js index 8b60877e5..20f9f86f5 100644 --- a/test/models/predicates/matchesTest.js +++ b/test/models/predicates/matchesTest.js @@ -55,14 +55,14 @@ describe('predicates', function () { it('should throw an error if encoding is base64', function () { try { - const predicate = { matches: { field: 'dGVzdA==' } }, - request = { field: 'dGVzdA==' }; - predicates.evaluate(predicate, request, 'base64'); + const predicate = { matches: { body: 'dGVzdA==' } }, + request = { body: 'dGVzdA==' }; + predicates.evaluate(predicate, request); assert.fail('should have thrown'); } catch (error) { assert.strictEqual(error.code, 'bad data'); - assert.strictEqual(error.message, 'the matches predicate is not allowed in binary mode'); + assert.strictEqual(error.message, 'the matches predicate is not allowed for binary bodies'); } }); diff --git a/test/models/predicates/startsWithTest.js b/test/models/predicates/startsWithTest.js index 6600feac2..39198b74e 100644 --- a/test/models/predicates/startsWithTest.js +++ b/test/models/predicates/startsWithTest.js @@ -48,9 +48,9 @@ describe('predicates', function () { }); it('should return true if starts with binary sequence and encoding is base64', function () { - const predicate = { startsWith: { field: Buffer.from([1, 2]).toString('base64') } }, - request = { field: Buffer.from([1, 2, 3, 4]).toString('base64') }; - assert.ok(predicates.evaluate(predicate, request, 'base64')); + const predicate = { startsWith: { body: Buffer.from([1, 2]).toString('base64') } }, + request = { body: Buffer.from([1, 2, 3, 4]).toString('base64') }; + assert.ok(predicates.evaluate(predicate, request)); }); it('should return false if does not start with binary sequence and encoding is base64', function () { diff --git a/test/models/predicates/xpathTest.js b/test/models/predicates/xpathTest.js index 58037dc10..879e9038c 100644 --- a/test/models/predicates/xpathTest.js +++ b/test/models/predicates/xpathTest.js @@ -317,14 +317,14 @@ describe('predicates', function () { assert.ok(!predicates.evaluate(predicate, request)); }); - it('should throw an error if encoding is base64', function () { + it('should throw an error if body is binary', function () { try { const predicate = { - equals: { field: 'dGVzdA==' }, + equals: { body: 'dGVzdA==' }, xpath: { selector: 'dGVzdA==' } }, - request = { field: 'dGVzdA==' }; - predicates.evaluate(predicate, request, 'base64'); + request = { body: 'dGVzdA==' }; + predicates.evaluate(predicate, request); assert.fail('should have thrown'); } catch (error) {