diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..541b904 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: +jobs: + integration-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [13.x, 20.x, 22.x, 23.x] + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - run: npm test diff --git a/README.md b/README.md index b989434..0b5babb 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,115 @@ # node-XMLHttpRequest # -node-XMLHttpRequest is a wrapper for the built-in http client to emulate the -browser XMLHttpRequest object. +Fork of [node-XMLHttpRequest](https://github.com/driverdan/node-XMLHttpRequest) by [driverdan](http://driverdan.com). Forked and published to npm because a [pull request](https://github.com/rase-/node-XMLHttpRequest/commit/a6b6f296e0a8278165c2d0270d9840b54d5eeadd) is not being created and merged. Changes made by [rase-](https://github.com/rase-/node-XMLHttpRequest/tree/add/ssl-support) are needed for [engine.io-client](https://github.com/Automattic/engine.io-client). -This can be used with JS designed for browsers to improve reuse of code and -allow the use of existing libraries. - -Note: This library currently conforms to [XMLHttpRequest 1](http://www.w3.org/TR/XMLHttpRequest/). Version 2.0 will target [XMLHttpRequest Level 2](http://www.w3.org/TR/XMLHttpRequest2/). - -## Usage ## +## Usage ## Here's how to include the module in your project and use as the browser-based XHR object. - var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest; + var XMLHttpRequest = require("xmlhttprequest-ssl").XMLHttpRequest; var xhr = new XMLHttpRequest(); -Note: use the lowercase string "xmlhttprequest" in your require(). On +Note: use the lowercase string "xmlhttprequest-ssl" in your require(). On case-sensitive systems (eg Linux) using uppercase letters won't work. +## Non-standard features ## +### Additional options ### + +Non-standard options for this module are passed through the `XMLHttpRequest` constructor. Here is the list of all options: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDefault valueDescription
caundefinedControl https: requests, you can find their functionality in the Nodejs Documentation
cert
ciphers
key
passhphrase
pfx
rejectUnauthorizedtrue
agentundefinedAllows to specify a Nodejs Agent instance, allowing connection reuse
autoUnreffalseSet to any truthy value to prevent a process from not exiting naturally because a request socket from this module is still open
maxRedirects20Allows control over the maximum number of redirects that are followed
allowFileSystemResourcestrueAllows control access to the local filesystem through the file: protocol
originundefinedSet a base URL for the requests called using this instance. The resulting request URL will be constructed as follows: new URL(url, origin)
syncPolicy"warn"Control feature behavior of the synchronous feature:
  • "disabled": Disable the feature completely, throws error after calling .send() if in synchronous mode
  • "warn": Enable the feature, but show a warning when calling .open() with synchronous mode
  • "enabled": Enable the feature without showing any additional warnings or errors
