Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"update:pcdomainsapi": "cp node_modules/@internetx/internetx-swagger-files/src/pcdomains.json src/swagger",
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs",
"test:mocha": "nyc mocha \"tests/Specs/**/*.js\""
"test:mocha": "nyc mocha \"tests/Specs/**/*.js\"",
"test:integration": "nyc mocha \"tests/Integration/**/*.js\""
},
"nyc": {
"exclude": [
Expand All @@ -38,14 +39,14 @@
},
"devDependencies": {
"@internetx/internetx-swagger-files": "https://github.com/InterNetX/domainrobot-api.git",
"axios-mock-adapter": "^1.17.0",
"axios-mock-adapter": "^1.22.0",
"cross-env": "^6.0.3",
"mocha": "^9.2.2",
"nyc": "^15.1.0",
"webpack": "^5.99.5"
},
"dependencies": {
"axios": "^0.30.0",
"axios": "^1.18.1",
"chai": "^4.3.4",
"yarn.lock": "^0.0.1-security"
},
Expand Down
6 changes: 6 additions & 0 deletions src/Domainrobot.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// services

const ContactService = require("./services/ContactService")
const ContactDocumentService = require("./services/ContactDocumentService")
const CertificateService = require("./services/CertificateService")
const DomainStudio = require("./services/DomainStudio")
const DomainService = require("./services/DomainService")
Expand Down Expand Up @@ -33,6 +34,11 @@ class DomainRobot {
return new ContactService(this.domainRobotConfig)
}

// contact document stuff
contactDocument() {
return new ContactDocumentService(this.domainRobotConfig)
}

// certificate stuff
certificate() {
return new CertificateService(this.domainRobotConfig)
Expand Down
15 changes: 11 additions & 4 deletions src/services/ContactDocumentService.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ class ContactDocumentService extends DomainRobotService {
if (keys.length > 0) {
keysString = "?keys[]=" + keys.join("&keys[]=");
}
axios.defaults.headers.common['Content-Type'] =
"multipart/form-data";

// The base service sets a global default Content-Type of
// "application/json" (axios.defaults.headers.common). For a FormData
// upload that default would win and break the multipart request, so we
// clear it per-request. axios 1.x then auto-detects the FormData
// payload and sets "multipart/form-data" incl. the boundary itself.
this.axiosconfig.headers = {
...(this.axiosconfig.headers || {}),
"Content-Type": null,
};

return await this.sendPostRequest(
this.domainRobotConfig.url + "/contact/" + contactId + "/document/" + type + keysString,
formData
Expand Down Expand Up @@ -77,4 +84,4 @@ class ContactDocumentService extends DomainRobotService {
}
}

module.exports = Contact;
module.exports = ContactDocumentService;
89 changes: 89 additions & 0 deletions tests/Integration/ContactDocumentUpload.integration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* global describe, it, beforeEach, afterEach, require */

// Real HTTP integration test (NO axios-mock-adapter).
// It spins up a local HTTP server so the request actually travels through
// axios' real adapter. This is the only way to verify axios 1.x behaviour
// that a mock-adapter based test cannot reach: that a FormData payload
// produces a genuine `multipart/form-data` body incl. boundary.

const http = require("http");
const Domainrobot = require("../../src/Domainrobot");
const expect = require("chai").expect;

describe("ContactDocumentService upload (integration)", () => {
let server;
let port;
let lastRequest;

beforeEach((done) => {
lastRequest = null;
server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
lastRequest = {
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf8"),
};
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
stid: "integration-test",
status: { code: "S0000", type: "SUCCESS" },
})
);
});
});
server.listen(0, "127.0.0.1", () => {
port = server.address().port;
done();
});
});

afterEach((done) => {
server.close(done);
});

it("sends a genuine multipart/form-data body with boundary", async () => {
const baseUrl = `http://127.0.0.1:${port}`;

const formData = new FormData();
formData.append(
"file",
new Blob(["dummy-id-document-content"]),
"idcard.pdf"
);

const domainRobot = new Domainrobot({
url: baseUrl,
auth: { user: "user", password: "password", context: "9" },
});

const result = await domainRobot
.contactDocument()
.upload(1, "idcard", formData);

expect(result.status).to.be.equal(200);

expect(lastRequest, "server received a request").to.not.be.equal(null);
expect(lastRequest.method).to.be.equal("POST");
expect(lastRequest.url).to.be.equal("/contact/1/document/idcard");

// The key axios 1.x assertion: the wire Content-Type must be
// multipart/form-data WITH a boundary - not the global application/json
// default set in the DomainRobotService constructor.
const contentType = lastRequest.headers["content-type"];
expect(contentType, "Content-Type header").to.match(
/^multipart\/form-data; boundary=/
);

// The boundary from the header must actually delimit the body, i.e.
// axios produced a real multipart body from the FormData payload.
const boundary = contentType.split("boundary=")[1];
expect(lastRequest.body).to.contain(boundary);
expect(lastRequest.body).to.contain("idcard.pdf");
expect(lastRequest.body).to.contain("dummy-id-document-content");
});
});
184 changes: 184 additions & 0 deletions tests/Integration/DomainRobotService.integration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/* global describe, it, beforeEach, afterEach, require, Buffer */

