/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { HttpServer } = ChromeUtils.importESModule(
"resource://testing-common/httpd.sys.mjs"
);
const { NetworkTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/mailnews/NetworkTestUtils.sys.mjs"
);
const { HttpsProxy } = ChromeUtils.importESModule(
"resource://testing-common/mailnews/HttpsProxy.sys.mjs"
);
const { setTimeout, clearTimeout } = ChromeUtils.importESModule(
"resource://gre/modules/Timer.sys.mjs"
);
const { TestUtils } = ChromeUtils.importESModule(
"resource://testing-common/TestUtils.sys.mjs"
);
const { CommonUtils } = ChromeUtils.importESModule(
"resource://services-common/utils.sys.mjs"
);
const { fetchHTTP } = ChromeUtils.importESModule(
"resource:///modules/accountcreation/FetchHTTP.sys.mjs"
);
const mockServer = {
expectedRequests: [],
requests: [],
delayedResponses: [],
/**
* Initialize the mock server, including a HTTP server at http://test.test and a HTTPS variant at https://test.test.
*/
async init() {
this.server = new HttpServer();
this.server.start(-1);
this.server.identity.add("http", "test.test", 80);
this.server.identity.add("https", "test.test", 443);
NetworkTestUtils.configureProxy(
"test.test",
80,
this.server.identity.primaryPort
);
this.secureProxy = await HttpsProxy.create(
this.server.identity.primaryPort,
"valid",
"test.test"
);
this.reset();
},
/**
* Register an expected request. Can currently only handle one request per
* path.
*
* @param {object} requestInfo
* @param {string} requestInfo.path - The request path on the server.
* @param {string} requestInfo.method - The request method.
* @param {string} [requestInfo.body] - The body of the request.
* @param {string} [requestInfo.queryString=""] - Optional query string.
* @param {object} [requestInfo.headers={}] - Expected headers, if any.
* @param {string} [requestInfo.responseData] - The data to respond to the request with.
* @param {object} [requestInfo.responseHeaders={}] - Headers to set on the response.
* @param {number} [requestInfo.responseCode=200] - The code to respond with.
* @param {number} [requestInfo.delayResponseBy=0] - If the response should be delayed by the given amount of ms. If 0 a response is immediately returned.
*/
expectRequest({
path,
method,
body,
queryString = "",
headers = {},
responseData,
responseHeaders = {},
responseCode = 200,
delayResponseBy = 0,
}) {
const headerEntries = Object.entries(headers);
const responseHeaderEntries = Object.entries(responseHeaders);
this.expectedRequests.push({
path,
method,
queryString,
headers,
body,
});
this.server.registerPathHandler(path, (request, response) => {
this.requests.push(request);
info(
`${request.method} request to ${request.path}?${request.queryString}`
);
if (request.method === "POST" && body) {
request.bodyString = CommonUtils.readBytesFromInputStream(
request.bodyInputStream
);
}
if (
request.method === method &&
request.queryString == queryString &&
headerEntries.every(
([header, value]) => request.getHeader(header) === value
) &&
(!body || request.bodyString == body)
) {
response.setStatusLine(request.httpVersion, responseCode, "OK");
for (const [header, value] of responseHeaderEntries) {
response.setHeader(header, value);
}
if (responseData) {
response.write(responseData);
}
if (delayResponseBy > 0) {
response.seizePower();
this.delayedResponses.push(
/* eslint-disable mozilla/no-arbitrary-setTimeout */
setTimeout(() => {
response.finish();
}, delayResponseBy)
/* eslint-enable mozilla/no-arbitrary-setTimeout */
);
}
return;
}
response.setStatusLine("1.1", 404, "Not Found");
});
},
/**
* Check that all seend requests exactly match the expected requests. Only
* useful if the registration order of expected requests matches the actual
* order and there are no duplicate requests to the same path.
*/
checkRequests() {
Assert.equal(
this.requests.length,
this.expectedRequests.length,
"Should have received as many requests as expected"
);
Assert.ok(
this.expectedRequests.every((expectedRequest, index) => {
const request = this.requests[index];
return (
request.path === expectedRequest.path &&
request.method === expectedRequest.method &&
request.queryString == expectedRequest.queryString &&
Object.entries(expectedRequest.headers).every(
([header, value]) => request.getHeader(header) === value
) &&
(!expectedRequest.body || request.bodyString == expectedRequest.body)
);
}),
"Actual requests should match expected requests"
);
},
/**
* Reset the server handling state between tasks.
*/
reset() {
for (const expectedRequest of this.expectedRequests) {
this.server.registerPathHandler(expectedRequest.path, null);
}
for (const delayedResponse of this.delayedResponses) {
clearTimeout(delayedResponse);
}
this.requests = [];
this.expectedRequests = [];
this.delayedResponses = [];
},
cleanup() {
this.secureProxy.destroy();
this.server.stop();
},
};
/**
* Check if an error looks like a ServerException
*
* @param {Error} error - The error to check.
* @param {number} code - The code number to expect.
* @param {string} url - The URL the error should be thrown for.
* @param {Error} [cause] - The cause of the error, if expected.
* @returns {boolean} If the error looks like a ServerException.
*/
function checkServerException(error, code, url, cause) {
return (
Error.isError(error) &&
error.code == code &&
error.uri == url &&
error.url == url &&
(!cause || error.cause === cause)
);
}
add_setup(async () => {
do_get_profile();
await mockServer.init();
});
registerCleanupFunction(() => {
mockServer.cleanup();
});
add_task(async function test_get() {
mockServer.expectRequest({
path: "/testget",
method: "GET",
responseData: "foo",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("http://test.test/testget");
mockServer.checkRequests();
Assert.equal(result, "foo", "Should get a response");
mockServer.reset();
});
add_task(async function test_get_urlArgs() {
mockServer.expectRequest({
path: "/testget",
method: "GET",
queryString: "foo=b%C3%A4r&test=1&extra=lorem+ipsum",
responseData: "foo",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("http://test.test/testget", {
urlArgs: {
foo: "bär",
test: 1,
extra: "lorem ipsum",
},
});
mockServer.checkRequests();
Assert.equal(result, "foo", "Should get a response");
mockServer.reset();
});
add_task(async function test_get_headers() {
mockServer.expectRequest({
path: "/testget",
method: "GET",
headers: { Foo: "Bar" },
responseData: "baz",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("http://test.test/testget", {
headers: {
Foo: "Bar",
},
});
mockServer.checkRequests();
Assert.equal(result, "baz", "Should get response");
mockServer.reset();
});
add_task(async function test_get_authenticated() {
mockServer.expectRequest({
path: "/authenticated",
method: "GET",
headers: { Authorization: "Basic Zm9vOmJhcg==" },
responseData: "Success",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("http://test.test/authenticated", {
username: "foo",
password: "bar",
});
mockServer.checkRequests();
Assert.equal(result, "Success", "Should get secret response");
mockServer.reset();
});
add_task(async function test_get_responseJSON() {
const testData = {
foo: "bar",
lorem: {
ipsum: {
dolor: {
sit: ["amet"],
},
},
},
one: 1,
};
mockServer.expectRequest({
path: "/json",
method: "GET",
responseData: JSON.stringify(testData),
responseHeaders: {
"Content-Type": "application/json; charset=UTF-8",
},
});
mockServer.expectRequest({
path: "/textjson",
method: "GET",
responseData: JSON.stringify(testData),
responseHeaders: {
"Content-Type": "text/json; charset=UTF-8",
},
});
const applicationJSON = await fetchHTTP("http://test.test/json");
const textJSON = await fetchHTTP("http://test.test/textjson");
mockServer.checkRequests();
Assert.deepEqual(
applicationJSON,
testData,
"Should decode correct JSON data with application/json mimem type"
);
Assert.deepEqual(
textJSON,
testData,
"Should decode correct JSON data with text/json mime type"
);
mockServer.reset();
});
add_task(async function test_get_responseXML() {
const xml = "bar";
const xmlJXON = { foo: "bar", $foo: ["bar"] };
mockServer.expectRequest({
path: "/textxml",
method: "GET",
responseData: xml,
responseHeaders: {
"Content-Type": "text/xml; charset=UTF-8",
},
});
mockServer.expectRequest({
path: "/appxml",
method: "GET",
responseData: xml,
responseHeaders: {
"Content-Type": "application/xml; charset=UTF-8",
},
});
const textXML = await fetchHTTP("http://test.test/textxml");
const applicationXML = await fetchHTTP("http://test.test/appxml");
mockServer.checkRequests();
Assert.deepEqual(
textXML,
xmlJXON,
"Should return expected JXON for text/xml mime type"
);
Assert.deepEqual(
applicationXML,
xmlJXON,
"Should return expected JXON for application/xml mime type"
);
mockServer.reset();
});
add_task(async function test_get_https() {
mockServer.expectRequest({
path: "/gettest",
method: "GET",
responseData: "hi",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("https://test.test/gettest");
mockServer.checkRequests();
Assert.equal(result, "hi", "Should get expected response over HTTPS");
mockServer.reset();
});
add_task(async function test_get_responseDecodingError() {
mockServer.expectRequest({
path: "/invalidjson",
method: "GET",
responseData: "invalid JSON",
responseHeaders: {
"Content-Type": "text/json",
},
});
await Assert.rejects(
fetchHTTP("http://test.test/invalidjson"),
error => checkServerException(error, -4, "http://test.test/invalidjson"),
"Should reject when body can't be parsed"
);
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_get_serverError() {
mockServer.expectRequest({
path: "/server-error",
method: "GET",
responseData: "Oops",
responseHeaders: {
"Content-Type": "text/plain",
},
responseCode: 501,
});
await Assert.rejects(
fetchHTTP("http://test.test/server-error"),
error => checkServerException(error, 501, "http://test.test/server-error"),
"Should get the error returned by the server as a rejection"
);
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_get_abortSignal() {
const abortController = new AbortController();
const abortReason = new Error("test");
abortController.abort(abortReason);
mockServer.expectRequest({
path: "/aborted",
method: "GET",
delayResponseBy: 6000,
});
await Assert.rejects(
fetchHTTP("http://test.test/aborted", {
signal: abortController.signal,
}),
error => error === abortReason,
"Should immediately reject with the abort reason"
);
Assert.deepEqual(
mockServer.requests,
[],
"Should not have registered any requests"
);
const secondAbortController = new AbortController();
const inProgressFetch = fetchHTTP("http://test.test/aborted", {
timeout: 9000,
signal: secondAbortController.signal,
});
// Wait long enough for the request to have been started. In theory the abort
// could also be triggered before the request is sent, but that's much
// thougher timing.
await TestUtils.waitForCondition(() => mockServer.requests.length > 0);
secondAbortController.abort(abortReason);
await Assert.rejects(
inProgressFetch,
error =>
checkServerException(error, -2, "http://test.test/aborted", abortReason),
"Should abort with the given reason"
);
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_get_timeout() {
mockServer.expectRequest({
path: "/aborted",
method: "GET",
delayResponseBy: 6000,
});
await Assert.rejects(
fetchHTTP("http://test.test/aborted", {
timeout: 100,
}),
error => checkServerException(error, -2, "http://test.test/aborted"),
"Should reject due to the timeout"
);
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_get_redirect() {
mockServer.expectRequest({
path: "/redirect",
method: "GET",
headers: { Authorization: "Basic Zm9vOmJhcg==" },
responseCode: 301,
responseHeaders: {
Location: "http://example.com/",
},
});
await Assert.rejects(
fetchHTTP("http://test.test/redirect", {
username: "foo",
password: "bar",
}),
error => checkServerException(error, -2, "http://example.com/"),
"Should reject when redirecting"
);
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_get_offline() {
mockServer.expectRequest({
path: "/offline",
method: "GET",
});
Services.io.offline = true;
await Assert.rejects(
fetchHTTP("http://test.test/offline"),
error => checkServerException(error, -2, "http://test.test/offline"),
"Should reject when offline"
);
Services.io.offline = false;
Assert.deepEqual(
mockServer.requests,
[],
"Should not have gotten any request on the server"
);
mockServer.reset();
});
add_task(async function test_post() {
mockServer.expectRequest({
path: "/post",
method: "POST",
responseData: "Updated",
responseHeaders: {
"Content-Type": "text/plain",
},
});
const result = await fetchHTTP("http://test.test/post", {
post: true,
});
mockServer.checkRequests();
Assert.equal(result, "Updated", "Should get expected result");
mockServer.reset();
});
add_task(async function test_post_xml() {
const xml = "bar";
mockServer.expectRequest({
path: "/postxml",
method: "POST",
headers: {
"Content-Type": "application/xml; charset=UTF-8",
},
body: xml,
});
mockServer.expectRequest({
path: "/postxmlsimple",
method: "POST",
headers: {
"Content-Type": "text/xml; charset=UTF-8",
},
body: xml,
});
const parser = new DOMParser();
const parsedXML = parser.parseFromString(xml, "application/xml");
await fetchHTTP("http://test.test/postxml", {
post: true,
uploadBody: parsedXML,
headers: {
"Content-Type": "application/xml; charset=UTF-8",
},
});
info("Second request without headers");
await fetchHTTP("http://test.test/postxmlsimple", {
uploadBody: parsedXML,
});
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_post_json() {
const uploadBody = { foo: "bar" };
const json = JSON.stringify(uploadBody);
mockServer.expectRequest({
path: "/postjson",
method: "POST",
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
body: json,
});
mockServer.expectRequest({
path: "/postjsonsimple",
method: "POST",
headers: {
"Content-Type": "text/json; charset=UTF-8",
},
body: json,
});
await fetchHTTP("http://test.test/postjson", {
post: true,
uploadBody,
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
});
info("Second request without headers");
await fetchHTTP("http://test.test/postjsonsimple", {
uploadBody,
});
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_post_text() {
const uploadBody = "lorem ipsum dolor sit amet";
mockServer.expectRequest({
path: "/postjson",
method: "POST",
headers: {
"Content-Type": "text/plain; charset=UTF-8",
},
body: uploadBody,
});
await fetchHTTP("http://test.test/postjson", {
uploadBody,
});
mockServer.checkRequests();
mockServer.reset();
});
add_task(async function test_post_arbitraryBody() {
mockServer.expectRequest({
path: "/postfunction",
method: "POST",
body: (() => {}).toString(),
});
await fetchHTTP("http://test.test/postfunction", {
uploadBody: () => {},
});
mockServer.checkRequests();
mockServer.reset();
});