disableHeaderCheckfalseDisable the check against forbidden headers to be added to a XHR request
xmlParsernoneSpecify a parser (non-async) to parse document from text when xhr.responseType is either "document" or in text format. If the parser is invalid or omitted, xhr.responseXML will be null
textDecoderTextDecoder or buf.toString(enc) depending on Node versionSpecify a text decoder (non-async), accepting a buffer buf and encoding enc to decode to desired encoding.
Note that TextDecoder at version 12 does not support a wide range of encodings than later node version does
+ +### Additional methods ### +`XMLHttpRequest` object created using this library exposes `xhr.getRequestHeader(header_name)` method to retrieve any header content by name in the request headers list. This feature is deprecated and will be removed in future releases. + +# Original README # + ## Versions ## +Version 2.0.0 introduces a potentially breaking change concerning local file system requests. +If these requests fail this library now returns the `errno` (or -1) as the response status code instead of +returning status code 0. + Prior to 1.4.0 version numbers were arbitrary. From 1.4.0 on they conform to the standard major.minor.bugfix. 1.x shouldn't necessarily be considered stable just because it's above 0.x. diff --git a/example/demo.js b/example/demo.js index 4f333de..1872ab5 100644 --- a/example/demo.js +++ b/example/demo.js @@ -4,11 +4,11 @@ var XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest; var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { - sys.puts("State: " + this.readyState); - + console.log("State: " + this.readyState); + if (this.readyState == 4) { - sys.puts("Complete.\nBody length: " + this.responseText.length); - sys.puts("Body:\n" + this.responseText); + console.log("Complete.\nBody length: " + this.responseText.length); + console.log("Body:\n" + this.responseText); } }; diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 52e36b3..f37edf0 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -12,8 +12,31 @@ */ var fs = require('fs'); -var Url = require('url'); +var os = require('os'); +var path = require('path'); var spawn = require('child_process').spawn; +/** + * Constants + */ + +var stateConstants = { + UNSENT: 0, + OPENED: 1, + HEADERS_RECEIVED: 2, + LOADING: 3, + DONE: 4 +}; + +var assignStateConstants = function (object) { + for (let stateKey in stateConstants) Object.defineProperty(object, stateKey, { + enumerable: true, + writable: false, + configurable: false, + value: stateConstants[stateKey] + }); +} + +assignStateConstants(XMLHttpRequest); /** * Module exports. @@ -35,6 +58,93 @@ XMLHttpRequest.XMLHttpRequest = XMLHttpRequest; */ function XMLHttpRequest(opts) { + "use strict"; + + if (!new.target) { + throw new TypeError("Failed to construct 'XMLHttpRequest': Please use the 'new' operator, this object constructor cannot be called as a function."); + } + + var dataMap = Object.create(null); + + /** + * Safely assign any key with value to an object, preventing prototype pollution + * @param {any} obj Object to assign + * @param {any} key key name + * @param {any} value value to assign + * @param {boolean} assignable whether user can change this value (this defaults to `true` when value is a function) + */ + var assignProp = function (obj, key, value, assignable) { + if ("function" === typeof value) Object.defineProperty(obj, key, { + value: value, + writable: true, + enumerable: true, + configurable: true + }); + else if (assignable) Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: function (value) { dataMap[key] = value; }, + enumerable: true, + configurable: true + }); + else Object.defineProperty(obj, key, { + get: function () { return dataMap[key]; }, + set: undefined, + enumerable: true, + configurable: true + }); + } + + // defines a list of default options to prevent parameters pollution + var default_options = { + pfx: undefined, + key: undefined, + passphrase: undefined, + cert: undefined, + ca: undefined, + ciphers: undefined, + rejectUnauthorized: true, + autoUnref: false, + agent: undefined, + allowFileSystemResources: true, + maxRedirects: 20, // Chrome standard + syncPolicy: "warn", + disableHeaderCheck: false, + xmlParser: function (text) { + return null; + }, + textDecoder: function (buf, enc) { + if ("function" === typeof TextDecoder) try { + return new TextDecoder(enc).decode(buf); + } + catch (e) {} + return buf.toString(enc); + }, + origin: undefined + }; + + opts = Object.assign(Object.create(null), default_options, opts); + + if (opts.syncPolicy !== "warn" && opts.syncPolicy !== "disabled" && opts.syncPolicy !== "enabled") { + opts.syncPolicy = "warn"; + } + + for (var i of ["xmlParser", "textDecoder"]) { + if (typeof opts[i] !== "function") { + //@TODO: find a reliable way to check if function is async + opts[i] = default_options[i]; + } + } + + var sslOptions = { + pfx: opts.pfx, + key: opts.key, + passphrase: opts.passphrase, + cert: opts.cert, + ca: opts.ca, + ciphers: opts.ciphers, + rejectUnauthorized: opts.rejectUnauthorized !== false + }; + /** * Private variables */ @@ -42,16 +152,20 @@ function XMLHttpRequest(opts) { var http = require('http'); var https = require('https'); + var maxRedirects = opts.maxRedirects; + if (typeof maxRedirects !== 'number' || Number.isNaN(maxRedirects)) maxRedirects = 20; + else maxRedirects = Math.max(maxRedirects, 0); + + var redirectCount = 0; + // Holds http.js objects var request; var response; // Request settings - var settings = {}; + var settings = Object.create(null); - // Disable header blacklist. - // Not part of XHR specs. - var disableHeaderCheck = false; + assignStateConstants(this); // Set some default headers var defaultHeaders = { @@ -59,7 +173,7 @@ function XMLHttpRequest(opts) { "Accept": "*/*" }; - var headers = defaultHeaders; + var headers = Object.assign(Object.create(null), defaultHeaders); // These headers are not user setable. // The following are allowed but banned in the spec: @@ -98,35 +212,46 @@ function XMLHttpRequest(opts) { var sendFlag = false; // Error flag, used when errors occur or abort is called var errorFlag = false; + var abortedFlag = false; - // Event listeners - var listeners = {}; + // Custom encoding (if user called via xhr.overrideMimeType) + var customEncoding = ""; - /** - * Constants - */ + // Event listeners + var listeners = Object.create(null); - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; + // private ready state (not exposed so user cannot modify) + var readyState = this.UNSENT; /** * Public vars */ - // Current state - this.readyState = this.UNSENT; + Object.defineProperty(this, "readyState", { + get: function () { return readyState; }, + configurable: true, + enumerable: true + }); // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; + assignProp(this, 'onreadystatechange', null, true); // Result & response - this.responseText = ""; - this.responseXML = ""; - this.status = null; - this.statusText = null; + assignProp(this, 'responseText', ""); + assignProp(this, "responseXML", ""); + assignProp(this, "responseURL", ""); + assignProp(this, "response", Buffer.alloc(0)); + assignProp(this, "status", null); + assignProp(this, "statusText", null); + + // xhr.responseType is supported: + // When responseType is 'text' or '', self.responseText will be utf8 decoded text. + // When responseType is 'json', self.responseText initially will be utf8 decoded text, + // which is then JSON parsed into self.response. + // When responseType is 'arraybuffer', self.response is an ArrayBuffer. + // When responseType is 'blob', self.response is a Blob. + // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + assignProp(this, "responseType", "", true); /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ /** * Private methods @@ -139,7 +264,7 @@ function XMLHttpRequest(opts) { * @return boolean False if not allowed, otherwise true */ var isAllowedHttpHeader = function(header) { - return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); + return opts.disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); }; /** @@ -152,10 +277,139 @@ function XMLHttpRequest(opts) { return (method && forbiddenRequestMethods.indexOf(method) === -1); }; + /** + * Given a Buffer buf, check whether buf.buffer.byteLength > buf.length and if so, + * create a new ArrayBuffer whose byteLength is buf.length, containing the bytes. + * of buf. This function shouldn't usually be needed, unless there's a future + * behavior change where buf.buffer.byteLength > buf.length unexpectedly. + * + * @param {Buffer} buf + * @returns {ArrayBuffer} + */ + var checkAndShrinkBuffer = function(buf) { + if (buf.length === buf.buffer.byteLength) + return buf.buffer; + const ab = new ArrayBuffer(buf.length); + const result = Buffer.from(ab); + for (let k = 0; k < buf.length; k++) + result[k] = buf[k]; + return ab; + } + + /** + * Given the user-input (or Content-Type header value) of MIME type, + * Parse given string to retrieve mimeType and its encoding (defaults to utf8 if not exists) + * @param {string} contentType + */ + var parseContentType = function (contentType) { + const regex = /([a-zA-Z0-9!#$%&'*+.^_`|~-]+\/[a-zA-Z0-9!#$%&'*+.^_`|~-]+)(?:; charset=([a-zA-Z0-9-]+))?/; + + const matches = contentType.toLowerCase().match(regex); + + if (matches) { + const mimeType = matches[1]; + const charset = matches[2] || 'utf-8'; + + return { mimeType, charset }; + } else { + return { mimeType: "", charset: "utf-8" } + } + } + + /** + * Called when an error is encountered to deal with it. + * @param status {number} HTTP status code to use rather than the default (0) for XHR errors. + */ + var handleError = function(error, status) { + dataMap.status = status || 0; + dataMap.statusText = error.message || ""; + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.responseURL = ""; + dataMap.response = Buffer.alloc(0); + errorFlag = true; + setState(self.DONE); + if (!settings.async) throw error; + }; + + /** + * Construct the correct form of response, given default content type + * + * The input is the response parameter which is a Buffer. + * When self.responseType is "", "text", + * the input is further refined to be: new TextDecoder(encoding).decode(response), + * encoding is defined either by `Content-Type` header or set through `xhr.overrideMimetype()`. + * When self.responseType is "json", + * the input is further refined to be: JSON.parse(response.toString('utf8')). + * When self.responseType is "arraybuffer", "blob", + * the input is further refined to be: checkAndShrinkBuffer(response). + * A special case is when self.responseType is "document", + * the decoded text will be passed to a parser function to create a DOM, or returns `null` + * + * @param {Buffer} response + */ + var createResponse = function(response, customContentType) { + dataMap.responseText = null; + dataMap.responseXML = null; + switch (self.responseType) { + case 'json': + dataMap.response = JSON.parse(response.toString('utf8')); + break; + case 'blob': + case 'arraybuffer': + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + dataMap.response = checkAndShrinkBuffer(response); + if (dataMap.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + dataMap.response = new Blob([self.response]); + } + break; + default: + try { + dataMap.responseText = opts.textDecoder.call(opts, response, customEncoding || parseContentType(String(customContentType)).charset); + } + catch (e) { + // fall back to utf8 ONLY if custom encoding is present + if (customEncoding) dataMap.responseText = response.toString('utf8'); + else dataMap.responseText = ""; + } + dataMap.response = self.responseText; + try { dataMap.responseXML = opts.xmlParser.call(opts, self.responseText); } + catch (e) { dataMap.responseXML = null; } + } + + // Special handling of self.responseType === 'document' + if (dataMap.responseType === 'document') { + dataMap.response = self.responseXML; + dataMap.responseText = null; + } + } + /** * Public methods */ + /** + * Acts as if the Content-Type header value for a response is mime. (It does not change the header.) + * Throws an error if state is LOADING or DONE. + * + * @param {string} mimeType - The MIME type to override with (e.g., "text/plain; charset=UTF-8"). + */ + assignProp(this, 'overrideMimeType', function(mimeType) { + if (arguments.length === 0) { + throw new TypeError("Failed to execute 'overrideMimeType' on 'XMLHttpRequest': 1 argument required, but only 0 present."); + } + + // check if state is LOADING or DONE + if (readyState === this.LOADING || readyState === this.DONE) { + throw new Error("INVALID_STATE_ERR: MimeType cannot be overridden when the state is LOADING or DONE."); + } + + // parse mimeType from given string and set custom charset + customEncoding = parseContentType(String(mimeType)).charset; + }); + /** * Open the connection. Currently supports local server requests. * @@ -165,55 +419,78 @@ function XMLHttpRequest(opts) { * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ - this.open = function(method, url, async, user, password) { - this.abort(); + assignProp(this, 'open', function(method, url, async, user, password) { + abort(); errorFlag = false; + abortedFlag = false; // Check for valid request method if (!isAllowedHttpMethod(method)) { - throw "SecurityError: Request method not allowed"; + throw new Error("SecurityError: Request method not allowed"); } settings = { - "method": method, - "url": url.toString(), + "method": method.toUpperCase(), + "url": url, "async": (typeof async !== "boolean" ? true : async), "user": user || null, "password": password || null }; - setState(this.OPENED); - }; + // check for sync + if (opts.syncPolicy === "warn" && !settings.async) { + console.warn("[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + } - /** - * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. - * This does not conform to the W3C spec. - * - * @param boolean state Enable or disable header checking. - */ - this.setDisableHeaderCheck = function(state) { - disableHeaderCheck = state; - }; + // parse origin + try { + settings.origin = new URL(opts.origin); + } + catch (e) { + settings.origin = null; + } + + setState(this.OPENED); + }); /** * Sets a header for the request. * * @param string header Header name * @param string value Header value + * @return boolean Header added */ - this.setRequestHeader = function(header, value) { - if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"; + assignProp(this, 'setRequestHeader', function(header, value) { + if (readyState != this.OPENED) { + throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); } if (!isAllowedHttpHeader(header)) { console.warn('Refused to set unsafe header "' + header + '"'); - return; + return false; } if (sendFlag) { - throw "INVALID_STATE_ERR: send flag is true"; + throw new Error("INVALID_STATE_ERR: send flag is true"); } headers[header] = value; - }; + return true; + }); + + /** + * Gets a request header + * + * @deprecated + * @param string name Name of header to get + * @return string Returns the request header or empty string if not set + */ + assignProp(this, 'getRequestHeader', function(name) { + // @TODO Make this case insensitive + console.warn("`xhr.getRequestHeader()` is deprecated and will be removed in a future release. It’s non-standard and not part of the XHR spec."); + if (typeof name === "string" && headers[name]) { + return headers[name]; + } + + return ""; + }); /** * Gets a header from the server response. @@ -221,25 +498,29 @@ function XMLHttpRequest(opts) { * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ - this.getResponseHeader = function(header) { + assignProp(this, 'getResponseHeader', function(header) { + // in case of local request, headers are not present if (typeof header === "string" - && this.readyState > this.OPENED + && readyState > this.OPENED && response.headers[header.toLowerCase()] && !errorFlag + && response + && response.headers ) { return response.headers[header.toLowerCase()]; } return null; - }; + }); /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ - this.getAllResponseHeaders = function() { - if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { + assignProp(this, 'getAllResponseHeaders', function() { + // in case of local request, headers are not present + if (readyState < this.HEADERS_RECEIVED || errorFlag || !response || !response.headers) { return ""; } var result = ""; @@ -250,52 +531,113 @@ function XMLHttpRequest(opts) { result += i + ": " + response.headers[i] + "\r\n"; } } - return result.substr(0, result.length - 2); - }; + return result.slice(0, -2); + }); /** - * Gets a request header - * - * @param string name Name of header to get - * @return string Returns the request header or empty string if not set + * Convert from Data URI to Buffer + * @param {URL} url URI to parse + * @returns {Buffer} buffer */ - this.getRequestHeader = function(name) { - // @TODO Make this case insensitive - if (typeof name === "string" && headers[name]) { - return headers[name]; + + var bufferFromDataUri = function (url) { + // Triming from original url object for more consistency + var data = url.href.slice(5); + + // separator between header and actual data + var parts = data.split(",", 2); + + if (parts.length < 2) throw "Invalid URL"; + + var dataHeaders = parts[0].split(";"); + + var base64 = false, charset; + + // check if header part has base64 (from 2nd header onwards) + // also get charset encoding of data URI (from FIRST found only) + for (var i = 1; i < dataHeaders.length; ++i) { + if (base64 && charset) break; + var header = dataHeaders[i]; + + if (!base64) base64 = header.toLowerCase() === "base64"; + if (!charset && header.startsWith("charset=")) { + charset = header.slice(8).toLowerCase(); + } } - return ""; - }; + var responseData, inputData = decodeURIComponent(parts[1]); + + if (base64) { + // remove any ASCII whitespaces + inputData = inputData.replace(/(\s|\t|\r|\n|\v|\f)+/g, ""); + // check padding amount + let padding = inputData.match(/=*$/)[0]; + if (padding.length + (inputData.length - padding.length) % 4 > 4) throw "invalid padding"; + inputData = inputData.slice(0, inputData.length - padding.length); + responseData = Buffer.from(inputData, "base64"); + if (responseData.toString("base64").replace(/=+$/, "") !== inputData) throw "malformed base64 encoding"; + return { + data: responseData, + charset: charset || "utf-8" + } + } + else { + return { + data: Buffer.from(inputData), + charset: charset || "utf-8" + } + } + } /** * Sends the request to the server. * * @param string data Optional data to send as request body. */ - this.send = function(data) { - if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: connection must be opened before send() is called"; + assignProp(this, 'send', function(data) { + if (readyState != this.OPENED) { + throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); } if (sendFlag) { - throw "INVALID_STATE_ERR: send has already been called"; + throw new Error("INVALID_STATE_ERR: send has already been called"); + } + + if (opts.syncPolicy === "disabled" && !settings.async) { + throw new Error("Synchronous requests are disabled for this instance."); } - var ssl = false, local = false; - var url = Url.parse(settings.url); + var isSsl = false, isLocal = false, isDataUri = false; + var url; + try { + if (settings.origin) { + url = new URL(settings.url, settings.origin); + } + else { + url = new URL(settings.url); + } + settings.url = url.href; + } + catch (e) { + // URL parsing throws TypeError, here we only want to take its message + handleError(new Error(e.message)); + return; + } var host; // Determine the server switch (url.protocol) { case 'https:': - ssl = true; + isSsl = true; // SSL & non-SSL both need host, no break here. case 'http:': host = url.hostname; break; + case 'data:': + isDataUri = true; + case 'file:': - local = true; + isLocal = true; break; case undefined: @@ -304,32 +646,56 @@ function XMLHttpRequest(opts) { break; default: - throw "Protocol not supported."; + throw new Error("Protocol not supported."); } // Load files off the local filesystem (file://) - if (local) { + // or data from Data URI (data:) + if (isLocal) { + if (isDataUri) try { + dataMap.status = 200; + dataMap.responseURL = settings.url; + var uriData = bufferFromDataUri(url); + createResponse(uriData.data, "text/plain; charset=" + uriData.charset); + setState(self.DONE); + return; + } + catch (e) { + handleError(new Error("Invalid data URI")); + return; + } + + if (!opts.allowFileSystemResources) { + handleError(new Error("Not allowed to load local resource: " + url.href)); + return; + } + if (settings.method !== "GET") { - throw "XMLHttpRequest: Only GET method is supported"; + throw new Error("XMLHttpRequest: Only GET method is supported"); } if (settings.async) { - fs.readFile(url.pathname, 'utf8', function(error, data) { + fs.readFile(unescape(url.pathname), function(error, data) { if (error) { - self.handleError(error); + handleError(error, error.errno || -1); } else { - self.status = 200; - self.responseText = data; + dataMap.status = 200; + dataMap.responseURL = settings.url; + // Use self.responseType to create the correct self.responseType, self.response. + createResponse(data, ""); setState(self.DONE); } }); } else { try { - this.responseText = fs.readFileSync(url.pathname, 'utf8'); - this.status = 200; + dataMap.status = 200; + const syncData = fs.readFileSync(unescape(url.pathname)); + // Use self.responseType to create the correct self.responseType, self.response. + dataMap.responseURL = settings.url; + createResponse(syncData, ""); setState(self.DONE); } catch(e) { - this.handleError(e); + handleError(e, e.errno || -1); } } @@ -338,22 +704,22 @@ function XMLHttpRequest(opts) { // Default to port 80. If accessing localhost on another port be sure // to use http://localhost:port/path - var port = url.port || (ssl ? 443 : 80); + var port = url.port || (isSsl ? 443 : 80); // Add query string if one is used - var uri = url.pathname + (url.search ? url.search : ''); + var uri = url.pathname + (url.search || ''); // Set the Host header or the server may reject the request headers["Host"] = host; - if (!((ssl && port === 443) || port === 80)) { + if (!((isSsl && port === 443) || port === 80)) { headers["Host"] += ':' + url.port; } // Set Basic Auth if necessary if (settings.user) { - if (typeof settings.password == "undefined") { + if (typeof settings.password === "undefined") { settings.password = ""; } - var authBuf = new Buffer(settings.user + ":" + settings.password); + var authBuf = Buffer.from(settings.user + ":" + settings.password); headers["Authorization"] = "Basic " + authBuf.toString("base64"); } @@ -363,7 +729,8 @@ function XMLHttpRequest(opts) { } else if (data) { headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); - if (!headers["Content-Type"]) { + var headersKeys = Object.keys(headers); + if (!headersKeys.some(function (h) { return h.toLowerCase() === 'content-type' })) { headers["Content-Type"] = "text/plain;charset=UTF-8"; } } else if (settings.method === "POST") { @@ -372,271 +739,401 @@ function XMLHttpRequest(opts) { headers["Content-Length"] = 0; } - var agent = false; - if (opts && opts.agent) { - agent = opts.agent; - } var options = { host: host, port: port, path: uri, method: settings.method, headers: headers, - agent: agent + agent: opts.agent || false }; - if (ssl) { - options.pfx = opts.pfx; - options.key = opts.key; - options.passphrase = opts.passphrase; - options.cert = opts.cert; - options.ca = opts.ca; - options.ciphers = opts.ciphers; - options.rejectUnauthorized = opts.rejectUnauthorized; - } - // Reset error flag errorFlag = false; - // Handle async requests if (settings.async) { // Use the proper protocol - var doRequest = ssl ? https.request : http.request; + var doRequest = isSsl ? https.request : http.request; // Request is being sent, set send flag sendFlag = true; // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); + dispatchEvent("readystatechange"); // Handler for the response - function responseHandler(resp) { - // Set response var to the response we got back - // This is so it remains accessable outside this scope - response = resp; + var responseHandler = function(resp) { // Check for redirect - // @TODO Prevent looped redirects - if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { + if ( + resp.statusCode === 301 || + resp.statusCode === 302 || + resp.statusCode === 303 || + resp.statusCode === 307 || + resp.statusCode === 308 + ) { + ++redirectCount; + // end the response + resp.destroy(); + if (redirectCount > maxRedirects) { + handleError(new Error("Too many redirects")); + return; + } // Change URL to the redirect location - settings.url = response.headers.location; - var url = Url.parse(settings.url); - // Set host var in case it's used later + var url; + try { + url = new URL(resp.headers.location, settings.url); + // reject redirects to any protocols other than http and https + if (url.protocol !== "https:" && url.protocol !== "http:") throw "bad protocol"; + settings.url = url.href; + } + catch (e) { + handleError(new Error("Unsafe redirect")); + return; + } + // change request options again to match with new redirect protocol + isSsl = url.protocol === "https:"; + doRequest = isSsl ? https.request : http.request; + + // Set host and port var in case it's used later host = url.hostname; + port = url.port || (isSsl ? 443 : 80); + + headers["Host"] = host; + if (!((isSsl && port === 443) || port === 80)) { + headers["Host"] += ':' + url.port; + } + // Options for the new request var newOptions = { hostname: url.hostname, - port: url.port, - path: url.path, - method: response.statusCode === 303 ? 'GET' : settings.method, + port: port, + path: url.pathname + (url.search || ''), + method: resp.statusCode === 303 ? 'GET' : settings.method, headers: headers }; - if (ssl) { - options.pfx = opts.pfx; - options.key = opts.key; - options.passphrase = opts.passphrase; - options.cert = opts.cert; - options.ca = opts.ca; - options.ciphers = opts.ciphers; - options.rejectUnauthorized = opts.rejectUnauthorized; - } - // Issue the new request - request = doRequest(newOptions, responseHandler).on('error', errorHandler); - request.end(); + createRequest(newOptions); // @TODO Check if an XHR event needs to be fired here return; } - response.setEncoding("utf8"); + // Set response var to the response we got back + // This is so it remains accessable outside this scope + response = resp; + // Collect buffers and concatenate once. + const buffers = []; setState(self.HEADERS_RECEIVED); - self.status = response.statusCode; + + dataMap.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (chunk) { - self.responseText += chunk; + buffers.push(chunk); } // Don't emit state changes if the connection has been aborted. if (sendFlag) { setState(self.LOADING); } - }); + }.bind(response)); response.on('end', function() { if (sendFlag) { + // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks + // there can be a timing issue (the callback is called and a new call is made before the flag is reset). + sendFlag = false; + // Create the correct response for responseType. + createResponse(Buffer.concat(buffers), response.headers['content-type'] || ""); + dataMap.statusText = this.statusMessage; + dataMap.responseURL = settings.url; // Discard the 'end' event if the connection has been aborted setState(self.DONE); - sendFlag = false; } - }); + }.bind(response)); response.on('error', function(error) { - self.handleError(error); - }); + handleError(error); + }.bind(response)); } // Error handler for the request - function errorHandler(error) { - self.handleError(error); + var errorHandler = function(error) { + // In the case of https://nodejs.org/api/http.html#requestreusedsocket triggering an ECONNRESET, + // don't fail the xhr request, attempt again. + if (request.reusedSocket && error.code === 'ECONNRESET') + return doRequest(options, responseHandler).on('error', errorHandler); + handleError(error); } - // Create the request - request = doRequest(options, responseHandler).on('error', errorHandler); + var createRequest = function (opt) { + opt = Object.assign(Object.create(null), opt); + if (isSsl) Object.assign(opt, sslOptions); + + request = doRequest(opt, responseHandler).on('error', errorHandler); - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - request.write(data); + if (opts.autoUnref) { + request.on('socket', function (socket) { + socket.unref(); + }); + } + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + request.write(data); + } + + request.end(); } - request.end(); + // Create the request + createRequest(options); - self.dispatchEvent("loadstart"); + dispatchEvent("loadstart"); } else { // Synchronous - // Create a temporary file for communication with the other Node process - var contentFile = ".node-xmlhttprequest-content-" + process.pid; - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); - // The async request the other Node process executes - var execString = "var http = require('http'), https = require('https'), fs = require('fs');" - + "var doRequest = http" + (ssl ? "s" : "") + ".request;" - + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" - + "var req = doRequest(options, function(response) {" - + "response.setEncoding('utf8');" - + "response.on('data', function(chunk) {" - + " responseText += chunk;" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "}).on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"") - + "req.end();"; - // Start the other Node Process, executing this string - var syncProc = spawn(process.argv[0], ["-e", execString]); - var statusText; - while(fs.existsSync(syncFile)) { - // Wait while the sync file is empty + try { + // Create a temporary file for communication with the other Node process + var tmpDir = os.tmpdir(); + var syncResponse; + var contentFile = path.join(tmpDir, ".node-xmlhttprequest-content-" + process.pid); + var syncFile = path.join(tmpDir, ".node-xmlhttprequest-sync-" + process.pid); + fs.writeFileSync(syncFile, "", "utf8"); + // The async request the other Node process executes + var execString = "'use strict';" + + "var http = require('http'), https = require('https'), fs = require('fs');" + + "function concat(bufferArray) {" + + " let length = 0, offset = 0;" + + " for (let k = 0; k < bufferArray.length; k++)" + + " length += bufferArray[k].length;" + + " const result = Buffer.alloc(length);" + + " for (let k = 0; k < bufferArray.length; k++) {" + + " for (let i = 0; i < bufferArray[k].length; i++) {" + + " result[offset+i] = bufferArray[k][i]" + + " }" + + " offset += bufferArray[k].length;" + + " }" + + " return result;" + + "};" + + "var doRequest = http" + (isSsl ? "s" : "") + ".request;" + + "var isSsl = " + !!isSsl + ";" + + "var options = " + JSON.stringify(options) + ";" + + "var sslOptions = " + JSON.stringify(sslOptions) + ";" + + "var responseData = Buffer.alloc(0);" + + "var buffers = [];" + + "var url = new URL(" + JSON.stringify(settings.url) + ");" + + "var maxRedirects = " + maxRedirects + ", redirects_count = 0;" + + "var makeRequest = function () {" + + " var opt = Object.assign(Object.create(null), options);" + + " if (isSsl) Object.assign(opt, sslOptions);" + + " var req = doRequest(opt, function(response) {" + + " if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307 || response.statusCode === 308) {" + + " response.destroy();" + + " ++redirects_count;" + + " if (redirects_count > maxRedirects) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Too many redirects', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " }" + + " try {" + + " url = new URL(response.headers.location, url);" + + " if (url.protocol !== 'https:' && url.protocol !== 'http:') throw 'bad protocol';" + + " }" + + " catch (e) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR-REDIRECT: Unsafe redirect', 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " return;" + + " };" + + " isSsl = url.protocol === 'https:';" + + " doRequest = isSsl ? https.request : http.request;" + + " var port = url.port;" + + " options = {" + + " hostname: url.hostname," + + " port: port," + + " path: url.pathname + (url.search || '')," + + " method: response.statusCode === 303 ? 'GET' : options.method," + + " headers: options.headers" + + " };" + + " options.headers['Host'] = url.host;" + + " if (!((isSsl && port === 443) || port === 80)) options.headers['Host'] += ':' + port;" + + " makeRequest();" + + " return;" + + " }" + + " response.on('data', function(chunk) {" + + " buffers.push(chunk);" + + " });" + + " response.on('end', function() {" + + " responseData = concat(buffers);" + + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: { url: url.href, statusCode: response.statusCode, statusText: response.statusMessage, headers: response.headers }}), 'utf8');" + + " fs.writeFileSync('" + contentFile + ".bin', responseData);" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " response.on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " }).on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " " + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") + + " req.end();" + + "};" + + "makeRequest();" + // Start the other Node Process, executing this string + var syncProc = spawn(process.argv[0], ["-e", execString]); + while(fs.existsSync(syncFile)) { + // Wait while the sync file is empty + } + syncResponse = fs.readFileSync(contentFile, 'utf8'); + // Kill the child process once the file has data + syncProc.stdin.end(); + // Remove the temporary file + fs.unlinkSync(contentFile); } - self.responseText = fs.readFileSync(contentFile, 'utf8'); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file - fs.unlinkSync(contentFile); - if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { + catch (e) { + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); + } + if (syncResponse.match(/^NODE-XMLHTTPREQUEST-ERROR(-REDIRECT){0,1}:/)) { // If the file returned an error, handle it - var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); - self.handleError(errorObj); - } else { + if (syncResponse.startsWith('NODE-XMLHTTPREQUEST-ERROR-REDIRECT')) { + handleError(new Error(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR-REDIRECT: /, ""))); + } + else { + var errorObj = JSON.parse(syncResponse.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "")); + handleError(errorObj, 503); + } + } else try { // If the file returned okay, parse its data and move to the DONE state - self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1"); - self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1"); + const resp = JSON.parse(syncResponse); + dataMap.status = resp.data.statusCode; + dataMap.statusText = resp.data.statusText; + dataMap.responseURL = resp.data.url; + dataMap.response = fs.readFileSync(contentFile + ".bin"); + fs.unlinkSync(contentFile + ".bin"); + // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. + createResponse(self.response, resp.data.headers["content-type"] || ""); + // Set up response correctly. + response = { + statusCode: self.status, + headers: resp.data.headers + }; setState(self.DONE); } + catch (e) { + handleError(new Error("Synchronous operation aborted: Unable to access the OS temporary directory for read/write operations.")); + } } - }; - - /** - * Called when an error is encountered to deal with it. - */ - this.handleError = function(error) { - this.status = 503; - this.statusText = error; - this.responseText = error.stack; - errorFlag = true; - setState(this.DONE); - }; + }); /** * Aborts a request. */ - this.abort = function() { + var abort = function() { if (request) { request.abort(); request = null; } - headers = defaultHeaders; - this.responseText = ""; - this.responseXML = ""; - - errorFlag = true; + headers = Object.assign(Object.create(null), defaultHeaders); + dataMap.responseText = ""; + dataMap.responseXML = ""; + dataMap.response = Buffer.alloc(0); - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { + errorFlag = abortedFlag = true + if (readyState !== self.UNSENT + && (readyState !== self.OPENED || sendFlag) + && readyState !== self.DONE) { sendFlag = false; - setState(this.DONE); + setState(self.DONE); } - this.readyState = this.UNSENT; + readyState = self.UNSENT; }; + /** + * Aborts a request. + */ + assignProp(this, 'abort', abort); + /** * Adds an event listener. Preferred method of binding to events. */ - this.addEventListener = function(event, callback) { + assignProp(this, 'addEventListener', function(event, callback) { if (!(event in listeners)) { listeners[event] = []; } // Currently allows duplicate callbacks. Should it? listeners[event].push(callback); - }; + }); /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ - this.removeEventListener = function(event, callback) { + assignProp(this, 'removeEventListener', function(event, callback) { if (event in listeners) { // Filter will return a new array with the callback removed listeners[event] = listeners[event].filter(function(ev) { return ev !== callback; }); } - }; + }); /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function(event) { + var dispatchEvent = function (event) { + let argument = { type: event }; if (typeof self["on" + event] === "function") { - self["on" + event](); + if (readyState === self.DONE && settings.async) + setTimeout(function() { self["on" + event](argument) }, 0) + else + self["on" + event](argument) } if (event in listeners) { - for (var i = 0, len = listeners[event].length; i < len; i++) { - listeners[event][i].call(self); + for (let i = 0, len = listeners[event].length; i < len; i++) { + if (readyState === self.DONE) + setTimeout(function() { listeners[event][i].call(self, argument) }, 0) + else + listeners[event][i].call(self, argument) } } }; + /** + * Dispatch any events, including both "on" methods and events attached using addEventListener. + */ + assignProp(this, 'dispatchEvent', dispatchEvent); + /** * Changes readyState and calls onreadystatechange. * * @param int state New state */ var setState = function(state) { - if (self.readyState !== state) { - self.readyState = state; + if ((readyState === state) || (readyState === self.UNSENT && abortedFlag)) + return - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); - } + readyState = state; - if (self.readyState === self.DONE && !errorFlag) { - self.dispatchEvent("load"); - // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); - } + if (settings.async || readyState < self.OPENED || readyState === self.DONE) { + dispatchEvent("readystatechange"); + } + + if (readyState === self.DONE) { + let fire + + if (abortedFlag) + fire = "abort" + else if (errorFlag) + fire = "error" + else + fire = "load" + + dispatchEvent(fire) + + // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) + dispatchEvent("loadend"); } }; }; diff --git a/package.json b/package.json index 5cd17b8..3bcf394 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { - "name": "xmlhttprequest", + "name": "xmlhttprequest-ssl", "description": "XMLHttpRequest for Node", - "version": "1.5.0", + "version": "4.0.0", "author": { - "name": "Dan DeFelippi", - "url": "http://driverdan.com" + "name": "Michael de Wit" }, "keywords": [ "xhr", @@ -18,16 +17,24 @@ ], "repository": { "type": "git", - "url": "git://github.com/driverdan/node-XMLHttpRequest.git" + "url": "git://github.com/mjwwit/node-XMLHttpRequest.git" }, - "bugs": "http://github.com/driverdan/node-XMLHttpRequest/issues", + "bugs": "http://github.com/mjwwit/node-XMLHttpRequest/issues", "engines": { - "node": ">=0.4.0" + "node": ">=13.0.0" + }, + "scripts": { + "test": "cd ./tests && node run-test.js" }, "directories": { "lib": "./lib", "example": "./example" }, + "files": [ + "lib/XMLHttpRequest.js", + "LICENSE", + "README.md" + ], "main": "./lib/XMLHttpRequest.js", "dependencies": {} } diff --git a/tests/run-test.js b/tests/run-test.js new file mode 100644 index 0000000..1e00421 --- /dev/null +++ b/tests/run-test.js @@ -0,0 +1,50 @@ +var ignored_files = [ + "run-test.js", // this file + "server.js" +]; + +var spawnSync = require("child_process").spawnSync; +var fs = require("fs"); +var path = require("path"); + +// global flag to check if some of test fails, and will store location of failed test file +var fail_path = false; + +// function to read and conduct test case +var run_test = function (file) { + if (fail_path) return; + // logging + console.log("Running:", file); + + // spawn a nodejs process + var proc = spawnSync("node", [file]); + + if (proc.status === 0) { + console.log(proc.stdout.toString()); + console.log("--> PASSED"); + } + else { + fail_path = file; + console.log("--> TEST FAILED - CAUGHT ERROR:", proc.stderr.toString()); + } +} + +var check_dir = function (dirPath) { + if (fail_path) return; + var files = fs.readdirSync(dirPath); + + for (var file of files) { + // return early in case something fails + if (fail_path) return; + var full_path = path.join(dirPath, file); + if (fs.statSync(full_path).isDirectory()) check_dir(full_path); + else if (path.extname(file) === ".js" && !ignored_files.includes(full_path)) run_test(full_path); + } +} + +// start test +check_dir("./"); + +if (fail_path) throw new Error("Test failed at file: " + fail_path); + +console.log("ALL TESTS PASSED."); diff --git a/tests/server.js b/tests/server.js new file mode 100644 index 0000000..28fe380 --- /dev/null +++ b/tests/server.js @@ -0,0 +1,88 @@ +'use strict'; +var http = require("http"); + +var bufferBody = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + +var server = http.createServer(function (req, res) { + switch (req.url) { + case "/": { + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.end(body); + return; + } + case "/text": + res.writeHead(200, {"Content-Type": "text/plain"}) + res.end("Hello world!"); + return; + case "/xml": + res.writeHead(200, {"Content-Type": "application/xml"}) + res.end("Foobar"); + return; + case "/json": + res.writeHead(200, {"Content-Type": "application/json"}) + res.end(JSON.stringify({ foo: "bar" })); + return; + case "/binary1": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(Buffer.from("Hello world!")); + return; + case "/binary2": + const ta = new Float32Array([1, 5, 6, 7]); + const buf = Buffer.from(ta); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(buf); + return; + case "/latin1": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=ISO-8859-1', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); + return; + case "/latin1-invalid": + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=lorem_ipsum', + 'Content-Length': bufferBody.length + }); + res.end(bufferBody); + return; + default: + if (req.url.startsWith('/redirectingResource/')) { + let remaining = req.url.replace(/^\/redirectingResource\/*/, "") - 1; + res.writeHead(301, {'Location': remaining ? ('http://localhost:8888/redirectingResource/' + remaining) : 'http://localhost:8888/'}); + res.end(); + } + else { + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + } + } +}).listen(8888); + +process.on("SIGINT", function () { + server.close(); +}); diff --git a/tests/test-constants.js b/tests/test-constants.js index 372e46c..0cd7ec4 100644 --- a/tests/test-constants.js +++ b/tests/test-constants.js @@ -1,13 +1,18 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest(); // Test constant values +assert.equal(0, XMLHttpRequest.UNSENT); +assert.equal(1, XMLHttpRequest.OPENED); +assert.equal(2, XMLHttpRequest.HEADERS_RECEIVED); +assert.equal(3, XMLHttpRequest.LOADING); +assert.equal(4, XMLHttpRequest.DONE); + assert.equal(0, xhr.UNSENT); assert.equal(1, xhr.OPENED); assert.equal(2, xhr.HEADERS_RECEIVED); assert.equal(3, xhr.LOADING); assert.equal(4, xhr.DONE); -sys.puts("done"); +console.log("done"); diff --git a/tests/test-data-uri.js b/tests/test-data-uri.js new file mode 100644 index 0000000..e7c3314 --- /dev/null +++ b/tests/test-data-uri.js @@ -0,0 +1,136 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +// define test data +var tests = [ + { + name: "Test plain URI Data", + data: "data:,Hello%20World", + output: "Hello World" + }, + { + name: "Test plain URI Data with spaces", + data: "data:, Hello World", + output: " Hello World" + }, + { + name: "Test plain URI Data with data URI headers", + data: "data:base64;example=1;args=2,Hello%20World", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI", + data: "data:text;base64,SGVsbG8gV29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with mixed space characters", + data: "data:text;base64,SGV sbG8gV\n29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with mixed space characters (url-encoded)", + data: "data:text;base64,SGV%20sbG8gV%0a29ybGQ=", + output: "Hello World" + }, + { + name: "Test normal bass64-encoded data URI with invalid characters", + data: "data:text;base64,SGV&&&&sbG8gV{29ybGQ=", + error: "Invalid data URI" + }, + { + name: "Test normal bass64-encoded data URI with invalid characters (url-encoded)", + data: "data:text;base64,SGV%26%26%26%26sbG8gV%7B29ybGQ%3D", + error: "Invalid data URI" + }, + { + name: "Test base64-encoded data with no paddings", + data: "data:text;base64,SGVsbG8gV29ybGQ", + output: "Hello World" + }, + { + name: "Test base64-encoded data with excessive paddings", + data: "data:text;base64,SGVsbG8gV29ybGQ==", + error: "Invalid data URI" + } +]; + +var tests_passed = 0; + +var runAsyncTest = function (test) { + console.log(" ASYNC"); + + var xhr = new XMLHttpRequest; + xhr.open("get", test.data); + xhr.onreadystatechange = function () { + if (this.readyState === 4) { + if (test.error) { + assert.equal(xhr.status, 0); + assert.equal(xhr.statusText, test.error); + } + else { + assert.equal(xhr.status, 200); + assert.equal(xhr.responseText, test.output); + } + console.log(" --> SUCCESS"); + ++tests_passed; + } + } + xhr.send(); +} + +var runSyncTest = function (test) { + console.log(" SYNC"); + + var xhr = new XMLHttpRequest; + xhr.open("get", test.data, false); + try { + xhr.send(); + if (test.error) throw "Expected to fail, Success with " + e.responseText; + assert.equal(xhr.status, 200); + assert.equal(xhr.responseText, test.output); + } + catch (e) { + if (!test.error) throw "Expected to success, Caught error: " + e.toString() + assert.equal(xhr.status, 0); + assert.equal(e.message, test.error); + } + console.log(" --> SUCCESS"); + ++tests_passed; +} + +var i = 0; + +var startTest = function () { + let test = tests[i]; + + if (!test) { + console.log(tests_passed, "/", tests.length * 2, "tests passed"); + if (tests_passed === tests.length * 2) + console.log("Done: PASS"); + else { + console.error("Done: FAILED"); + throw ""; + } + return; + } + + console.log(test.name); + + runAsyncTest(test); + + setTimeout(function () { + try { + runSyncTest(test); + } + catch (e) { + console.error(e); + throw e; + }; + console.log(""); + ++i; + startTest(); + }, 500); +} + +startTest(); diff --git a/tests/test-disallow-fs-resources.js b/tests/test-disallow-fs-resources.js new file mode 100644 index 0000000..a244849 --- /dev/null +++ b/tests/test-disallow-fs-resources.js @@ -0,0 +1,39 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr; + +xhr = new XMLHttpRequest({ allowFileSystemResources: false }); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(this.statusText, "Not allowed to load local resource: " + url); + assert.equal(this.status, 0); + try { + runSync(); + } catch (e) { + if (e instanceof assert.AssertionError) { + throw e; + } + } + } +}; + +// Async +var url = "file://" + __dirname + "/testdata.txt"; +xhr.open("GET", url); +xhr.send(); + +// Sync +var runSync = function() { + xhr = new XMLHttpRequest({ allowFileSystemResources: false }); + + xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(this.statusText, "Not allowed to load local resource: " + url); + assert.equal(this.status, 0); + console.log("done"); + } + }; + xhr.open("GET", url, false); + xhr.send(); +} diff --git a/tests/test-events.js b/tests/test-events.js index c72f001..0d07fd7 100644 --- a/tests/test-events.js +++ b/tests/test-events.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , http = require("http") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -20,7 +19,7 @@ var server = http.createServer(function (req, res) { assert.equal(onreadystatechange, true); assert.equal(readystatechange, true); assert.equal(removed, true); - sys.puts("done"); + console.log("done"); this.close(); }).listen(8000); diff --git a/tests/test-exceptions.js b/tests/test-exceptions.js index f1edd71..721ef72 100644 --- a/tests/test-exceptions.js +++ b/tests/test-exceptions.js @@ -1,6 +1,4 @@ -var sys = require("util") - , assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest(); // Test request methods that aren't allowed @@ -20,7 +18,8 @@ try { try { xhr.open("GET", "http://localhost:8000/"); } catch(e) { - console.log("ERROR: Invalid exception for GET", e); + console.error(e); + throw new Error("ERROR: Invalid exception for GET"); } // Test forbidden headers @@ -44,15 +43,12 @@ var forbiddenRequestHeaders = [ "trailer", "transfer-encoding", "upgrade", - "user-agent", "via" ]; for (var i in forbiddenRequestHeaders) { - try { - xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test"); - console.log("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); - } catch(e) { + if(xhr.setRequestHeader(forbiddenRequestHeaders[i], "Test") !== false) { + throw new Error("ERROR: " + forbiddenRequestHeaders[i] + " should have thrown exception"); } } diff --git a/tests/test-headers.js b/tests/test-headers.js index 76454f1..c957103 100644 --- a/tests/test-headers.js +++ b/tests/test-headers.js @@ -1,17 +1,27 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); // Test server var server = http.createServer(function (req, res) { - // Test setRequestHeader - assert.equal("Foobar", req.headers["x-test"]); - // Test non-conforming allowed header - assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); - // Test header set with blacklist disabled - assert.equal("http://github.com", req.headers["referer"]); + switch (req.url) { + case "/allow": + // Test disabling header check + assert.equal("http://github.com", req.headers["referer"]); + console.log("No header check: PASSED"); + break; + default: + // Test setRequestHeader + assert.equal("Foobar", req.headers["x-test"]); + // Test non-conforming allowed header + assert.equal("node-XMLHttpRequest-test", req.headers["user-agent"]); + // Test case insensitive header was set + assert.equal("text/plain", req.headers["content-type"]); + // Test forbidden header + assert.equal(null, req.headers["referer"]); + console.log("Strict header check: PASSED"); + } var body = "Hello World"; res.writeHead(200, { @@ -47,30 +57,38 @@ xhr.onreadystatechange = function() { assert.equal("", this.getAllResponseHeaders()); assert.equal(null, this.getResponseHeader("Connection")); - sys.puts("done"); + console.log("Response headers check: PASSED"); } }; assert.equal(null, xhr.getResponseHeader("Content-Type")); try { - xhr.open("GET", "http://localhost:8000/"); + xhr.open("POST", "http://localhost:8000/"); + var body = "Hello World"; // Valid header xhr.setRequestHeader("X-Test", "Foobar"); - // Invalid header - xhr.setRequestHeader("Content-Length", 0); + // Invalid header Content-Length + xhr.setRequestHeader("Content-Length", Buffer.byteLength(body)); + // Invalid header Referer + xhr.setRequestHeader("Referer", "http://github.com"); // Allowed header outside of specs xhr.setRequestHeader("user-agent", "node-XMLHttpRequest-test"); - // Test getRequestHeader - assert.equal("Foobar", xhr.getRequestHeader("X-Test")); - // Test invalid header - assert.equal("", xhr.getRequestHeader("Content-Length")); + // Case insensitive header + xhr.setRequestHeader("content-type", 'text/plain'); + xhr.send(body); +} catch(e) { + console.error("ERROR: Exception raised", e); + throw e; +} +try { // Test allowing all headers - xhr.setDisableHeaderCheck(true); + xhr = new XMLHttpRequest({ disableHeaderCheck: true }); + xhr.open("POST", "http://localhost:8000/allow"); xhr.setRequestHeader("Referer", "http://github.com"); - assert.equal("http://github.com", xhr.getRequestHeader("Referer")); - xhr.send(); -} catch(e) { - console.log("ERROR: Exception raised", e); +} +catch (e) { + console.error("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-keepalive.js b/tests/test-keepalive.js new file mode 100644 index 0000000..f7d22bd --- /dev/null +++ b/tests/test-keepalive.js @@ -0,0 +1,37 @@ +var assert = require("assert"); +var http = require('http'); +var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); + +var server = http.createServer({ keepAliveTimeout: 200 }, function handleConnection (req, res) { + res.write('hello\n'); + res.end(); +}).listen(8889); + +var agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 2000, +}); +var xhr = new XMLHttpRequest({ agent }); +var url = "http://localhost:8889"; + +var repeats = 0; +var maxMessages = 20; +var interval = setInterval(function sendRequest() { + xhr.open("GET", url); + xhr.onloadend = function(event) { + if (xhr.status !== 200) { + console.error('Error: non-200 xhr response, message is\n', xhr.responseText); + clearInterval(interval); + agent.destroy(); + server.close(); + assert.equal(xhr.status, 200); + } + if (repeats++ > maxMessages) { + console.log('Done.'); + clearInterval(interval); + agent.destroy(); + server.close(); + } + } + xhr.send(); +}, 200); diff --git a/tests/test-max-redirects.js b/tests/test-max-redirects.js new file mode 100644 index 0000000..1afd3ec --- /dev/null +++ b/tests/test-max-redirects.js @@ -0,0 +1,47 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn; + +// Test server +var serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var runTest = function () { + try { + let xhr = new XMLHttpRequest({ maxRedirects: 10 }); + xhr.open("GET", "http://localhost:8888/redirectingResource/10", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("safe redirects count: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + try { + let xhr = new XMLHttpRequest({ maxRedirects: 10 }); + xhr.open("GET", "http://localhost:8888/redirectingResource/20", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.statusText, 'Too many redirects'); + assert.equal(xhr.status, 0); + console.log("excessive redirects count: done"); + } + }; + xhr.send(); + } catch(e) { + assert.equal(e.message, 'Too many redirects'); + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); diff --git a/tests/test-mimetype.js b/tests/test-mimetype.js new file mode 100644 index 0000000..5e65d21 --- /dev/null +++ b/tests/test-mimetype.js @@ -0,0 +1,168 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn + , serverProcess; + +const body = Buffer.from([ + 0x48, // H + 0xE9, // é + 0x6C, // l + 0x6C, // l + 0x6F, // o + 0x20, // + 0x77, // w + 0xF8, // ø + 0x72, // r + 0x6C, // l + 0x64, // d + 0x20, // + 0x6E, // n + 0x61, // a + 0xEF, // ï + 0x76, // v + 0x65 // e +]); + +var base64Str = function (charset) { + return "data:text/plain;base64;charset=" + charset + "," + body.toString('base64'); +} + +// specify custom decoder to work on older node versions +var decodeTextFromBuffer = function (buf, enc) { + if (enc == "iso-8859-1") return buf.toString("latin1"); + return new TextDecoder(enc).decode(buf); +} + +var plainStr = decodeTextFromBuffer(body, "iso-8859-1"); +var plainStrUTF8 = decodeTextFromBuffer(body, "utf-8"); + +var createXHRInstance = function (opts) { + return new XMLHttpRequest(Object.assign({ textDecoder: null }, opts)); +} + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + serverProcess.kill('SIGINT'); + throw e; + } finally { + + } +}, 100); + +var tests = [ + { + name: "XHR with default latin-1 encoding", + endpoint: "http://localhost:8888/latin1", + expected: plainStr + }, + { + name: "XHR with overrideMimeType charset=latin-1", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR with wrong charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + expected: '' + }, + { + name: "XHR with wrong overriden charset (utf-8 expected, actual is latin-1)", + endpoint: "http://localhost:8888/latin1-invalid", + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + }, + { + name: "XHR on data URI with Latin-1 and charset specified", + endpoint: base64Str("ISO-8859-1"), + expected: plainStr + }, + { + name: "XHR on data URI with overrideMimeType to Latin-1", + endpoint: base64Str("UTF-8"), + override: "text/plain; charset=ISO-8859-1", + expected: plainStr + }, + { + name: "XHR on data URI with wrong default charset (utf-8 vs latin-1)", + endpoint: base64Str("lorem_ipsum"), + expected: '' + }, + { + name: "XHR with wrong overriden charset and Data URI (utf-8 expected, actual is latin-1)", + endpoint: base64Str("iso-8859-1"), + override: "text/plain; charset=lorem_ipsum", + expected: plainStrUTF8 + } +]; + +var tests_passed = 0; + +var total_tests = tests.length * 2; + +var runSyncTest = function (i) { + var test = tests[i]; + var index = i + 1; + try { + var xhr = createXHRInstance(); + console.log("Test " + index + ": [SYNC] " + test.name); + xhr.open("GET", test.endpoint, false); + if (test.override) xhr.overrideMimeType(test.override); + xhr.send(); + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + } +} + +var runAsyncTest = function (i) { + if (i >= tests.length) { + serverProcess.kill('SIGINT'); + if (tests_passed === total_tests) return console.log("ALL PASSED"); + else { + console.error("FAILED: Only " + tests_passed + " / " + total_tests + " tests passed"); + throw ""; + }; + } + var test = tests[i]; + var index = i + tests.length + 1; + try { + var xhr = createXHRInstance(); + console.log("Test " + index + ": [ASYNC] " + test.name); + xhr.open("GET", test.endpoint); + if (test.override) xhr.overrideMimeType(test.override); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) try { + assert.equal(xhr.responseText, test.expected); + console.log("Test " + index + ": PASSED"); + ++tests_passed; + runAsyncTest(i + 1); + } + catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } + } + xhr.send(); + } catch (e) { + console.log("Test " + index + ": FAILED with exception", e); + runAsyncTest(i + 1); + } +} + +var runTest = function () { + for (var i = 0; i < tests.length; i++) { + runSyncTest(i); + } + + runAsyncTest(0); +} diff --git a/tests/test-perf.js b/tests/test-perf.js new file mode 100644 index 0000000..b93fd97 --- /dev/null +++ b/tests/test-perf.js @@ -0,0 +1,239 @@ + +/****************************************************************************************** + * This test measurs the elapsed time to download a Float32Array of length 100,000,000. + */ +'use strict'; + +const http = require("http"); + +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +const supressConsoleOutput = false; +function log (_) { + if ( !supressConsoleOutput) + console.log.apply(console, arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Create a random Float32Array of length N. + * 2) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('storage:', JSON.stringify(storageLength())); +} + +/** + * mini-webserver: Serves up anything uploaded. + * Tested with: + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) { + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); + } + else { + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); + } + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType) +{ + responseType = responseType || 'arraybuffer'; + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads random float32 array array of length 100,000,000. . + * 2) Downloads the float32 array and measures the download elpased time. + */ + +const N = 100 * 1000 * 1000; +const _f32 = createFloat32Array(N); + +const F32 = Buffer.from(_f32.buffer); + +const urlF32 = "http://localhost:8888/F32"; + +const xhr = new XMLHttpRequest(); +var handle, success, _t0; + +/** + * 1) Upload Float32Array of length N=100,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + */ +function runTest() { + let r = upload(xhr, urlF32, F32); // big + return r.then(afterUpload) +} + +function afterUpload(r) { + log('upload urlF32, F32 ', r); + + log('-----------------------------------------------------------------------------------'); + checkStorage(); // Check what's in the mini-webserver storage. + log('-----------------------------------------------------------------------------------'); + + _t0 = Date.now(); + success = true; + handle = setTimeout(() => { + console.error('Download has taken longer than 5 seconds and hence it has failed!'); + success = false; + }, 5 * 1000) + const ab = download(xhr, urlF32, 'arraybuffer'); // big + return ab.then(afterDownload); +} + +function afterDownload(ab) { + clearTimeout(handle); + console.log(`Download elapsed time:, ${Date.now() - _t0}ms`, ab.byteLength); + console.info('...waiting to see elapsed time of download...'); + if (!success) + throw new Error("Download has taken far too long!"); +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); +}, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-pollution.js b/tests/test-pollution.js new file mode 100644 index 0000000..9fbc516 --- /dev/null +++ b/tests/test-pollution.js @@ -0,0 +1,61 @@ +// Main purpose of this test is to ensure that XHR options and exposed methods cannot be prototype-polluted + +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest, + spawn = require("child_process").spawn, + assert = require("assert"), + xhr, + objectProto = Object.getPrototypeOf({}); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var polluteFunc = function (buf, enc) { + return "Polluted!"; +} + +var runTest = function () { + // most naive pollution + objectProto.textDecoder = polluteFunc; + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Naive pollution: PASSED"); + + delete objectProto.textDecoder; + + // pollute with getter/setter + Object.defineProperty(objectProto, 'textDecoder', { + get: function () { return polluteFunc; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Getter/Setter pollution: PASSED"); + + // pollute xhr properties + Object.defineProperty(objectProto, 'responseText', { + get: function () { return "Polluted!"; }, + set: function (value) {} + }); + + xhr = new XMLHttpRequest(); + xhr.open("GET", "http://localhost:8888", false); + xhr.send(); + assert.equal("Hello World", xhr.responseText); + console.log("Pollute xhr.responseText: PASSED"); +} + +try { + runTest(); +} +catch (e) { + throw e; +} +finally { + serverProcess.kill('SIGINT'); +} diff --git a/tests/test-redirect-301.js b/tests/test-redirect-301.js new file mode 100644 index 0000000..c116533 --- /dev/null +++ b/tests/test-redirect-301.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(301, {'Location': 'http://localhost:8000/'}); + res.end(); + return; + } + + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-redirect-302.js b/tests/test-redirect-302.js index d884f78..ef32808 100644 --- a/tests/test-redirect-302.js +++ b/tests/test-redirect-302.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -26,10 +25,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + if (this.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -38,4 +37,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-303.js b/tests/test-redirect-303.js index 60d9343..717aa42 100644 --- a/tests/test-redirect-303.js +++ b/tests/test-redirect-303.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -26,10 +25,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + if (this.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -38,4 +37,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-307.js b/tests/test-redirect-307.js index 3abc906..04ff971 100644 --- a/tests/test-redirect-307.js +++ b/tests/test-redirect-307.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr = new XMLHttpRequest() , http = require("http"); @@ -28,10 +27,10 @@ var server = http.createServer(function (req, res) { }).listen(8000); xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal(xhr.getRequestHeader('Location'), ''); + if (this.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); assert.equal(xhr.responseText, "Hello World"); - sys.puts("done"); + console.log("done"); } }; @@ -40,4 +39,5 @@ try { xhr.send(); } catch(e) { console.log("ERROR: Exception raised", e); + throw e; } diff --git a/tests/test-redirect-308.js b/tests/test-redirect-308.js new file mode 100644 index 0000000..6de9f7b --- /dev/null +++ b/tests/test-redirect-308.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(308, {'Location': 'http://localhost:8000/'}); + res.end(); + return; + } + + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + // assert.equal(xhr.getRequestHeader('Location'), ''); + assert.equal(xhr.responseText, "Hello World"); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-request-methods.js b/tests/test-request-methods.js index fa1b1be..275a5d7 100644 --- a/tests/test-request-methods.js +++ b/tests/test-request-methods.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , http = require("http") , xhr; @@ -24,7 +23,7 @@ var server = http.createServer(function (req, res) { if (curMethod == methods.length - 1) { this.close(); - sys.puts("done"); + console.log("done"); } }).listen(8000); @@ -47,7 +46,7 @@ function start(method) { curMethod++; if (curMethod < methods.length) { - sys.puts("Testing " + methods[curMethod]); + console.log("Testing " + methods[curMethod]); start(methods[curMethod]); } } @@ -58,5 +57,5 @@ function start(method) { xhr.send(); } -sys.puts("Testing " + methods[curMethod]); +console.log("Testing " + methods[curMethod]); start(methods[curMethod]); diff --git a/tests/test-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js new file mode 100644 index 0000000..1a10344 --- /dev/null +++ b/tests/test-request-protocols-binary-data.js @@ -0,0 +1,90 @@ +/** + * Test GET file URL with both async and sync mode. + * Use xhr.responseType = "arraybuffer". + */ +'use strict'; +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var url = "file://" + __dirname + "/testBinaryData"; + +function download (url, isAsync) { + if (isAsync === undefined) + isAsync = true; + var xhr = new XMLHttpRequest(); + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = 'arraybuffer'; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.response); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +function runTest () { + // Async + var ab = download(url, /*isAsync*/ true); + return ab.then(afterAsyncDownload); +} + +function afterAsyncDownload(ab) { + var str = Buffer.from(ab).toString('binary'); + var strLog = logBinary(str); + log('async phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data async phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done async phase"); + + // Sync + var abSync = download(url, /*isAsync*/ false); + return abSync.then(afterSyncDownload); +} + +function afterSyncDownload(abSync) { + var str = Buffer.from(abSync).toString('binary'); + var strLog = logBinary(str); + log('sync phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data sync phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done sync phase"); +} + +runTest() + .then(() => console.log('PASSED')) + .catch((e) => { console.error('FAILED'); throw e; }); + +function logBinary(data) { + function log(data, idx) { + const char = data.charCodeAt(idx).toString(16); + // node compatibility: padStart doesn't exist to make sure return is 2 characters + if (char.length === 1) + return '0' + char; + else + return char; + } + if (!data) return 'no data'; + if (typeof data !== 'string') return 'not a string'; + let str = ''; + for (let k = 0; k < data.length - 2; k += 2) + str += `${log(data, k)}${log(data, k+1)} `; + if ((data.length % 2) == 0) + str += `${log(data, data.length - 2)}${log(data, data.length - 1)}`; + else + str += `${log(data, data.length - 1)}`; + return str; +} diff --git a/tests/test-request-protocols.js b/tests/test-request-protocols-txt-data.js similarity index 83% rename from tests/test-request-protocols.js rename to tests/test-request-protocols-txt-data.js index cd4e174..8164333 100644 --- a/tests/test-request-protocols.js +++ b/tests/test-request-protocols-txt-data.js @@ -1,5 +1,4 @@ -var sys = require("util") - , assert = require("assert") +var assert = require("assert") , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , xhr; @@ -8,7 +7,6 @@ xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); runSync(); } }; @@ -25,8 +23,7 @@ var runSync = function() { xhr.onreadystatechange = function() { if (this.readyState == 4) { assert.equal("Hello World", this.responseText); - this.close(); - sys.puts("done"); + console.log("done"); } }; xhr.open("GET", url, false); diff --git a/tests/test-response-type.js b/tests/test-response-type.js new file mode 100644 index 0000000..6b4f39f --- /dev/null +++ b/tests/test-response-type.js @@ -0,0 +1,400 @@ + +/****************************************************************************************** + * This test validates xhr.responseType as described by: + * section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + * except xhr.responseType='document' is not yet supported. + * + * 1) Create a simple min-webserver using the node http module. + * 2) Upload 2 different float32 arrays . + * 3) Upload the utf8 encoding of the underlying in-memory representations of 1). + * 4) Upload a stringified JSON object. + * 5) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ +'use strict'; + +const http = require("http"); +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Convert typed array to binary string identical to underlying in-memory representation. + * 2) Convert string to typed array when the string is the in-memory representation of a Float32Array. + * 3) Display the underlying in-memory representation of the input string data. + * 4) Pause/sleep for t milliseconds. + * 5) Create a random Float32Array of length N. + * 6) Check to see if 2 array-like objects have the same elements. + * 7) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a string corresponding to the in-memory representation of typed array ta. + * @param {{ buffer: ArrayBuffer, length: number }} ta + * @returns {string} + */ +function typedArrayToString (ta) { + const u8 = new Uint8Array(ta.buffer); + return u8.reduce((acc, cur) => acc + String.fromCharCode(cur), ""); +} + +/** + * Assumes str is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in str are all <= 0xFF. + * Returns Float32Array corresponding to str. + * + * @param {string} str + * @returns {Float32Array} + */ +function stringToFloat32Array (str) { + const u8 = new Uint8Array(str.length); + for (let k = 0; k < str.length; k++) + u8[k] = Number(str.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + bufferArray[k].copy(result, offset, 0, bufferArray[k].length) + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + if (key !== '/Json') // json not stored when uploading, but is stored when retrieving, new key makes check fail + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('-----------------------------------------------------------------------------------'); + log('storage:', JSON.stringify(storageLength())); + log('-----------------------------------------------------------------------------------'); +} + +// Xml doc for testing responseType "document" +const xmlDoc = +'' ++' test' ++' ' ++''; + +/** + * Serves up anything uploaded. + * Tested with: + * const urlF32 = "http://localhost:8888/F32"; + * const urlF32_2 = "http://localhost:8888/F32_2"; + * const urlUtf8 = "http://localhost:8888/Utf8"; + * const urlUtf8_2 = "http://localhost:8888/Utf8_2"; + * const urlJson = "http://localhost:8888/Json"; + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + //req.on('data', chunk => chunks.push(chunk)); + req.on('data', chunk => { + // console.log('foo', chunk.toString('utf8')); + // console.log('bar', JSON.parse(chunk.toString('utf8'))); + // console.log('bar', unescape(chunk.toString('utf8'))); + chunks.push(chunk); + }); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + // console.log(u8.toString('utf8')); + // console.log('-------------------'); + // console.log(xmlDoc); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) + { + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); + return; + } + if (req.url === "/Utf8" || req.url === "/Utf8_2" || req.url === "/Json" || req.url === "/Xml") + { + res.writeHead(200, {"Content-Type": "text/plain; charset=utf8"}) + res.end(storage[req.url].toString()); + return; + } + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType) +{ + responseType = responseType || 'arraybuffer'; + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads 2 different float32 arrays . + * 2) Uploads the utf8 encoding of the underlying in-memory representations of 1). + * 3) Uploads a stringified JSON object. + * 4) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ + +const N = 1 * 1000 * 1000; +const _f32 = createFloat32Array(N); +const _f32_2 = new Float32Array([ 1, 5, 6, 7, 2, 8 ]); + +const F32 = Buffer.from(_f32.buffer); +const F32_2 = Buffer.from(_f32_2.buffer); +const F32Utf8 = Buffer.from(typedArrayToString(_f32), 'utf8'); +const F32Utf8_2 = Buffer.from(typedArrayToString(_f32_2), 'utf8'); + +const urlF32 = "http://localhost:8888/F32"; +const urlF32_2 = "http://localhost:8888/F32_2"; +const urlUtf8 = "http://localhost:8888/Utf8"; +const urlUtf8_2 = "http://localhost:8888/Utf8_2"; +const urlJson = "http://localhost:8888/Json"; + +const xhr = new XMLHttpRequest(); + +const type = (o) => { return `type=${o && o.constructor && o.constructor.name}`; }; + +/** + * 1) Upload Float32Array of length N=1,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + * 2) Convert the Float32Array of 1) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="text" and check the the string length is the same as the + * byteLength of the array in 1). Downloading as "text" decodes the utf8 into the original. + * 3) Upload Float32Array([1, 5, 6, 7, 2, 8]). + * Then download using xhr.responseType="blob", extract the contained arrayBuffer, view it as + * a Float32Aray and check that the contents are identical. + * 4) Convert the Float32Array of 3) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="" and check the the string length is the same as the + * byteLength of the array in 3). Downloading as "" decodes the utf8 into the original. + * 5) Let testJson be the current mini-webserver storage object: + * e.g. testJson = {ralph:2,'/F32':4000000,'/Utf8':5333575,'/F32_2':24,'/Utf8_2':28,'/Xml':56,'/Json':77} + * Upload JSON.stringify(testJson) and download it using xhr.responseType="json" + * Check that the objects are the same by comparing the strings after calling JSON.stringify. + * 6) Did a test of xhr.responseType="document" using a simple xml example. + */ +function runTest() { + const uploadPromises = []; + var r; + return upload(xhr, urlF32, F32) // upload float32 + .then((r) => { + log('upload urlF32, F32 ', r); + }) + .then(() => { // download float32 + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { // make sure download is correct + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlUtf8, F32Utf8); + }) + .then((r) => { + log('upload urlUtf8, F32Utf8 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlF32_2, F32_2); + }) + .then((r) => { + log('upload urlF32_2, F32_2 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab) + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + log('XXXXXXXXXXXXXXXXX', urlUtf8_2, F32Utf8_2) + return upload(xhr, urlUtf8_2, F32Utf8_2); + }) + .then((r) => { + log('upload urlUtf8_2, F32Utf8_2', r); + }) + .then(() => { + return download(xhr, urlUtf8_2, 'text'); + }) + .then((text2) => { + const text2_f32 = stringToFloat32Array(text2); + log('download urlUtf8_2 default', text2.length, type(text2), text2_f32); + if (!isEqual(text2_f32, _f32_2)) + throw new Error(`Download from urlUtf8_2 has incorrect content: ${text2_f32} !== ${_f32_2}`); + }) + .then(() => { + return upload(xhr, urlJson, JSON.stringify(storageLength())); + }) + .then((r) => { + log('upload:urlJson, storage ', r); + }) + .then(() => { + return download(xhr, urlJson, 'json'); + }) + .then((json) => { + log(`download urlJson json ${JSON.stringify(json).length}`, type(json), json); + const testJson = storageLength(); + if (JSON.stringify(json) !== JSON.stringify(testJson)) + throw new Error(`Download from urlJson has incorrect content:\n ${JSON.stringify(json)} !== ${JSON.stringify(testJson)}`); + }); + +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); +}, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-sync-flag.js b/tests/test-sync-flag.js new file mode 100644 index 0000000..8f9af5c --- /dev/null +++ b/tests/test-sync-flag.js @@ -0,0 +1,107 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require("child_process").spawn + , serverProcess + , process = require("process"); + +// spawn a server +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + throw e; + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); + +/** + * stage = 0 // idle + * stage = 1 // expect warning to check + * stage = 2 // available but does not expect warning + */ +var stage = 0; +// warning catch +let oldWarn = console.warn; +console.warn = function (warning) { + if (stage > 0) { + if (stage === 1) { + assert.equal(warning, "[Deprecation] Synchronous XMLHttpRequest is deprecated because of its detrimental effects to the end user's experience. For more information, see https://xhr.spec.whatwg.org/#sync-flag"); + console.log("Correct warning caught."); + } + else if (stage === 2) { + throw "Does not expect warning, caught " + JSON.stringify(warning); + } + } + + return oldWarn.call(this, warning); +} + +var runTest = function () { + // xhr with no syncPolicy (default = warn) + try { + console.log("Testing 1: XHR with no syncPolicy (default = warn)"); + var xhr = new XMLHttpRequest(); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 1: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = warn + try { + console.log("Testing 2: XHR with syncPolicy = warn"); + var xhr = new XMLHttpRequest({ syncPolicy: "warn" }); + stage = 1; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 2: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = enabled + try { + console.log("Testing 3: XHR with syncPolicy = enabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "enabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + assert.equal(xhr.responseText, "Hello world!"); + console.log("Test 3: PASSED"); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } + + // xhr with syncPolicy = disabled + var errored = false; + try { + console.log("Testing 4: XHR with syncPolicy = disabled"); + var xhr = new XMLHttpRequest({ syncPolicy: "disabled" }); + stage = 2; + xhr.open("GET", "http://localhost:8888/text", false); + stage = 0; + xhr.send(); + } catch(e) { + errored = true; + assert.equal(e.message, "Synchronous requests are disabled for this instance."); + console.log("Correct error message.") + console.log("Test 4: PASSED"); + } + + if (!errored) throw "Test 4 expects an error."; +} diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js new file mode 100644 index 0000000..a9ae473 --- /dev/null +++ b/tests/test-sync-response.js @@ -0,0 +1,127 @@ +/** + * Test GET http URL with both async and sync mode. + * Use xhr.responseType = "" and "arraybuffer". + */ +'use strict'; + +var assert = require("assert") + , spawn = require('child_process').spawn + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , serverProcess; + +const supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log.apply(console, arguments); +} + +// Running a sync XHR and a webserver within the same process will cause a deadlock +serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +setTimeout(function () { + try { + runTest(); + console.log('PASSED'); + } catch (e) { + console.log('FAILED'); + throw e; + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + +function runTest() { + var xhr = new XMLHttpRequest(); + var isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.responseText is a 'utf8' string. + var str = xhr.responseText; + log('/text', str); + assert.equal(xhr.responseText, "Hello world!"); + assert.equal(xhr.getResponseHeader('content-type'), 'text/plain') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/text", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.response is an ArrayBuffer + var str = Buffer.from(xhr.response).toString('utf8'); + log('/binary1', str); + assert.equal(str, 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.responseType = 'arraybuffer'; + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.response is an ArrayBuffer + var binary = Buffer.from(xhr.response); + var f32 = new Float32Array(binary); + log('/binary2', f32); + var answer = new Float32Array([1, 5, 6, 7]); + assert.equal(isEqual(f32, answer), true); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary2", false); + xhr.responseType = 'arraybuffer'; + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + assert.equal(xhr.response.toString(), 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + console.log("done"); +} diff --git a/tests/test-unsafe-redirect.js b/tests/test-unsafe-redirect.js new file mode 100644 index 0000000..065d808 --- /dev/null +++ b/tests/test-unsafe-redirect.js @@ -0,0 +1,41 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , xhr = new XMLHttpRequest() + , http = require("http"); + +// Test server +var server = http.createServer(function (req, res) { + if (req.url === '/redirectingResource') { + res.writeHead(301, {'Location': 'file:///etc/passwd'}); + res.end(); + } + else { + var body = "Hello World"; + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(body), + "Date": "Thu, 30 Aug 2012 18:17:53 GMT", + "Connection": "close" + }); + res.write("Hello World"); + res.end(); + } + + this.close(); +}).listen(8000); + +xhr.onreadystatechange = function() { + if (this.readyState === 4) { + assert.equal(xhr.statusText, "Unsafe redirect"); + assert.equal(xhr.status, 0); + console.log("done"); + } +}; + +try { + xhr.open("GET", "http://localhost:8000/redirectingResource"); + xhr.send(); +} catch(e) { + console.log("ERROR: Exception raised", e); + throw e; +} diff --git a/tests/test-url-origin.js b/tests/test-url-origin.js new file mode 100644 index 0000000..ff89b2c --- /dev/null +++ b/tests/test-url-origin.js @@ -0,0 +1,47 @@ +var assert = require("assert") + , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest + , spawn = require('child_process').spawn; + +// Test server +var serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); + +var runTest = function () { + try { + let xhr = new XMLHttpRequest({ origin: "http://localhost:8888" }); + xhr.open("GET", "text", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.getResponseHeader('Content-Type'), 'text/plain'); + assert.equal(xhr.responseText, "Hello world!"); + console.log("origin test 1: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + } + + try { + let xhr = new XMLHttpRequest({ origin: "http://localhost:8888/text" }); + xhr.open("GET", "", false); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + assert.equal(xhr.getResponseHeader('Content-Type'), 'text/plain'); + assert.equal(xhr.responseText, "Hello world!"); + console.log("origin test 2: done"); + } + }; + xhr.send(); + } catch(e) { + console.log("ERROR: Exception raised", e); + throw e; + } +} + +setTimeout(function () { + try { + runTest(); + } finally { + serverProcess.kill('SIGINT'); + } +}, 100); diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js new file mode 100644 index 0000000..79a6d46 --- /dev/null +++ b/tests/test-utf8-tearing.js @@ -0,0 +1,276 @@ + +/****************************************************************************************** + * Assume a web server serves up the utf8 encoding of a random Uint8Array, + * so that xhr.responseText is a string corresponding to the in-memory + * representation of the Uint8Array. This test demonstrates a bug in xmlhttprequest-ssl, + * where the utf8 endcoding of a byte with 0x80 <= byte <= 0xff, is torn across 2 chunks. + * + * Consider a code point 0x80. The utf8 encoding has 2 bytes 0xc2 and 0x80. + * It is possible for one chunk to end with 0xc2 and the next chunk starts with 0x80. + * This is what is meant by tearing. The fix is to remove + * self.responseText += data.toString('utf8'); + * from the response 'data' handler and add the following to the response 'end' handler + * // Construct responseText from response + * self.responseText = self.response.toString('utf8'); + */ +'use strict'; + +var assert = require("assert"); +var http = require("http"); +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; + +var supressConsoleOutput = true; +function log (_) { + if ( !supressConsoleOutput) + console.log(arguments); +} + +var serverProcess; + +/****************************************************************************************** + * This section produces a web server that serves up + * 1) Buffer.from(ta.buffer) using url = "http://localhost:8888/binary"; + * 2) utf8 encoding of ta_to_hexStr(ta) using url = "http://localhost:8888/binaryUtf8"; + * where ta is a Float32Array. + * Note: In order to repro utf8 tearing ta.length needs to be pretty big + * N = 1 * 1000 * 1000; + */ + +/** + * Create a string corresponding to the in-memory representation of Float32Array ta. + * + * @param {Float32Array} ta + * @returns {string} + */ +function ta_to_hexStr(ta) { + var u8 = new Uint8Array(ta.buffer); + return u8.reduce(function (acc, cur) { return acc + String.fromCharCode(cur) }, ""); +} + +/** + * Create a random Float32Array of length N. + * + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array(N) { + assert(N > 0); + var ta = new Float32Array(N); + for (var k = 0; k < ta.length; k++) + ta[k] = Math.random(); + //ta = new Float32Array([1, 5, 6, 7]); // Use to debug + return ta; +} +var N = 1 * 1000 * 1000; // Needs to be big enough to tear a few utf8 sequences. +var f32 = createFloat32Array(N); + +/** + * From a Float32Array f32 transform into: + * 1) buffer: Buffer.from(ta.buffer) + * 2) bufferUtf8: utf8 encoding of ta_to_hexStr(ta) + * + * @param {Float32Array} f32 + * @returns {{ buffer: Buffer, bufferUtf8: Buffer }} + */ +function createBuffers(f32) { + var buffer = Buffer.from(f32.buffer); + var ss = ta_to_hexStr(f32); + var bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 + return { buffer, bufferUtf8 }; +} +var bufs = createBuffers(f32); +var buffer = bufs.buffer, + bufferUtf8 = bufs.bufferUtf8 + +/** + * Serves up buffer at + * url = "http://localhost:8888/binary"; + * Serves up bufferUtf8 at + * url = "http://localhost:8888/binaryUtf8"; + * + * @param {Buffer} buffer + * @param {Buffer} bufferUtf8 + */ +function createServer(buffer, bufferUtf8) { + serverProcess = http.createServer(function (req, res) { + switch (req.url) { + case "/binary": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(buffer); + return; + case "/binaryUtf8": + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(bufferUtf8); + return; + default: + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + return; + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(buffer, bufferUtf8); + +/****************************************************************************************** + * This section tests the above web server and verifies the correct Float32Array can be + * successfully reconstituted for both + * 1) url = "http://localhost:8888/binary"; + * 2) url = "http://localhost:8888/binaryUtf8"; + */ + +/** + * Assumes hexStr is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in hexStr are all <= 0xFF. + * Returns Float32Array corresponding to hexStr. + * + * @param {string} hexStr + * @returns {Float32Array} + */ +function hexStr_to_ta(hexStr) { + var u8 = new Uint8Array(hexStr.length); + for (var k = 0; k < hexStr.length; k++) + u8[k] = Number(hexStr.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Verify ta1 and ta2 are the same kind of view. + * Verify the first count elements of ta1 and ta2 are equal. + * + * @param {Float32Array} ta1 + * @param {Float32Array} ta2 + * @param {number} [count=1000] + * @returns {boolean} + */ +function checkEnough(ta1, ta2, count) { + if (count === undefined) + count = 1000 + assert(ta1 && ta2); + if (ta1.constructor.name !== ta2.constructor.name) return false; + if (ta1.length !== ta2.length) return false; + if (ta1.byteOffset !== ta2.byteOffset) return false; + for (var k = 0; k < Math.min(count, ta1.length); k++) { + if (ta1[k] !== ta2[k]) { + log('checkEnough: Not Equal!', k, ta1[k], ta2[k]); + return false; + } + } + return true; +} + +var xhr = new XMLHttpRequest(); +var url = "http://localhost:8888/binary"; +var urlUtf8 = "http://localhost:8888/binaryUtf8"; + +function download (xhr, url, responseType) +{ + if (responseType === undefined) + responseType = 'arraybuffer'; + return new Promise(function (resolve, reject) { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = function () { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + var errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/** + * Send a GET request to the server. + * When isUtf8 is true, assume that xhr.response is already + * utf8 encoded so that xhr.responseText. + * + * @param {string} url + * @param {boolean} isUtf8 + * @returns {Promise} + */ +function Get(url, isUtf8) { + return download(xhr, url, 'text').then((dataTxt) => { + return download(xhr, url, 'arraybuffer').then((ab) => { + var data = Buffer.from(ab); + + assert(dataTxt && data); + + log('XHR GET:', dataTxt.length, data.length, data.toString('utf8').length); + log('XHR GET:', data.constructor.name, dataTxt.constructor.name); + + if (isUtf8 && dataTxt.length !== data.toString('utf8').length) + throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); + + var ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); + log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); + + if (!checkEnough(ta, f32)) + throw new Error("Unable to correctly reconstitute Float32Array"); + + return ta; + }) + }); +} + +/** + * Test function which gets utf8 encoded bytes of the typed array + * new Uint8Array(new Float32Array(N).buffer), + * then it gets the raw bytes from + * new Uint8Array(new Float32Array(N).buffer). + * Before the utf8 tearing bug is fixed, + * Get(urlUtf8, true) + * will fail with the exception: + * Error: xhr.responseText !== xhr.response.toString('utf8'). + * + * @returns {Promise} + */ +function runTest() { + return Get(urlUtf8, true) + .then(function () { return Get(url, false); }); +} + +/** + * Run the test. + */ +setTimeout(function () { + runTest() + .then(function (ta) { + console.log("done", ta && ta.length); + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }) + .catch(function (e) { + console.log("FAILED"); + if (serverProcess) + serverProcess.close(); + serverProcess = null; + throw e; + }) +}, 100); + diff --git a/tests/testBinaryData b/tests/testBinaryData new file mode 100644 index 0000000..f998f0a Binary files /dev/null and b/tests/testBinaryData differ diff --git a/tests/testdata.txt b/tests/testdata.txt index 557db03..5e1c309 100644 --- a/tests/testdata.txt +++ b/tests/testdata.txt @@ -1 +1 @@ -Hello World +Hello World \ No newline at end of file