// Real HTTP integration tests (NO axios-mock-adapter).
// These exercise axios 1.x behaviour that a mock-adapter based suite cannot
// reach, because the mock intercepts BELOW axios' real serialization/parsing:
// - strict JSON parsing of the response body
// - response headers exposed as an AxiosHeaders object
// - error handling: a non-2xx response throws and is mapped to
// DomainRobotException (axios 1.x consistently throws)
// - connection errors without a response are rethrown untouched
// - Basic auth + custom headers are actually put on the wire
// - JSON request bodies are serialized with application/json
//
// A local HTTP server captures the incoming request and replies with a
// per-test configurable responder.

const http = require("http");
const Domainrobot = require("../../src/Domainrobot");
const DomainRobotException = require("../../src/lib/DomainRobotException");
const ApiFactory = require("../../src/lib/Factory");
const domainrobot = require("../../src/swagger/domainrobot.json");
const expect = require("chai").expect;

const Backend = new ApiFactory(domainrobot);
const DomainRobotModels = Backend.models;

describe("DomainRobotService HTTP (integration)", () => {
let server;
let port;
let lastRequest;
let responder;

beforeEach((done) => {
lastRequest = null;

// default: 200 + a JSON body and a custom response header
responder = (req, res) => {
res.writeHead(200, {
"Content-Type": "application/json",
"X-Domainrobot-Stid": "stid-integration-123",
});
res.end(
JSON.stringify({
stid: "stid-integration-123",
status: { code: "S0000", type: "SUCCESS" },
})
);
};

server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
lastRequest = {
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf8"),
};
responder(req, res);
});
});
server.listen(0, "127.0.0.1", () => {
port = server.address().port;
done();
});
});

afterEach((done) => {
if (server) {
server.close(done);
} else {
done();
}
});

function makeClient() {
return new Domainrobot({
url: `http://127.0.0.1:${port}`,
auth: { user: "theuser", password: "thepass", context: "9" },
});
}

it("puts basic auth + context header on the wire and parses the JSON response", async () => {
const result = await makeClient().contact().info(42);

// --- wire ---
expect(lastRequest.method).to.be.equal("GET");
expect(lastRequest.url).to.be.equal("/contact/42");

const expectedAuth =
"Basic " + Buffer.from("theuser:thepass").toString("base64");
expect(lastRequest.headers["authorization"]).to.be.equal(expectedAuth);
expect(lastRequest.headers["x-domainrobot-context"]).to.be.equal("9");

// --- response: real JSON parsing by axios 1.x ---
expect(result.status).to.be.equal(200);
expect(result.result).to.be.an("object");
expect(result.result.status.type).to.be.equal("SUCCESS");
});

it("exposes response headers (AxiosHeaders) via getHeaders()", async () => {
const result = await makeClient().contact().info(1);

const headers = result.getHeaders();
expect(headers).to.not.be.equal(undefined);
// axios 1.x normalizes response header names to lower case
expect(headers["x-domainrobot-stid"]).to.be.equal(
"stid-integration-123"
);
});

it("serializes a model as an application/json request body", async () => {
const contact = new DomainRobotModels.Contact({
city: "Regensburg",
country: "DE",
});

const result = await makeClient().contact().create(contact);

expect(lastRequest.method).to.be.equal("POST");
expect(lastRequest.url).to.be.equal("/contact");
expect(lastRequest.headers["content-type"]).to.match(
/^application\/json/
);

// The body must be valid JSON (axios 1.x serialized it) ...
let parsed;
expect(() => {
parsed = JSON.parse(lastRequest.body);
}).to.not.throw();
expect(parsed).to.be.an("object");
expect(lastRequest.body).to.contain("Regensburg");

expect(result.status).to.be.equal(200);
});

it("throws DomainRobotException with status + body on a 4xx response", async () => {
responder = (req, res) => {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: { code: "EF02324", type: "ERROR" },
messages: ["Validation failed"],
})
);
};

let thrown;
try {
await makeClient().contact().info(1);
} catch (e) {
thrown = e;
}

expect(thrown, "an exception was thrown").to.not.be.equal(undefined);
expect(thrown.type).to.be.equal("DomainRobotException");
expect(thrown).to.be.an.instanceof(DomainRobotException);
expect(thrown.status).to.be.equal(400);
// the response body is mapped onto exception.error
expect(thrown.error.status.type).to.be.equal("ERROR");
});

it("rethrows connection errors that carry no response", async () => {
const client = makeClient();

// Close the server so the connection is refused (no HTTP response at all).
await new Promise((resolve) => server.close(resolve));
server = null; // afterEach guard: already closed

let thrown;
try {
await client.contact().info(1);
} catch (e) {
thrown = e;
}

expect(thrown, "an error was thrown").to.not.be.equal(undefined);
// took the `error.response === undefined` branch -> raw error, not a
// DomainRobotException
expect(thrown.response).to.be.equal(undefined);
expect(thrown.type).to.not.be.equal("DomainRobotException");
});
});
Loading
Loading