diff --git a/CHANGELOG.md b/CHANGELOG.md index 1243ab6..e256530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ Changelog ========= -1.0.1 (current) +1.1.0 (current) ------------------- +1.0.1 (2019-01-17) +------------------- +- FIX: Resolved issue with restricted "Accept" & "Content-Type" headers to support only "application/json" or "application/jose+json" 1.0.0 (2018-12-21) ------------------- diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index b2d83fb..49b1348 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -90,6 +90,7 @@ export default class ApiClient { if (this.isEncrypted) { contentType = "application/jose+json"; accept = "application/jose+json"; + this.createJoseJsonParser(); requestDataPromise = this.encryption.encrypt(data); } requestDataPromise.then((requestData) => { @@ -120,6 +121,7 @@ export default class ApiClient { if (this.isEncrypted) { contentType = "application/jose+json"; accept = "application/jose+json"; + this.createJoseJsonParser(); requestDataPromise = this.encryption.encrypt(data); } requestDataPromise.then((requestData) => { @@ -148,6 +150,7 @@ export default class ApiClient { if (this.isEncrypted) { contentType = "application/jose+json"; accept = "application/jose+json"; + this.createJoseJsonParser(); } request .get(`${this.server}/rest/v3/${partialUrl}`) @@ -170,14 +173,15 @@ export default class ApiClient { */ wrapCallback(httpMethod, callback = () => null) { return (err, res) => { - if (res && ((!this.isEncrypted && res.type !== "application/json") || (this.isEncrypted && res.type !== "application/jose+json"))) { + if (res && res.header && ((!this.isEncrypted && res.header["content-type"].indexOf("application/json") === -1) || + (this.isEncrypted && res.header["content-type"].indexOf("application/jose+json") === -1))) { callback([{ message: "Invalid Content-Type specified in Response Header", }], res ? res.body : undefined, res); return; } if (this.isEncrypted) { - this.processEncryptedResponse(httpMethod, err, res, callback); + this.processEncryptedResponse(httpMethod, err, res.body, callback); } else { this.processNonEncryptedResponse(err, res, callback); } @@ -222,20 +226,37 @@ export default class ApiClient { * @private */ processEncryptedResponse(httpMethod, err, res, callback) { - if (!err) { - let responseBody = res.rawResponse ? res.rawResponse : res.text; - try { - responseBody = this.encryption.base64Encode(JSON.parse(res.text)); - } catch (e) { - // nothing to do - } - this.encryption.decrypt(responseBody) - .then((decryptedData) => { - callback(undefined, JSON.parse(decryptedData.payload.toString()), decryptedData); - }) - .catch(() => callback(`Failed to decrypt response for ${httpMethod} request`, responseBody, responseBody)); - } else { - this.processNonEncryptedResponse(err, res, callback); + if (!res) { + callback("Try to decrypt empty response body", undefined, undefined); } + this.encryption.decrypt(res) + .then((decryptedData) => { + const responseBody = JSON.parse(decryptedData.payload.toString()); + if (responseBody.errors) { + const responseWithErrors = {}; + responseWithErrors.body = responseBody; + this.processNonEncryptedResponse(responseBody, responseWithErrors, callback); + } else { + callback(undefined, responseBody, decryptedData); + } + }) + .catch(() => callback(`Failed to decrypt response for ${httpMethod} request`, res, res)); + } + + /** + * Creates response body parser for application/jose+json content-type + * + * @private + */ + createJoseJsonParser() { + request.parse["application/jose+json"] = (res, callback) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + callback(null, data); + }); + }; } } diff --git a/test/utils/ApiClient.spec.js b/test/utils/ApiClient.spec.js index 9f15fcb..7e0b0d3 100644 --- a/test/utils/ApiClient.spec.js +++ b/test/utils/ApiClient.spec.js @@ -63,10 +63,36 @@ describe("utils/ApiClient", () => { test: "value", }) .query({ sort: "test" }) - .reply(201, { + .reply(201, { response: "value" }, { "Content-Type": "application/json" }); + + client.doPost("test", { test: "value" }, { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + body.should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(201); + + cb(); + }); + }); + + /** @test {ApiClient#doPost} */ + it("should return response if call was successful (with query parameters) when content type contains charset", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .matchHeader("Content-Type", "application/json") + .post("/rest/v3/test", { + test: "value", + }) + .query({ sort: "test" }) + .reply(201, { response: "value" }, { + "Content-Type": "application/json;charset=utf-8", + }); + client.doPost("test", { test: "value" }, { sort: "test" }, (err, body, res) => { expect(err).to.be.undefined(); @@ -81,7 +107,7 @@ describe("utils/ApiClient", () => { }); /** @test {ApiClient#doPost} */ - it("should return response if call was successful (without query parameters)", (cb) => { + it("should return response if call was successful (with query parameters) when content type contains charset ahead", (cb) => { nock("https://test-server") .matchHeader("Authorization", authHeader) .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) @@ -90,10 +116,36 @@ describe("utils/ApiClient", () => { .post("/rest/v3/test", { test: "value", }) - .reply(201, { + .query({ sort: "test" }) + .reply(201, { response: "value" }, { + "Content-Type": "charset=utf-8;application/json", + }); + + client.doPost("test", { test: "value" }, { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + JSON.parse(body.toString("utf8")).should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(201); + + cb(); + }); + }); + + /** @test {ApiClient#doPost} */ + it("should return response if call was successful (without query parameters)", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .matchHeader("Content-Type", "application/json") + .post("/rest/v3/test", { + test: "value", + }) + .reply(201, { response: "value" }, { "Content-Type": "application/json" }); + client.doPost("test", { test: "value" }, {}, (err, body, res) => { expect(err).to.be.undefined(); @@ -139,7 +191,7 @@ describe("utils/ApiClient", () => { relatedResources: ["trm-f3d38df1-adb7-4127-9858-e72ebe682a79", "trm-601b1401-4464-4f3f-97b3-09079ee7723b"], }], - }); + }, { "Content-Type": "application/json" }); client.doPost("test", { test: "value" }, {}, (err, body, res) => { err.should.be.deep.equal([{ @@ -185,7 +237,7 @@ describe("utils/ApiClient", () => { .matchHeader("Accept", "application/jose+json") .matchHeader("Content-Type", "application/jose+json") .post("/", /.+/) - .reply(200, encryption.base64Decode(encryptedBody), { + .reply(200, encryptedBody, { "Content-Type": "application/jose+json", }); @@ -203,6 +255,82 @@ describe("utils/ApiClient", () => { }); }); + /** @test {ApiClient#doPost} */ + it("should return encrypted response if encrypted POST call was successful when content type contains charset", (cb) => { + const clientPath = path.join(__dirname, "..", "resources", "private-jwkset1"); + const hwPath = path.join(__dirname, "..", "resources", "public-jwkset1"); + const clientWithEncryption = new ApiClient("test-username", "test-password", "https://test-server", { + clientPrivateKeySetPath: clientPath, + hyperwalletKeySetPath: hwPath, + }); + const encryption = new Encryption(clientPath, hwPath); + const testMessage = { + message: "Test message", + }; + + encryption.encrypt(testMessage).then((encryptedBody) => { + nock("https://test-server") + .filteringPath(() => "/") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/jose+json") + .matchHeader("Content-Type", "application/jose+json") + .post("/", /.+/) + .reply(200, encryptedBody, { + "Content-Type": "application/jose+json;charset=utf-8", + }); + + clientWithEncryption.doPost("test", { message: "Test message" }, {}, (err, body, res) => { + expect(err).to.be.undefined(); + + expect(res).to.not.be.undefined(); + + body.should.be.deep.equal({ + message: "Test message", + }); + + cb(); + }); + }); + }); + + /** @test {ApiClient#doPost} */ + it("should return error when encrypted response body is empty", (cb) => { + const clientPath = path.join(__dirname, "..", "resources", "private-jwkset1"); + const hwPath = path.join(__dirname, "..", "resources", "public-jwkset1"); + const clientWithEncryption = new ApiClient("test-username", "test-password", "https://test-server", { + clientPrivateKeySetPath: clientPath, + hyperwalletKeySetPath: hwPath, + }); + const encryption = new Encryption(clientPath, hwPath); + const testMessage = { + message: "Test message", + }; + + encryption.encrypt(testMessage).then(() => { + nock("https://test-server") + .filteringPath(() => "/") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/jose+json") + .matchHeader("Content-Type", "application/jose+json") + .post("/", /.+/) + .reply(200, null, { + "Content-Type": "application/jose+json", + }); + + clientWithEncryption.doPost("test", { message: "Test message" }, {}, (err, body, res) => { + expect(body).to.be.undefined(); + + expect(res).to.be.undefined(); + + err.should.be.deep.equal("Try to decrypt empty response body"); + + cb(); + }); + }); + }); + /** @test {ApiClient#doPost} */ it("should return error when fail to encrypt POST request body", (cb) => { const clientPath = path.join(__dirname, "..", "resources", "private-jwkset1"); @@ -271,10 +399,36 @@ describe("utils/ApiClient", () => { test: "value", }) .query({ sort: "test" }) - .reply(200, { + .reply(200, { response: "value" }, { "Content-Type": "application/json" }); + + client.doPut("test", { test: "value" }, { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + body.should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(200); + + cb(); + }); + }); + + /** @test {ApiClient#doPut} */ + it("should return response if call was successful (with query parameters) when content type contains charset", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .matchHeader("Content-Type", "application/json") + .put("/rest/v3/test", { + test: "value", + }) + .query({ sort: "test" }) + .reply(200, { response: "value" }, { + "Content-Type": "application/json;charset=utf-8", + }); + client.doPut("test", { test: "value" }, { sort: "test" }, (err, body, res) => { expect(err).to.be.undefined(); @@ -289,7 +443,7 @@ describe("utils/ApiClient", () => { }); /** @test {ApiClient#doPut} */ - it("should return response if call was successful (without query parameters)", (cb) => { + it("should return response if call was successful (with query parameters) when content type contains charset ahead", (cb) => { nock("https://test-server") .matchHeader("Authorization", authHeader) .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) @@ -298,10 +452,36 @@ describe("utils/ApiClient", () => { .put("/rest/v3/test", { test: "value", }) - .reply(200, { + .query({ sort: "test" }) + .reply(200, { response: "value" }, { + "Content-Type": "charset=utf-8;application/json", + }); + + client.doPut("test", { test: "value" }, { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + JSON.parse(body.toString("utf8")).should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(200); + + cb(); + }); + }); + + /** @test {ApiClient#doPut} */ + it("should return response if call was successful (without query parameters)", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .matchHeader("Content-Type", "application/json") + .put("/rest/v3/test", { + test: "value", + }) + .reply(200, { response: "value" }, { "Content-Type": "application/json" }); + client.doPut("test", { test: "value" }, {}, (err, body, res) => { expect(err).to.be.undefined(); @@ -347,7 +527,7 @@ describe("utils/ApiClient", () => { relatedResources: ["trm-f3d38df1-adb7-4127-9858-e72ebe682a79", "trm-601b1401-4464-4f3f-97b3-09079ee7723b"], }], - }); + }, { "Content-Type": "application/json" }); client.doPut("test", { test: "value" }, {}, (err, body, res) => { err.should.be.deep.equal([{ @@ -393,7 +573,46 @@ describe("utils/ApiClient", () => { .matchHeader("Accept", "application/jose+json") .matchHeader("Content-Type", "application/jose+json") .put("/", /.+/) - .reply(201, encryption.base64Decode(encryptedBody), { "Content-Type": "application/jose+json" }); + .reply(201, encryptedBody, { "Content-Type": "application/jose+json" }); + + clientWithEncryption.doPut("test", { message: "Test message" }, {}, (err, body, res) => { + expect(err).to.be.undefined(); + + expect(res).to.not.be.undefined(); + + body.should.be.deep.equal({ + message: "Test message", + }); + + cb(); + }); + }); + }); + + /** @test {ApiClient#doPut} */ + it("should return encrypted response if encrypted PUT call was successful when content type contains charset", (cb) => { + const clientPath = path.join(__dirname, "..", "resources", "private-jwkset1"); + const hwPath = path.join(__dirname, "..", "resources", "public-jwkset1"); + const clientWithEncryption = new ApiClient("test-username", "test-password", "https://test-server", { + clientPrivateKeySetPath: clientPath, + hyperwalletKeySetPath: hwPath, + }); + const encryption = new Encryption(clientPath, hwPath); + const testMessage = { + message: "Test message", + }; + + encryption.encrypt(testMessage).then((encryptedBody) => { + nock("https://test-server") + .filteringPath(() => "/") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/jose+json") + .matchHeader("Content-Type", "application/jose+json") + .put("/", /.+/) + .reply(201, encryptedBody, { + "Content-Type": "application/jose+json;charset=utf-8", + }); clientWithEncryption.doPut("test", { message: "Test message" }, {}, (err, body, res) => { expect(err).to.be.undefined(); @@ -465,7 +684,7 @@ describe("utils/ApiClient", () => { .matchHeader("Accept", "application/jose+json") .matchHeader("Content-Type", "application/jose+json") .put("/", /.+/) - .reply(201, encryption.base64Decode(encryptedBody), { "Content-Type": "application/jose+json" }); + .reply(201, encryptedBody, { "Content-Type": "application/jose+json" }); clientWithEncryption.doPut("test", { message: "Test message" }, {}, (err, body, res) => { err.should.be.deep.equal("Failed to decrypt response for PUT request"); @@ -487,37 +706,48 @@ describe("utils/ApiClient", () => { clientPrivateKeySetPath: clientPath, hyperwalletKeySetPath: hwPath, }); - - nock("https://test-server") - .filteringPath(() => "/") - .matchHeader("Authorization", authHeader) - .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) - .matchHeader("Accept", "application/jose+json") - .matchHeader("Content-Type", "application/jose+json") - .put("/", /.+/) - .reply(404, { - errors: [ - "test1", - "test2", - ], - }, { "Content-Type": "application/jose+json" }); - - clientWithEncryption.doPut("test", { message: "Test message" }, {}, (err, body, res) => { - err.should.be.deep.equal([ + const encryption = new Encryption(clientPath, hwPath); + const errorMessage = { + errors: [ "test1", "test2", - ]); + ], + }; - body.should.be.deep.equal({ - errors: [ + encryption.encrypt(errorMessage).then((encryptedBody) => { + nock("https://test-server") + .filteringPath(() => "/") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/jose+json") + .matchHeader("Content-Type", "application/jose+json") + .put("/", /.+/) + .reply(404, encryptedBody, { "Content-Type": "application/jose+json" }); + + clientWithEncryption.doPut("test", { message: "Test message" }, {}, (err, body, res) => { + err.should.be.deep.equal([ "test1", "test2", - ], - }); + ]); - res.status.should.be.equal(404); + body.should.be.deep.equal({ + errors: [ + "test1", + "test2", + ], + }); - cb(); + res.should.be.deep.equal({ + body: { + errors: [ + "test1", + "test2", + ], + }, + }); + + cb(); + }); }); }); }); @@ -551,10 +781,33 @@ describe("utils/ApiClient", () => { .matchHeader("Accept", "application/json") .get("/rest/v3/test") .query({ sort: "test" }) - .reply(200, { + .reply(200, { response: "value" }, { "Content-Type": "application/json" }); + + client.doGet("test", { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + body.should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(200); + + cb(); + }); + }); + + /** @test {ApiClient#doGet} */ + it("should return response if call was successful (with query parameters) when content type contains charset", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .get("/rest/v3/test") + .query({ sort: "test" }) + .reply(200, { response: "value" }, { + "Content-Type": "application/json;charset=utf-8", + }); + client.doGet("test", { sort: "test" }, (err, body, res) => { expect(err).to.be.undefined(); @@ -569,16 +822,39 @@ describe("utils/ApiClient", () => { }); /** @test {ApiClient#doGet} */ - it("should return response if call was successful (without query parameters)", (cb) => { + it("should return response if call was successful (with query parameters) when content type contains charset ahead", (cb) => { nock("https://test-server") .matchHeader("Authorization", authHeader) .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) .matchHeader("Accept", "application/json") .get("/rest/v3/test") - .reply(200, { + .query({ sort: "test" }) + .reply(200, { response: "value" }, { + "Content-Type": "charset=utf-8;application/json", + }); + + client.doGet("test", { sort: "test" }, (err, body, res) => { + expect(err).to.be.undefined(); + + JSON.parse(body.toString("utf8")).should.be.deep.equal({ response: "value", }); + res.status.should.be.equal(200); + + cb(); + }); + }); + + /** @test {ApiClient#doGet} */ + it("should return response if call was successful (without query parameters)", (cb) => { + nock("https://test-server") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/json") + .get("/rest/v3/test") + .reply(200, { response: "value" }, { "Content-Type": "application/json" }); + client.doGet("test", {}, (err, body, res) => { expect(err).to.be.undefined(); @@ -621,7 +897,7 @@ describe("utils/ApiClient", () => { relatedResources: ["trm-f3d38df1-adb7-4127-9858-e72ebe682a79", "trm-601b1401-4464-4f3f-97b3-09079ee7723b"], }], - }); + }, { "Content-Type": "application/json" }); client.doGet("test", {}, (err, body, res) => { err.should.be.deep.equal([{ @@ -666,7 +942,45 @@ describe("utils/ApiClient", () => { .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) .matchHeader("Accept", "application/jose+json") .get("/") - .reply(200, encryption.base64Decode(encryptedBody), { "Content-Type": "application/jose+json" }); + .reply(200, encryptedBody, { "Content-Type": "application/jose+json" }); + + clientWithEncryption.doGet("test", {}, (err, body, res) => { + expect(err).to.be.undefined(); + + expect(res).to.not.be.undefined(); + + body.should.be.deep.equal({ + message: "Test message", + }); + + cb(); + }); + }); + }); + + /** @test {ApiClient#doGet} */ + it("should return encrypted response if encrypted GET call was successful when content type contains charset", (cb) => { + const clientPath = path.join(__dirname, "..", "resources", "private-jwkset1"); + const hwPath = path.join(__dirname, "..", "resources", "public-jwkset1"); + const clientWithEncryption = new ApiClient("test-username", "test-password", "https://test-server", { + clientPrivateKeySetPath: clientPath, + hyperwalletKeySetPath: hwPath, + }); + const encryption = new Encryption(clientPath, hwPath); + const testMessage = { + message: "Test message", + }; + + encryption.encrypt(testMessage).then((encryptedBody) => { + nock("https://test-server") + .filteringPath(() => "/") + .matchHeader("Authorization", authHeader) + .matchHeader("User-Agent", `Hyperwallet Node SDK v${packageJson.version}`) + .matchHeader("Accept", "application/jose+json") + .get("/") + .reply(200, encryptedBody, { + "Content-Type": "application/jose+json;charset=utf-8", + }); clientWithEncryption.doGet("test", {}, (err, body, res) => { expect(err).to.be.undefined(); @@ -803,7 +1117,7 @@ describe("utils/ApiClient", () => { cb(); }); const rawRes = { - text: JSON.stringify(encryption.base64Decode(encryptedBody)), + body: encryptedBody, status: 200, type: "application/jose+json", }; @@ -817,7 +1131,9 @@ describe("utils/ApiClient", () => { const rawRes = { body: "test", status: 200, - type: "wrongContentType", + header: { + "content-type": "wrongContentType", + }, }; const callback = client.wrapCallback("POST", (err, body, res) => { diff --git a/test/utils/Encryption.spec.js b/test/utils/Encryption.spec.js index a522900..705eeb8 100644 --- a/test/utils/Encryption.spec.js +++ b/test/utils/Encryption.spec.js @@ -59,6 +59,16 @@ describe("utils/Encryption", () => { }); }); + /** @test {Encryption#encrypt} */ + it("should successfully decode and encode encrypted text message", (cb) => { + encryption.encrypt(testMessage).then((encryptedBody) => { + const decodedMessage = encryption.base64Decode(encryptedBody); + const encodedMessage = encryption.base64Encode(decodedMessage); + encodedMessage.should.be.deep.equal(encryptedBody); + cb(); + }); + }); + /** @test {Encryption#encrypt} */ it("should throw exception when wrong jwk key set location is given", (cb) => { encryption = new Encryption("wrong_keyset_path", hwPath);