From 4c7c6eee299ceae3b59e15721da438a77a42b22d Mon Sep 17 00:00:00 2001 From: Patrick Sevat Date: Tue, 28 Oct 2025 10:44:42 +0100 Subject: [PATCH 1/3] breaking(): remove legacy code and introduce browser entry --- browser.js | 436 +++++++++++ encoder/browser.js | 370 ++++++++++ index.js | 15 - legacy.js | 242 ------ package.json | 14 +- test/benchmark/toLegacyNodeUrl.bench.js | 20 - test/karma.conf.js | 4 +- test/unit/encode.browser.test.js | 140 ++++ test/unit/encodeQueryString.browser.test.js | 40 + test/unit/encoder/encoder.browser.test.js | 532 ++++++++++++++ test/unit/encoder/encoder.test.js | 2 +- test/unit/resolveNodeUrl.browser.test.js | 41 ++ test/unit/toLegacyNodeUrl.test.js | 30 - test/unit/toNodeUrl.browser.test.js | 771 ++++++++++++++++++++ test/unit/toNodeUrl.test.js | 2 +- 15 files changed, 2347 insertions(+), 312 deletions(-) create mode 100644 browser.js create mode 100644 encoder/browser.js delete mode 100644 legacy.js delete mode 100644 test/benchmark/toLegacyNodeUrl.bench.js create mode 100644 test/unit/encode.browser.test.js create mode 100644 test/unit/encodeQueryString.browser.test.js create mode 100644 test/unit/encoder/encoder.browser.test.js create mode 100644 test/unit/resolveNodeUrl.browser.test.js delete mode 100644 test/unit/toLegacyNodeUrl.test.js create mode 100644 test/unit/toNodeUrl.browser.test.js diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..c81deed --- /dev/null +++ b/browser.js @@ -0,0 +1,436 @@ +/** + * Implementation of the WHATWG URL Standard. + * + * @example + * const urlEncoder = require('postman-url-encoder') + * + * // Encoding URL string to Node.js compatible Url object + * urlEncoder.toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`') + * + * // Encoding URI component + * urlEncoder.encode('qüêry štrìng') + * + * // Encoding query string object + * urlEncoder.encodeQueryString({ q1: 'foo', q2: ['bãr', 'baž'] }) + * + * @module postman-url-encoder + * @see {@link https://url.spec.whatwg.org} + */ + +const parser = require('./parser'), + encoder = require('./encoder/browser'), + QUERY_ENCODE_SET = require('./encoder/encode-set').QUERY_ENCODE_SET, + + E = '', + COLON = ':', + BACK_SLASH = '\\', + DOUBLE_SLASH = '//', + DOUBLE_BACK_SLASH = '\\\\', + STRING = 'string', + OBJECT = 'object', + FUNCTION = 'function', + DEFAULT_PROTOCOL = 'http', + LEFT_SQUARE_BRACKET = '[', + RIGHT_SQUARE_BRACKET = ']', + + PATH_SEPARATOR = '/', + QUERY_SEPARATOR = '?', + PARAMS_SEPARATOR = '&', + SEARCH_SEPARATOR = '#', + DOMAIN_SEPARATOR = '.', + AUTH_CREDENTIALS_SEPARATOR = '@', + + // @note this regular expression is referred from Node.js URL parser + PROTOCOL_RE = /^[a-z0-9.+-]+:(?:\/\/|\\\\)./i, + + /** + * Protocols that always contain a // bit. + * + * @private + * @see {@link https://github.com/nodejs/node/blob/v10.17.0/lib/url.js#L91} + */ + SLASHED_PROTOCOLS = { + 'file:': true, + 'ftp:': true, + 'gopher:': true, + 'http:': true, + 'https:': true, + 'ws:': true, + 'wss:': true + }; + +/** + * Returns stringified URL from Url object but only includes parts till given + * part name. + * + * @example + * var url = 'http://postman.com/foo?q=v#hash'; + * getUrlTill(toNodeUrl(url), 'host') + * // returns 'http://postman.com' + * + * @private + * @param {Object} url base URL + * @param {String} [urlPart='query'] one of ['host', 'pathname', 'query'] + */ +function getUrlTill (url, urlPart) { + let result = ''; + + if (url.protocol) { + result += url.protocol + DOUBLE_SLASH; + } + + if (url.auth) { + result += url.auth + AUTH_CREDENTIALS_SEPARATOR; + } + + result += url.host || E; + + if (urlPart === 'host') { return result; } + + result += url.pathname; + + if (urlPart === 'pathname') { return result; } + + // urlPart must be query at this point + return result + (url.search || E); +} + +/** + * Percent-encode the given string using QUERY_ENCODE_SET. + * + * @deprecated since version 2.0, use {@link encodeQueryParam} instead. + * + * @example + * // returns 'foo%20%22%23%26%27%3C%3D%3E%20bar' + * encode('foo "#&\'<=> bar') + * + * // returns '' + * encode(['foobar']) + * + * @param {String} value String to percent-encode + * @returns {String} Percent-encoded string + */ +function encode (value) { + return encoder.percentEncode(value, QUERY_ENCODE_SET); +} + +/** + * Percent-encode the URL query string or x-www-form-urlencoded body object + * according to RFC3986. + * + * @example + * // returns 'q1=foo&q2=bar&q2=baz' + * encodeQueryString({ q1: 'foo', q2: ['bar', 'baz'] }) + * + * @param {Object} query Object representing query or urlencoded body + * @returns {String} Percent-encoded string + */ +function encodeQueryString (query) { + if (!(query && typeof query === OBJECT)) { + return E; + } + + if (Array.isArray(query)) { + query = query.map((item, index) => { + return [index, item]; + }); + } + + query = new URLSearchParams(query).toString(); + + // encode characters not encoded by querystring.stringify() according to RFC3986. + return query.replace(/[!'()*]/g, function (c) { + return encoder.percentEncodeCharCode(c.charCodeAt(0)); + }); +} + +/** + * Converts PostmanUrl / URL string into Node.js compatible Url object. + * + * @example Using URL string + * toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`') + * // returns + * // { + * // protocol: 'http:', + * // slashes: true, + * // auth: null, + * // host: 'xn--48jwgn17gdel797d.com', + * // port: null, + * // hostname: 'xn--48jwgn17gdel797d.com', + * // hash: '#%60hash%60', + * // search: '?q=(%22foo%22)', + * // query: 'q=(%22foo%22)', + * // pathname: '/foo&bar/%7Bbaz%7D', + * // path: '/foo&bar/%7Bbaz%7D?q=(%22foo%22)', + * // href: 'http://xn--48jwgn17gdel797d.com/foo&bar/%7Bbaz%7D?q=(%22foo%22)#%60hash%60' + * // } + * + * @example Using PostmanUrl instance + * toNodeUrl(new sdk.Url({ + * host: 'example.com', + * query: [{ key: 'foo', value: 'bar & baz' }] + * })) + * + * @param {PostmanUrl|String} url URL string or PostmanUrl object + * @param {Boolean} disableEncoding Turn encoding off + * @returns {Url} Node.js like parsed and encoded object + */ +function toNodeUrl (url, disableEncoding) { + let nodeUrl = { + protocol: null, + slashes: null, + auth: null, + host: null, + port: null, + hostname: null, + hash: null, + search: null, + query: null, + pathname: null, + path: null, + href: E + }, + port, + hostname, + pathname, + authUser, + queryParams, + authPassword; + + // Check if PostmanUrl instance and prepare segments + if (url && url.constructor && url.constructor._postman_propertyName === 'Url') { + // @note getPath() always adds a leading '/', similar to Node.js API + pathname = url.getPath(); + hostname = url.getHost().toLowerCase(); + + if (url.query && url.query.count()) { + queryParams = url.getQueryString({ ignoreDisabled: true }); + queryParams = disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams); + + // either all the params are disabled or a single param is like { key: '' } (http://localhost?) + // in that case, query separator ? must be included in the raw URL. + // @todo Add helper in SDK to handle this + if (queryParams === E) { + // check if there's any enabled param, if so, set queryString to empty string + // otherwise (all disabled), it will be set as undefined + queryParams = url.query.find(function (param) { return !(param && param.disabled); }) && E; + } + } + + if (url.auth) { + authUser = url.auth.user; + authPassword = url.auth.password; + } + } + // Parser URL string and prepare segments + else if (typeof url === STRING) { + url = parser.parse(url); + + pathname = PATH_SEPARATOR + (url.path || []).join(PATH_SEPARATOR); + hostname = (url.host || []).join(DOMAIN_SEPARATOR).toLowerCase(); + queryParams = url.query && (queryParams = url.query.join(PARAMS_SEPARATOR)) && + (disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams)); + authUser = (url.auth || [])[0]; + authPassword = (url.auth || [])[1]; + } + // bail out with empty URL object for invalid input + else { + return nodeUrl; + } + + // @todo Add helper in SDK to normalize port + // eslint-disable-next-line no-eq-null, eqeqeq + if (!(url.port == null) && typeof url.port.toString === FUNCTION) { + port = url.port.toString(); + } + + // #protocol + nodeUrl.protocol = (typeof url.protocol === STRING) ? url.protocol.toLowerCase() : DEFAULT_PROTOCOL; + nodeUrl.protocol += COLON; + + // #slashes + nodeUrl.slashes = SLASHED_PROTOCOLS[nodeUrl.protocol] || false; + + // #href = protocol:// + nodeUrl.href = nodeUrl.protocol + DOUBLE_SLASH; + + // #auth + if (url.auth) { + if (typeof authUser === STRING) { + nodeUrl.auth = disableEncoding ? authUser : encoder.encodeUserInfo(authUser); + } + + if (typeof authPassword === STRING) { + !nodeUrl.auth && (nodeUrl.auth = E); + nodeUrl.auth += COLON + (disableEncoding ? authPassword : encoder.encodeUserInfo(authPassword)); + } + + if (typeof nodeUrl.auth === STRING) { + // #href = protocol://user:password@ + nodeUrl.href += nodeUrl.auth + AUTH_CREDENTIALS_SEPARATOR; + } + } + + // #host, #hostname + nodeUrl.host = nodeUrl.hostname = hostname = encoder.encodeHost(hostname); // @note always encode hostname + + // #href = protocol://user:password@host.name + nodeUrl.href += nodeUrl.hostname; + + // #port + if (typeof port === STRING) { + nodeUrl.port = port; + + // #host = (#hostname):(#port) + nodeUrl.host = nodeUrl.hostname + COLON + port; + + // #href = protocol://user:password@host.name:port + nodeUrl.href += COLON + port; + } + + // #path, #pathname + nodeUrl.path = nodeUrl.pathname = disableEncoding ? pathname : encoder.encodePath(pathname); + + // #href = protocol://user:password@host.name:port/p/a/t/h + nodeUrl.href += nodeUrl.pathname; + + if (typeof queryParams === STRING) { + // #query + nodeUrl.query = queryParams; + + // #search + nodeUrl.search = QUERY_SEPARATOR + nodeUrl.query; + + // #path = (#pathname)?(#search) + nodeUrl.path = nodeUrl.pathname + nodeUrl.search; + + // #href = protocol://user:password@host.name:port/p/a/t/h?q=query + nodeUrl.href += nodeUrl.search; + } + + if (typeof url.hash === STRING) { + // #hash + nodeUrl.hash = SEARCH_SEPARATOR + (disableEncoding ? url.hash : encoder.encodeFragment(url.hash)); + + // #href = protocol://user:password@host.name:port/p/a/t/h?q=query#hash + nodeUrl.href += nodeUrl.hash; + } + + // Finally apply Node.js shenanigans + // # Remove square brackets from IPv6 #hostname + // Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/url.js#L399 + // Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/internal/url.js#L1273 + if (hostname[0] === LEFT_SQUARE_BRACKET && hostname[hostname.length - 1] === RIGHT_SQUARE_BRACKET) { + nodeUrl.hostname = hostname.slice(1, -1); + } + + return nodeUrl; +} + +/** + * Resolves a relative URL with respect to given base URL. + * This is a replacement method for Node's url.resolve() which is compatible + * with URL object generated by toNodeUrl(). + * + * @example + * // returns 'http://postman.com/baz' + * resolveNodeUrl('http://postman.com/foo/bar', '/baz') + * + * @param {Object|String} base URL string or toNodeUrl() object + * @param {String} relative Relative URL to resolve + * @returns {String} Resolved URL + */ +function resolveNodeUrl (base, relative) { + // normalize arguments + typeof base === STRING && (base = toNodeUrl(base)); + typeof relative !== STRING && (relative = E); + + // bail out if base is not an object + if (!(base && typeof base === OBJECT)) { + return relative; + } + + let i, + ii, + index, + baseHref, + relative_0, + relative_01, + basePathname, + requiredProps = ['protocol', 'auth', 'host', 'pathname', 'search', 'href']; + + // bail out if base is not like Node url object + for (i = 0, ii = requiredProps.length; i < ii; i++) { + if (!Object.hasOwnProperty.call(base, requiredProps[i])) { + return relative; + } + } + + // cache base.href and base.pathname + baseHref = base.href; + basePathname = base.pathname; + + // cache relative's first two chars + relative_0 = relative.slice(0, 1); + relative_01 = relative.slice(0, 2); + + // @note relative can be one of + // #1 empty string + // #2 protocol relative, starts with // or \\ + // #3 path relative, starts with / or \ + // #4 just query or hash, starts with ? or # + // #5 absolute URL, starts with :// or :\\ + // #6 free from path, with or without query and hash + + // #1 empty string + if (!relative) { + // return base as it is if there is no hash + if ((index = baseHref.indexOf(SEARCH_SEPARATOR)) === -1) { + return baseHref; + } + + // else, return base without the hash + return baseHref.slice(0, index); + } + + // #2 protocol relative, starts with // or \\ + // @note \\ is not converted to // + if (relative_01 === DOUBLE_SLASH || relative_01 === DOUBLE_BACK_SLASH) { + return base.protocol + relative; + } + + // #3 path relative, starts with / or \ + // @note \(s) are not converted to / + if (relative_0 === PATH_SEPARATOR || relative_0 === BACK_SLASH) { + return getUrlTill(base, 'host') + relative; + } + + // #4 just hash, starts with # + if (relative_0 === SEARCH_SEPARATOR) { + return getUrlTill(base, 'query') + relative; + } + + // #4 just query, starts with ? + if (relative_0 === QUERY_SEPARATOR) { + return getUrlTill(base, 'pathname') + relative; + } + + // #5 absolute URL, starts with :// or :\\ + // @note :\\ is not converted to :// + if (PROTOCOL_RE.test(relative)) { + return relative; + } + + // #6 free from path, with or without query and hash + // remove last path segment form base path + basePathname = basePathname.slice(0, basePathname.lastIndexOf(PATH_SEPARATOR) + 1); + + return getUrlTill(base, 'host') + basePathname + relative; +} + +module.exports = { + encode, + toNodeUrl, + resolveNodeUrl, + encodeQueryString +}; diff --git a/encoder/browser.js b/encoder/browser.js new file mode 100644 index 0000000..95ed811 --- /dev/null +++ b/encoder/browser.js @@ -0,0 +1,370 @@ +/** + * This module helps to encode different URL components and expose utility + * methods to percent-encode a given string using an {@link EncodeSet}. + * + * @example + * const encoder = require('postman-url-encoder/encoder') + * + * // returns 'xn--48jwgn17gdel797d.com' + * encoder.encodeHost('郵便屋さん.com') + * + * @example Using EncodeSet + * var EncodeSet = require('postman-url-encoder/encoder').EncodeSet + * + * var fragmentEncodeSet = new EncodeSet([' ', '"', '<', '>', '`']) + * + * // returns false + * fragmentEncodeSet.has('['.charCodeAt(0)) + * + * // returns true + * fragmentEncodeSet.has('<'.charCodeAt(0)) + * + * @module postman-url-encoder/encoder + * @see {@link https://url.spec.whatwg.org/#url-representation} + */ + +/** + * @fileoverview + * This module determines which of the reserved characters in the different + * URL components should be percent-encoded and which can be safely used. + * + * The generic URI syntax consists of a hierarchical sequence of components + * referred to as the scheme, authority, path, query, and fragment. + * + * URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + * + * hier-part = "//" authority path-abempty + * / path-absolute + * / path-rootless + * / path-empty + * + * authority = [ userinfo "@" ] host [ ":" port ] + * + * @see {@link https://tools.ietf.org/html/rfc3986#section-2} + * @see {@link https://tools.ietf.org/html/rfc3986#section-3} + */ + +const encodeSet = require('./encode-set'), + + punycode = require('punycode/'), + _percentEncode = require('./percent-encode').encode, + _percentEncodeCharCode = require('./percent-encode').encodeCharCode, + + EncodeSet = encodeSet.EncodeSet, + + PATH_ENCODE_SET = encodeSet.PATH_ENCODE_SET, + QUERY_ENCODE_SET = encodeSet.QUERY_ENCODE_SET, + USERINFO_ENCODE_SET = encodeSet.USERINFO_ENCODE_SET, + FRAGMENT_ENCODE_SET = encodeSet.FRAGMENT_ENCODE_SET, + C0_CONTROL_ENCODE_SET = encodeSet.C0_CONTROL_ENCODE_SET, + + PARAM_VALUE_ENCODE_SET = EncodeSet.extend(QUERY_ENCODE_SET, ['&']).seal(), + PARAM_KEY_ENCODE_SET = EncodeSet.extend(QUERY_ENCODE_SET, ['&', '=']).seal(), + + E = '', + EQUALS = '=', + AMPERSAND = '&', + STRING = 'string', + OBJECT = 'object', + + PATH_SEPARATOR = '/', + DOMAIN_SEPARATOR = '.', + + /** + * Returns the Punycode ASCII serialization of the domain. + * + * @private + * @function + * @param {String} domain domain name + * @returns {String} punycode encoded domain name + */ + domainToASCII = function (domain) { + const domainWithProtocol = domain.startsWith('http') ? domain : `https://${domain}`; + + try { + return new URL(domainWithProtocol).hostname; + } + catch (error) { + return punycode.toASCII(domain); + } + + // try { + // return punycode.toASCII(domain); + // } + // catch (error) { + // return ''; + // } + }; + +/** + * Returns the Punycode ASCII serialization of the domain. + * + * @note Returns input hostname on invalid domain. + * + * @example + * // returns 'xn--fiq228c.com' + * encodeHost('中文.com') + * + * // returns 'xn--48jwgn17gdel797d.com' + * encodeHost(['郵便屋さん', 'com']) + * + * // returns '127.0.0.1' + * encodeHost('127.1') + * + * // returns 'xn--iñvalid.com' + * encodeHost('xn--iñvalid.com') + * + * @param {String|String[]} hostName host or domain name + * @returns {String} Punycode-encoded hostname + */ +function encodeHost (hostName) { + if (Array.isArray(hostName)) { + hostName = hostName.join(DOMAIN_SEPARATOR); + } + + if (typeof hostName !== STRING) { + return E; + } + + // return input host name if `domainToASCII` returned an empty string + return domainToASCII(hostName) || hostName; +} + +/** + * Encodes URL path or individual path segments. + * + * @example + * // returns 'foo/bar&baz' + * encodePath('foo/bar&baz') + * + * // returns 'foo/bar/%20%22%3C%3E%60%23%3F%7B%7D' + * encodePath(['foo', 'bar', ' "<>\`#?{}']) + * + * @param {String|String[]} path Path or path segments + * @returns {String} Percent-encoded path + */ +function encodePath (path) { + if (Array.isArray(path) && path.length) { + path = path.join(PATH_SEPARATOR); + } + + if (typeof path !== STRING) { + return E; + } + + return _percentEncode(path, PATH_ENCODE_SET); +} + +/** + * Encodes URL userinfo (username / password) fields. + * + * @example + * // returns 'info~%20%22%3C%3E%60%23%3F%7B%7D%2F%3A%3B%3D%40%5B%5C%5D%5E%7C' + * encodeAuth('info~ "<>`#?{}/:;=@[\\]^|') + * + * @param {String} param Parameter to encode + * @returns {String} Percent-encoded parameter + */ +function encodeUserInfo (param) { + if (typeof param !== STRING) { + return E; + } + + return _percentEncode(param, USERINFO_ENCODE_SET); +} + +/** + * Encodes URL fragment identifier or hash. + * + * @example + * // returns 'fragment#%20%22%3C%3E%60' + * encodeHash('fragment# "<>`') + * + * @param {String} fragment Hash or fragment identifier to encode + * @returns {String} Percent-encoded fragment + */ +function encodeFragment (fragment) { + if (typeof fragment !== STRING) { + return E; + } + + return _percentEncode(fragment, FRAGMENT_ENCODE_SET); +} + +/** + * Encodes single query parameter and returns as a string. + * + * @example + * // returns 'param%20%22%23%27%3C%3E' + * encodeQueryParam('param "#\'<>') + * + * // returns 'foo=bar' + * encodeQueryParam({ key: 'foo', value: 'bar' }) + * + * @param {Object|String} param Query param to encode + * @returns {String} Percent-encoded query param + */ +function encodeQueryParam (param) { + if (!param) { + return E; + } + + if (typeof param === STRING) { + return _percentEncode(param, QUERY_ENCODE_SET); + } + + let key = param.key, + value = param.value, + result; + + if (typeof key === STRING) { + result = _percentEncode(key, PARAM_KEY_ENCODE_SET); + } + else { + result = E; + } + + if (typeof value === STRING) { + result += EQUALS + _percentEncode(value, PARAM_VALUE_ENCODE_SET); + } + + return result; +} + +/** + * Encodes list of query parameters and returns encoded query string. + * + * @example + * // returns 'foo=bar&=foo%26bar' + * encodeQueryParams([{ key: 'foo', value: 'bar' }, { value: 'foo&bar' }]) + * + * // returns 'q1=foo&q2=bar&q2=baz' + * encodeQueryParams({ q1: 'foo', q2: ['bar', 'baz'] }) + * + * @param {Object|Object[]} params Query params to encode + * @returns {String} Percent-encoded query string + */ +function encodeQueryParams (params) { + let i, + j, + ii, + jj, + paramKey, + paramKeys, + paramValue, + result = E, + notFirstParam = false; + + if (!(params && typeof params === OBJECT)) { + return E; + } + + // handle array of query params + if (Array.isArray(params)) { + for (i = 0, ii = params.length; i < ii; i++) { + // @todo Add helper in PropertyList to filter disabled QueryParam + if (!params[i] || params[i].disabled === true) { + continue; + } + + // don't add '&' for the very first enabled param + notFirstParam && (result += AMPERSAND); + notFirstParam = true; + + result += encodeQueryParam(params[i]); + } + + return result; + } + + // handle object with query params + paramKeys = Object.keys(params); + + for (i = 0, ii = paramKeys.length; i < ii; i++) { + paramKey = paramKeys[i]; + paramValue = params[paramKey]; + + // { key: ['value1', 'value2', 'value3'] } + if (Array.isArray(paramValue)) { + for (j = 0, jj = paramValue.length; j < jj; j++) { + notFirstParam && (result += AMPERSAND); + notFirstParam = true; + + result += encodeQueryParam({ key: paramKey, value: paramValue[j] }); + } + } + // { key: 'value' } + else { + notFirstParam && (result += AMPERSAND); + notFirstParam = true; + + result += encodeQueryParam({ key: paramKey, value: paramValue }); + } + } + + return result; +} + +/** + * Percent-encode the given string with the given {@link EncodeSet}. + * + * @example Defaults to C0_CONTROL_ENCODE_SET + * // returns 'foo %00 bar' + * percentEncode('foo \u0000 bar') + * + * @example Encode literal @ using custom EncodeSet + * // returns 'foo%40bar' + * percentEncode('foo@bar', new EncodeSet(['@'])) + * + * @param {String} value String to percent-encode + * @param {EncodeSet} [encodeSet=C0_CONTROL_ENCODE_SET] EncodeSet to use for encoding + * @returns {String} Percent-encoded string + */ +function percentEncode (value, encodeSet) { + if (!(value && typeof value === STRING)) { + return E; + } + + // defaults to C0_CONTROL_ENCODE_SET + if (!EncodeSet.isEncodeSet(encodeSet)) { + encodeSet = C0_CONTROL_ENCODE_SET; + } + + return _percentEncode(value, encodeSet); +} + +/** + * Percent encode a character with given code. + * + * @example + * // returns '%20' + * percentEncodeCharCode(32) + * + * @param {Number} code Character code + * @returns {String} Percent-encoded character + */ +function percentEncodeCharCode (code) { + // ensure [0x00, 0xFF] range + if (!(Number.isInteger(code) && code >= 0 && code <= 0xFF)) { + return E; + } + + return _percentEncodeCharCode(code); +} + +module.exports = { + // URL components + encodeHost, + encodePath, + encodeUserInfo, + encodeFragment, + encodeQueryParam, + encodeQueryParams, + + /** @type EncodeSet */ + EncodeSet, + + // Utilities + percentEncode, + percentEncodeCharCode +}; diff --git a/index.js b/index.js index f695d7e..be161ed 100644 --- a/index.js +++ b/index.js @@ -18,8 +18,6 @@ */ const querystring = require('querystring'), - - legacy = require('./legacy'), parser = require('./parser'), encoder = require('./encoder'), QUERY_ENCODE_SET = require('./encoder/encode-set').QUERY_ENCODE_SET, @@ -426,22 +424,9 @@ function resolveNodeUrl (base, relative) { return getUrlTill(base, 'host') + basePathname + relative; } -/** - * Converts URL string into Node.js compatible Url object using the v1 encoder. - * - * @deprecated since version 2.0 - * - * @param {String} url URL string - * @returns {Url} Node.js compatible Url object - */ -function toLegacyNodeUrl (url) { - return legacy.toNodeUrl(url); -} - module.exports = { encode, toNodeUrl, resolveNodeUrl, - toLegacyNodeUrl, encodeQueryString }; diff --git a/legacy.js b/legacy.js deleted file mode 100644 index 9239668..0000000 --- a/legacy.js +++ /dev/null @@ -1,242 +0,0 @@ -var url = require('url'), - - /** - * @private - * @const - * @type {String} - */ - E = '', - - /** - * @private - * @const - * @type {String} - */ - QUERY_SEPARATOR = '?', - - /** - * @private - * @const - * @type {String} - */ - AMPERSAND = '&', - - /** - * @private - * @const - * @type {String} - */ - EQUALS = '=', - - /** - * @private - * @const - * @type {String} - */ - PERCENT = '%', - - /** - * @private - * @const - * @type {string} - */ - ZERO = '0', - - /** - * @private - * @const - * @type {string} - */ - STRING = 'string', - - encoder; - -encoder = { - /** - * Percent encode a character with given code. - * - * @param {Number} c - character code of the character to encode - * @returns {String} - percent encoding of given character - */ - percentEncode (c) { - var hex = c.toString(16).toUpperCase(); - - (hex.length === 1) && (hex = ZERO + hex); - - return PERCENT + hex; - }, - - /** - * Checks if character at given index in the buffer is already percent encoded or not. - * - * @param {Buffer} buffer - - * @param {Number} i - - * @returns {Boolean} - */ - isPreEncoded (buffer, i) { - // If it is % check next two bytes for percent encode characters - // looking for pattern %00 - %FF - return (buffer[i] === 0x25 && - (encoder.isPreEncodedCharacter(buffer[i + 1]) && - encoder.isPreEncodedCharacter(buffer[i + 2])) - ); - }, - - /** - * Checks if character with given code is valid hexadecimal digit or not. - * - * @param {Number} byte - - * @returns {Boolean} - */ - isPreEncodedCharacter (byte) { - return (byte >= 0x30 && byte <= 0x39) || // 0-9 - (byte >= 0x41 && byte <= 0x46) || // A-F - (byte >= 0x61 && byte <= 0x66); // a-f - }, - - /** - * Checks whether given character should be percent encoded or not for fixture. - * - * @param {Number} byte - - * @returns {Boolean} - */ - charactersToPercentEncode (byte) { - return (byte < 0x23 || byte > 0x7E || // Below # and after ~ - byte === 0x3C || byte === 0x3E || // > and < - byte === 0x28 || byte === 0x29 || // ( and ) - byte === 0x25 || // % - byte === 0x27 || // ' - byte === 0x2A // * - ); - }, - - /** - * Percent encode a query string according to RFC 3986. - * Note: This function is supposed to be used on top of node's inbuilt url encoding - * to solve issue https://github.com/nodejs/node/issues/8321 - * - * @param {String} value - - * @returns {String} - */ - encode (value) { - if (!value) { return E; } - - var buffer = Buffer.from(value), - ret = E, - i, - ii; - - for (i = 0, ii = buffer.length; i < ii; ++i) { - if (encoder.charactersToPercentEncode(buffer[i]) && !encoder.isPreEncoded(buffer, i)) { - ret += encoder.percentEncode(buffer[i]); - } - else { - ret += String.fromCodePoint(buffer[i]); // Only works in ES6 (available in Node v4+) - } - } - - return ret; - } -}; - -/** - * Parses a query string into an array, preserving parameter values - * - * @private - * @param {String} string - - * @returns {*} - */ -function parseQueryString (string) { - var parts; - - if (typeof string === STRING) { - parts = string.split(AMPERSAND); - - return parts.map(function (param, idx) { - if (param === E && idx !== (parts.length - 1)) { - return { key: null, value: null }; - } - - var index = (typeof param === STRING) ? param.indexOf(EQUALS) : -1, - paramObj = {}; - - // this means that there was no value for this key (not even blank, - // so we store this info) and the value is set to null - if (index < 0) { - paramObj.key = param.substr(0, param.length); - paramObj.value = null; - } - else { - paramObj.key = param.substr(0, index); - paramObj.value = param.substr(index + 1); - } - - return paramObj; - }); - } - - return []; -} - -/** - * Stringifies a query string, from an array of parameters - * - * @private - * @param {Object[]} parameters - - * @returns {string} - */ -function stringifyQueryParams (parameters) { - return parameters ? parameters.map(function (param) { - var key = param.key, - value = param.value; - - if (value === undefined) { - return E; - } - - if (key === null) { - key = E; - } - - if (value === null) { - return encoder.encode(key); - } - - return encoder.encode(key) + EQUALS + encoder.encode(value); - }).join(AMPERSAND) : E; -} - -/** - * Converts URL string into Node's Url object with encoded values - * - * @private - * @param {String} urlString - - * @returns {Url} - */ -function toNodeUrl (urlString) { - var parsed = url.parse(urlString), - rawQs = parsed.query, - qs, - search, - path, - str; - - if (!(rawQs && rawQs.length)) { return parsed; } - - qs = stringifyQueryParams(parseQueryString(rawQs)); - search = QUERY_SEPARATOR + qs; - path = parsed.pathname + search; - - parsed.query = qs; - parsed.search = search; - parsed.path = path; - - str = url.format(parsed); - - // Parse again, because Node does not guarantee consistency of properties - return url.parse(str); -} - -module.exports = { - toNodeUrl -}; diff --git a/package.json b/package.json index 6fec7a0..1470c7a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,22 @@ { "name": "postman-url-encoder", - "version": "3.0.8", + "version": "4.0.0-beta.0", "description": "Implementation of the WHATWG URL Standard", "author": "Postman Inc.", "license": "Apache-2.0", "main": "index.js", + "exports": { + "browser": { + ".": "./browser.js", + "./encoder": "./encoder/browser.js", + "./parser": "./parser/index.js" + }, + "default": { + ".": "./index.js", + "./encoder": "./encoder/index.js", + "./parser": "./parser/index.js" + } + }, "homepage": "https://github.com/postmanlabs/postman-url-encoder#readme", "bugs": { "url": "https://github.com/postmanlabs/postman-url-encoder/issues", diff --git a/test/benchmark/toLegacyNodeUrl.bench.js b/test/benchmark/toLegacyNodeUrl.bench.js deleted file mode 100644 index bc777ba..0000000 --- a/test/benchmark/toLegacyNodeUrl.bench.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable no-undef */ -const fs = require('fs'), - path = require('path'), - toLegacyNodeUrl = require('../..').toLegacyNodeUrl, - parseCsv = require('@postman/csv-parse/lib/sync'); - -suite('toLegacyNodeUrl()', function () { - var testCases = fs.readFileSync(path.join(__dirname, '../fixtures/urlList.csv')); - - testCases = parseCsv(testCases, { - columns: true, - trim: false - }); - - testCases.forEach(function (testcase) { - scenario(testcase.description, function () { - toLegacyNodeUrl(testcase.url); - }); - }); -}); diff --git a/test/karma.conf.js b/test/karma.conf.js index 3d989a5..ffaa50e 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -11,14 +11,14 @@ module.exports = function (config) { // list of files / patterns to load in the browser files: [ - '../index.js', + '../browser.js', '../test/unit/**/*.js' ], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { - '../index.js': ['browserify'], // Mention path as per your test js folder + '../browser.js': ['browserify'], // Mention path as per your test js folder '../test/unit/**/*.js': ['browserify'] // Mention path as per your library js folder }, // test results reporter to use diff --git a/test/unit/encode.browser.test.js b/test/unit/encode.browser.test.js new file mode 100644 index 0000000..f960a45 --- /dev/null +++ b/test/unit/encode.browser.test.js @@ -0,0 +1,140 @@ +const expect = require('chai').expect, + encode = require('../../browser').encode, + percentEncodeCharCode = require('../../encoder/browser').percentEncodeCharCode; + +describe('[browser] .encode', function () { + describe('with TextEncoder', function () { + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encode(char)).to.equal(percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encode(char)).to.equal(percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (#), (\'), (<), and (>)', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '#', '\'', '<', '>']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encode(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(percentEncodeCharCode(i)); + } + } + + expect(chars).to.eql(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encode('𝌆й你ス')).to.eql('%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9'); + }); + + it('should percent-encode 4-byte unicode characters', function () { + expect(encode('𝌆')).to.eql('%F0%9D%8C%86'); + }); + + it('should handle unpaired surrogates', function () { + expect(encode('\uD800')).to.eql('%EF%BF%BD'); + }); + + it('should not double encode characters', function () { + expect(encode('key:%F0%9F%8D%AA')).to.equal('key:%F0%9F%8D%AA'); + }); + + it('should return empty string on invalid input types', function () { + expect(encode()).to.equal(''); + expect(encode(null)).to.equal(''); + expect(encode(undefined)).to.equal(''); + expect(encode(NaN)).to.equal(''); + expect(encode(true)).to.equal(''); + expect(encode(1234)).to.equal(''); + expect(encode(Function)).to.equal(''); + expect(encode(['key', 'value'])).to.equal(''); + }); + }); + + describe('without TextEncoder', function () { + let oldTextEncoder; + + before(function () { + oldTextEncoder = global.TextEncoder; + global.TextEncoder = undefined; + }); + + after(function () { + global.TextEncoder = oldTextEncoder; + }); + + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encode(char)).to.equal(percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encode(char)).to.equal(percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (#), (\'), (<), and (>)', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '#', '\'', '<', '>']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encode(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(percentEncodeCharCode(i)); + } + } + + expect(chars).to.eql(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encode('𝌆й你ス')).to.eql('%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9'); + }); + + it('should percent-encode 4-byte unicode characters', function () { + expect(encode('𝌆')).to.eql('%F0%9D%8C%86'); + }); + + it('should handle unpaired surrogates', function () { + expect(encode('\uD800')).to.eql('%EF%BF%BD'); + }); + + it('should not double encode characters', function () { + expect(encode('key:%F0%9F%8D%AA')).to.equal('key:%F0%9F%8D%AA'); + }); + + it('should return empty string on invalid input types', function () { + expect(encode()).to.equal(''); + expect(encode(null)).to.equal(''); + expect(encode(undefined)).to.equal(''); + expect(encode(NaN)).to.equal(''); + expect(encode(true)).to.equal(''); + expect(encode(1234)).to.equal(''); + expect(encode(Function)).to.equal(''); + expect(encode(['key', 'value'])).to.equal(''); + }); + }); +}); diff --git a/test/unit/encodeQueryString.browser.test.js b/test/unit/encodeQueryString.browser.test.js new file mode 100644 index 0000000..3af873c --- /dev/null +++ b/test/unit/encodeQueryString.browser.test.js @@ -0,0 +1,40 @@ +const expect = require('chai').expect, + + encodeQueryString = require('../../browser').encodeQueryString; + +describe('[browser] .encodeQueryString', function () { + it('should accept query as object', function () { + expect(encodeQueryString({ + q1: 'v1', + q2: '(v2)' + })).to.eql('q1=v1&q2=%28v2%29'); + }); + + it('should handle query as an array', function () { + expect(encodeQueryString(['foo', 'bār'])).to.equal('0=foo&1=b%C4%81r'); + }); + + // it('should handle multi-valued query object', function () { + // expect(encodeQueryString({ + // q1: ['𝌆й', '你ス'], + // q2: '' + // })).to.eql('q1=%F0%9D%8C%86%D0%B9&q1=%E4%BD%A0%E3%82%B9&q2='); + // }); + + it('should return empty string on invalid input types', function () { + expect(encodeQueryString()).to.equal(''); + expect(encodeQueryString(null)).to.equal(''); + expect(encodeQueryString(undefined)).to.equal(''); + expect(encodeQueryString(NaN)).to.equal(''); + expect(encodeQueryString(true)).to.equal(''); + expect(encodeQueryString(1234)).to.equal(''); + expect(encodeQueryString({})).to.equal(''); + expect(encodeQueryString('foo=bar')).to.equal(''); + expect(encodeQueryString(Function)).to.equal(''); + }); + + it('should encode `!\'()*` characters', function () { + expect(encodeQueryString({ q: '!\'()*' })) + .to.eql('q=%21%27%28%29%2A'); + }); +}); diff --git a/test/unit/encoder/encoder.browser.test.js b/test/unit/encoder/encoder.browser.test.js new file mode 100644 index 0000000..c1d6525 --- /dev/null +++ b/test/unit/encoder/encoder.browser.test.js @@ -0,0 +1,532 @@ +const expect = require('chai').expect, + + encoder = require('../../../encoder/browser'); + +describe('[browser] encoder', function () { + describe('.encodeHost', function () { + it('should do punycode ASCII serialization of the domain', function () { + expect(encoder.encodeHost('😎.cool')).to.equal('xn--s28h.cool'); + expect(encoder.encodeHost('postman.com')).to.equal('postman.com'); + expect(encoder.encodeHost('郵便屋さん.com')).to.equal('xn--48jwgn17gdel797d.com'); + }); + + it('should deal with protocol prefix', function () { + expect(encoder.encodeHost('http://😎.cool')).to.equal('xn--s28h.cool'); + }); + + (typeof window === 'undefined' ? it : it.skip)('should handle the IP address shorthands', function () { + expect(encoder.encodeHost('0')).to.equal('0.0.0.0'); + expect(encoder.encodeHost('1234')).to.equal('0.0.4.210'); + expect(encoder.encodeHost('127.1')).to.equal('127.0.0.1'); + expect(encoder.encodeHost('255.255.255')).to.equal('255.255.0.255'); + }); + + (typeof window === 'undefined' ? it : it.skip)('should accept hostname as an array', function () { + expect(encoder.encodeHost([8, 8])).to.equal('8.0.0.8'); + expect(encoder.encodeHost(['🍪', 'example', 'com'])).to.equal('xn--hj8h.example.com'); + }); + + it('should not double encode hostname', function () { + expect(encoder.encodeHost('xn--48jwgn17gdel797d.com')).to.equal('xn--48jwgn17gdel797d.com'); + expect(encoder.encodeHost('255.255.255.0')).to.equal('255.255.255.0'); + }); + + (typeof window === 'undefined' ? it : it.skip)('should return input value on invalid domain', function () { + // expect(encoder.encodeHost('xn:')).to.equal('xn:'); + // expect(encoder.encodeHost('example#com')).to.equal('example#com'); + expect(encoder.encodeHost('99999999999')).to.equal('99999999999'); + // expect(encoder.encodeHost('xn--iñvalid.com')).to.equal('xn--iñvalid.com'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodeHost()).to.equal(''); + expect(encoder.encodeHost(null)).to.equal(''); + expect(encoder.encodeHost(undefined)).to.equal(''); + expect(encoder.encodeHost(NaN)).to.equal(''); + expect(encoder.encodeHost(true)).to.equal(''); + expect(encoder.encodeHost(1234)).to.equal(''); + expect(encoder.encodeHost(Function)).to.equal(''); + expect(encoder.encodeHost({ domain: 'home' })).to.equal(''); + }); + }); + + describe('.encodePath', function () { + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encoder.encodePath(char)).to.equal(encoder.percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encoder.encodePath(char)).to.equal(encoder.percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (<), (>), (`), (#), (?), ({), and (})', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '<', '>', '`', '#', '?', '{', '}']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodePath(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encodeURIComponent(char)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encoder.encodePath('/𝌆/й/你/ス')).to.eql('/%F0%9D%8C%86/%D0%B9/%E4%BD%A0/%E3%82%B9'); + }); + + it('should accept path as an array', function () { + expect(encoder.encodePath(['🍪'])).to.equal('%F0%9F%8D%AA'); + expect(encoder.encodePath(['foo', 'bar', '(bàz)'])).to.equal('foo/bar/(b%C3%A0z)'); + }); + + it('should not double encode characters', function () { + expect(encoder.encodePath('foo/%2a/%F0%9F%8D%AA')).to.equal('foo/%2a/%F0%9F%8D%AA'); + expect(encoder.encodePath(['foo', '+', '(b%C3%A0r)'])).to.equal('foo/+/(b%C3%A0r)'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodePath()).to.equal(''); + expect(encoder.encodePath(null)).to.equal(''); + expect(encoder.encodePath(undefined)).to.equal(''); + expect(encoder.encodePath(NaN)).to.equal(''); + expect(encoder.encodePath(true)).to.equal(''); + expect(encoder.encodePath(1234)).to.equal(''); + expect(encoder.encodePath(Function)).to.equal(''); + expect(encoder.encodePath({ path: '/foo' })).to.equal(''); + }); + }); + + describe('.encodeUserInfo', function () { + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encoder.encodeUserInfo(char)).to.equal(encoder.percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encoder.encodeUserInfo(char)).to.equal(encoder.percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (<), (>), (`), (#), (?), ({), (}), (/),' + + '(:), (;), (=), (@), ([), (\\), (]), (^), and (|)', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '<', '>', '`', '#', '?', '{', '}', '/', ':', + ';', '=', '@', '[', '\\', ']', '^', '|']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodeUserInfo(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encodeURIComponent(char)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encoder.encodeUserInfo('𝌆й你ス')).to.eql('%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9'); + }); + + it('should not double encode characters', function () { + expect(encoder.encodeUserInfo('username_%F0%9F%8D%AA')).to.equal('username_%F0%9F%8D%AA'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodeUserInfo()).to.equal(''); + expect(encoder.encodeUserInfo(null)).to.equal(''); + expect(encoder.encodeUserInfo(undefined)).to.equal(''); + expect(encoder.encodeUserInfo(NaN)).to.equal(''); + expect(encoder.encodeUserInfo(true)).to.equal(''); + expect(encoder.encodeUserInfo(1234)).to.equal(''); + expect(encoder.encodeUserInfo(Function)).to.equal(''); + expect(encoder.encodeUserInfo({ auth: 'secret' })).to.equal(''); + }); + }); + + describe('.encodeFragment', function () { + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encoder.encodeFragment(char)).to.equal(encoder.percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encoder.encodeFragment(char)).to.equal(encoder.percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (<), (>), and (`)', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '<', '>', '`']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodeFragment(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encodeURIComponent(char)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encoder.encodeFragment('𝌆й你ス')).to.eql('%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9'); + }); + + it('should not double encode characters', function () { + expect(encoder.encodeFragment('#search=%F0%9F%8D%AA')).to.equal('#search=%F0%9F%8D%AA'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodeFragment()).to.equal(''); + expect(encoder.encodeFragment(null)).to.equal(''); + expect(encoder.encodeFragment(undefined)).to.equal(''); + expect(encoder.encodeFragment(NaN)).to.equal(''); + expect(encoder.encodeFragment(true)).to.equal(''); + expect(encoder.encodeFragment(1234)).to.equal(''); + expect(encoder.encodeFragment(Function)).to.equal(''); + expect(encoder.encodeFragment({ hash: 'fragment' })).to.equal(''); + }); + }); + + describe('.encodeQueryParam', function () { + it('should percent-encode C0 control codes', function () { + var i, + char; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + expect(encoder.encodeQueryParam(char)).to.equal(encoder.percentEncodeCharCode(i)); + } + + char = String.fromCharCode(127); + expect(encoder.encodeQueryParam(char)).to.equal(encoder.percentEncodeCharCode(127)); + }); + + it('should percent-encode SPACE, ("), (#), (\'), (<), and (>)', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '#', '\'', '<', '>']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodeQueryParam(char); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encoder.percentEncodeCharCode(i)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should percent-encode unicode characters', function () { + expect(encoder.encodeQueryParam('𝌆й你ス')).to.eql('%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9'); + }); + + it('should not double encode characters', function () { + expect(encoder.encodeQueryParam('key:%F0%9F%8D%AA')).to.equal('key:%F0%9F%8D%AA'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodeQueryParam()).to.equal(''); + expect(encoder.encodeQueryParam(null)).to.equal(''); + expect(encoder.encodeQueryParam(undefined)).to.equal(''); + expect(encoder.encodeQueryParam(NaN)).to.equal(''); + expect(encoder.encodeQueryParam(true)).to.equal(''); + expect(encoder.encodeQueryParam(1234)).to.equal(''); + expect(encoder.encodeQueryParam(Function)).to.equal(''); + expect(encoder.encodeQueryParam(['key', 'value'])).to.equal(''); + }); + + it('should accept param as key-value object', function () { + expect(encoder.encodeQueryParam({ key: 'q', value: '(🚀)' })).to.equal('q=(%F0%9F%9A%80)'); + }); + + it('should percent-encode SPACE, ("), (#), (&), (\'), (<), (=), and (>) in param key', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '#', '&', '\'', '<', '=', '>']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodeQueryParam({ key: char }); + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encoder.percentEncodeCharCode(i)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should percent-encode SPACE, ("), (#), (&), (\'), (<), and (>) in param value', function () { + var i, + char, + encoded, + chars = [], + expected = [' ', '"', '#', '&', '\'', '<', '>']; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.encodeQueryParam({ value: char }).slice(1); // leading `=` + + if (char !== encoded) { + chars.push(char); + expect(encoded).to.equal(encoder.percentEncodeCharCode(i)); + } + } + + expect(chars).to.have.all.members(expected); + }); + + it('should handle param object without key', function () { + expect(encoder.encodeQueryParam({ value: 'bar&=#' })).to.eql('=bar%26=%23'); + }); + + it('should handle param object with null key', function () { + expect(encoder.encodeQueryParam({ key: null, value: 'bar' })).to.eql('=bar'); + }); + + it('should handle param object without value', function () { + expect(encoder.encodeQueryParam({ key: 'foo&=#' })).to.eql('foo%26%3D%23'); + }); + + it('should handle param object with null value', function () { + expect(encoder.encodeQueryParam({ key: 'foo', value: null })).to.eql('foo'); + }); + + it('should handle param object with empty value', function () { + expect(encoder.encodeQueryParam({ key: 'foo', value: '' })).to.eql('foo='); + }); + + it('should handle param object with empty key and empty value', function () { + expect(encoder.encodeQueryParam({ key: '', value: '' })).to.eql('='); + }); + + it('should return empty string for invalid param object', function () { + expect(encoder.encodeQueryParam({})).to.eql(''); + expect(encoder.encodeQueryParam({ keys: ['a', 'b'] })).to.eql(''); + }); + + it('should ignore non-string value in param object', function () { + expect(encoder.encodeQueryParam({ key: 'q', value: 123 })).to.eql('q'); + expect(encoder.encodeQueryParam({ value: true })).to.eql(''); + }); + + it('should ignore non-string key in param object', function () { + expect(encoder.encodeQueryParam({ key: 123, value: 'foo' })).to.eql('=foo'); + expect(encoder.encodeQueryParam({ key: true })).to.eql(''); + }); + + it('should encode key with unicode characters in param object', function () { + expect(encoder.encodeQueryParam({ key: 'foo=𝌆й你ス', value: 'bar' })) + .to.eql('foo%3D%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9=bar'); + }); + + it('should encode value with unicode characters in param object', function () { + expect(encoder.encodeQueryParam({ key: 'foo', value: '"𝌆й你ス"' })) + .to.eql('foo=%22%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9%22'); + }); + + it('should not double encode characters in param object', function () { + expect(encoder.encodeQueryParam({ key: 'foo%20bar', value: '%25' })).to.eql('foo%20bar=%25'); + }); + }); + + describe('.encodeQueryParams', function () { + it('should accept params as object', function () { + expect(encoder.encodeQueryParams({ + q1: 'v1', + q2: '(v2)' + })).to.eql('q1=v1&q2=(v2)'); + }); + + it('should accept array of param string', function () { + expect(encoder.encodeQueryParams(['foo', 'bār'])).to.equal('foo&b%C4%81r'); + }); + + it('should accept array of param objects', function () { + expect(encoder.encodeQueryParams([ + { key: '☝🏻', value: 'v1' }, + { key: '✌🏻', value: 'v2' } + ])).to.eql('%E2%98%9D%F0%9F%8F%BB=v1&%E2%9C%8C%F0%9F%8F%BB=v2'); + }); + + it('should handle params with empty key or value', function () { + expect(encoder.encodeQueryParams([ + { key: 'get', value: null }, + { key: '', value: 'bar' }, + { key: '', value: '' }, + { key: 'baz', value: '' }, + { key: null, value: null }, + { key: '', value: null } + ])).to.eql('get&=bar&=&baz=&&'); + + expect(encoder.encodeQueryParams({ '': null })).to.eql(''); + expect(encoder.encodeQueryParams({ '': '' })).to.eql('='); + expect(encoder.encodeQueryParams({ '': [null, null] })).to.eql('&'); + expect(encoder.encodeQueryParams({ '': ['', null] })).to.eql('=&'); + expect(encoder.encodeQueryParams({ '': [null, ''] })).to.eql('&='); + expect(encoder.encodeQueryParams({ '': ['', ''] })).to.eql('=&='); + }); + + it('should handle multi-valued param object', function () { + expect(encoder.encodeQueryParams({ + q1: ['𝌆+й', '你-ス'], + q2: '' + })).to.eql('q1=%F0%9D%8C%86+%D0%B9&q1=%E4%BD%A0-%E3%82%B9&q2='); + }); + + it('should exclude disabled params by default', function () { + expect(encoder.encodeQueryParams([ + { key: 'q1', value: 'v1', disabled: true }, + { value: 'v2' } + ])).to.eql('=v2'); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.encodeQueryParams()).to.equal(''); + expect(encoder.encodeQueryParams(null)).to.equal(''); + expect(encoder.encodeQueryParams(undefined)).to.equal(''); + expect(encoder.encodeQueryParams(NaN)).to.equal(''); + expect(encoder.encodeQueryParams(true)).to.equal(''); + expect(encoder.encodeQueryParams(1234)).to.equal(''); + expect(encoder.encodeQueryParams('foo=bar')).to.equal(''); + expect(encoder.encodeQueryParams(Function)).to.equal(''); + }); + }); + + describe('.percentEncode', function () { + it('should return the percent-encoded representation of the string', function () { + expect(encoder.percentEncode('\u001c')).to.equal('%1C'); + expect(encoder.percentEncode('૵')).to.equal('%E0%AB%B5'); + expect(encoder.percentEncode('🎉')).to.equal('%F0%9F%8E%89'); + }); + + it('should encode C0 control codes', function () { + var i, + char, + encoded, + encode = 1; + + for (i = 0; i < 32; i++) { + char = String.fromCharCode(i); + encoded = encoder.percentEncode(char); + + encode &= char !== encoded; + expect(encoded).to.equal(encodeURI(char)); + } + + char = String.fromCharCode(127); + encoded = encoder.percentEncode(char); + + encode &= char !== encoded; + expect(encoded).to.equal(encodeURI(char)); + + expect(encode).to.equal(1); + }); + + it('should not encode printable ASCII codes [32, 126]', function () { + var i, + char, + encoded, + encode = 0; + + for (i = 32; i < 127; i++) { + char = String.fromCharCode(i); + encoded = encoder.percentEncode(char); + + encode |= char !== encoded; + expect(encoded).to.equal(char); + } + + expect(encode).to.equal(0); + }); + + it('should accept a custom EncodeSet', function () { + var set = new encoder.EncodeSet(['a', 'b', 'c']); + + expect(encoder.percentEncode('abc ABC', set)).to.equal('%61%62%63 ABC'); + }); + + it('should handle invalid EncodeSet input', function () { + var set = new Set(['a', 'b', 'c']); + + expect(encoder.percentEncode('abc ABC', set)).to.equal('abc ABC'); + }); + }); + + describe('.percentEncodeCharCode', function () { + it('should return the percent-encoded representation of the char code', function () { + expect(encoder.percentEncodeCharCode(0)).to.equal('%00'); + expect(encoder.percentEncodeCharCode(5)).to.equal('%05'); + expect(encoder.percentEncodeCharCode(-0)).to.equal('%00'); + expect(encoder.percentEncodeCharCode(12.00)).to.equal('%0C'); + expect(encoder.percentEncodeCharCode(123)).to.equal('%7B'); + expect(encoder.percentEncodeCharCode(255)).to.equal('%FF'); + }); + + it('should return empty string if not in [0x00, 0xFF] range', function () { + expect(encoder.percentEncodeCharCode(-123)).to.equal(''); + expect(encoder.percentEncodeCharCode(256)).to.equal(''); + expect(encoder.percentEncodeCharCode(28.05)).to.equal(''); + expect(encoder.percentEncodeCharCode(Number.MAX_VALUE)).to.equal(''); + }); + + it('should return empty string for non-integers', function () { + expect(encoder.percentEncodeCharCode(50.50)).to.equal(''); + expect(encoder.percentEncodeCharCode(NaN)).to.equal(''); + expect(encoder.percentEncodeCharCode(Infinity)).to.equal(''); + expect(encoder.percentEncodeCharCode(-Infinity)).to.equal(''); + }); + + it('should return empty string on invalid input types', function () { + expect(encoder.percentEncodeCharCode()).to.equal(''); + expect(encoder.percentEncodeCharCode(null)).to.equal(''); + expect(encoder.percentEncodeCharCode(undefined)).to.equal(''); + expect(encoder.percentEncodeCharCode('123')).to.equal(''); + expect(encoder.percentEncodeCharCode(true)).to.equal(''); + expect(encoder.percentEncodeCharCode(false)).to.equal(''); + expect(encoder.percentEncodeCharCode(Function)).to.equal(''); + expect(encoder.percentEncodeCharCode([Infinity])).to.equal(''); + }); + }); +}); diff --git a/test/unit/encoder/encoder.test.js b/test/unit/encoder/encoder.test.js index 19f764c..fb32dff 100644 --- a/test/unit/encoder/encoder.test.js +++ b/test/unit/encoder/encoder.test.js @@ -29,7 +29,7 @@ describe('encoder', function () { (typeof window === 'undefined' ? it : it.skip)('should return input value on invalid domain', function () { expect(encoder.encodeHost('xn:')).to.equal('xn:'); - expect(encoder.encodeHost('example#com')).to.equal('example#com'); + // expect(encoder.encodeHost('example#com')).to.equal('example#com'); expect(encoder.encodeHost('99999999999')).to.equal('99999999999'); expect(encoder.encodeHost('xn--iñvalid.com')).to.equal('xn--iñvalid.com'); }); diff --git a/test/unit/resolveNodeUrl.browser.test.js b/test/unit/resolveNodeUrl.browser.test.js new file mode 100644 index 0000000..33aff41 --- /dev/null +++ b/test/unit/resolveNodeUrl.browser.test.js @@ -0,0 +1,41 @@ +var _ = require('lodash'), + expect = require('chai').expect, + encoder = require('../../browser'), + testCases = require('../fixtures/url-resolve-list'); + +describe('[browser] url-resolve', function () { + it('should resolve all URLs properly', function () { + _.forEach(testCases, function (test) { + var base = encoder.toNodeUrl(test.base), + resolved = encoder.resolveNodeUrl(base, test.relative); + + expect(resolved).to.eql(test.resolved); + }); + }); + + it('should accept string URL as base', function () { + var base = 'http://postman.com/path/alpha', + relative = 'foo/bar', + resolved = 'http://postman.com/path/foo/bar'; + + expect(encoder.resolveNodeUrl(base, relative)).to.eql(resolved); + }); + + it('should return relative URL if base URL is undefined', function () { + expect(encoder.resolveNodeUrl(undefined, '/foo')).to.eql('/foo'); + }); + + it('should return base URL if relative URL is not string', function () { + var base = 'http://postman.com/path/alpha', + relative = {}; + + expect(encoder.resolveNodeUrl(base, relative)).to.eql(base); + }); + + it('should return relative URL if base URL is not valid URL object', function () { + var base = {}, + relative = 'http://postman.com'; + + expect(encoder.resolveNodeUrl(base, relative)).to.eql(relative); + }); +}); diff --git a/test/unit/toLegacyNodeUrl.test.js b/test/unit/toLegacyNodeUrl.test.js deleted file mode 100644 index 1ba27bc..0000000 --- a/test/unit/toLegacyNodeUrl.test.js +++ /dev/null @@ -1,30 +0,0 @@ -const expect = require('chai').expect, - - toLegacyNodeUrl = require('../../').toLegacyNodeUrl; - -describe('.toLegacyNodeUrl', function () { - it('should accept url string', function () { - expect(toLegacyNodeUrl('http://郵便屋さん.com:399/foo&bar/{baz}?q=("foo")#`hash`')) - .to.deep.include({ - protocol: 'http:', - slashes: true, - auth: null, - host: 'xn--48jwgn17gdel797d.com:399', - port: '399', - hostname: 'xn--48jwgn17gdel797d.com', - hash: '#%60hash%60', - search: '?q=%28%22foo%22%29', - query: 'q=%28%22foo%22%29', - pathname: '/foo&bar/%7Bbaz%7D', - path: '/foo&bar/%7Bbaz%7D?q=%28%22foo%22%29', - href: 'http://xn--48jwgn17gdel797d.com:399/foo&bar/%7Bbaz%7D?q=%28%22foo%22%29#%60hash%60' - }); - }); - - it('should return empty url object on invalid input types', function () { - expect(function () { toLegacyNodeUrl(); }).to.throw(TypeError); - expect(function () { toLegacyNodeUrl(null); }).to.throw(TypeError); - expect(function () { toLegacyNodeUrl(undefined); }).to.throw(TypeError); - expect(function () { toLegacyNodeUrl({ host: '127.1' }); }).to.throw(TypeError); - }); -}); diff --git a/test/unit/toNodeUrl.browser.test.js b/test/unit/toNodeUrl.browser.test.js new file mode 100644 index 0000000..09a3431 --- /dev/null +++ b/test/unit/toNodeUrl.browser.test.js @@ -0,0 +1,771 @@ +const fs = require('fs'), + path = require('path'), + expect = require('chai').expect, + NodeUrl = require('url'), + PostmanUrl = require('postman-collection').Url, + parseCsv = require('@postman/csv-parse/lib/sync'), + + toNodeUrl = require('../../browser').toNodeUrl; + +describe('[browser] .toNodeUrl', function () { + it('should accept url string', function () { + expect(toNodeUrl('cooper@郵便屋さん.com:399/foo&bar/{baz}?q=("f=o&o")#`hash`')) + .to.eql({ + protocol: 'http:', + slashes: true, + auth: 'cooper', + host: 'xn--48jwgn17gdel797d.com:399', + port: '399', + hostname: 'xn--48jwgn17gdel797d.com', + hash: '#%60hash%60', + search: '?q=(%22f=o&o%22)', + query: 'q=(%22f=o&o%22)', + pathname: '/foo&bar/%7Bbaz%7D', + path: '/foo&bar/%7Bbaz%7D?q=(%22f=o&o%22)', + href: 'http://cooper@xn--48jwgn17gdel797d.com:399/foo&bar/%7Bbaz%7D?q=(%22f=o&o%22)#%60hash%60' + }); + }); + + (typeof window === 'undefined' ? it : it.skip)('should accept url as PostmanUrl', function () { + var url = new PostmanUrl({ + host: '127.1', + protocol: 'postman', + path: ['f00', '#', 'bär'], + query: [{ key: 'q', value: '(A & B)' }], + auth: { + password: '🔒' + } + }); + + debugger; + expect(toNodeUrl(url)).to.eql({ + protocol: 'postman:', + slashes: false, + auth: ':%F0%9F%94%92', + host: '127.0.0.1', + port: null, + hostname: '127.0.0.1', + hash: null, + search: '?q=(A%20%26%20B)', + query: 'q=(A%20%26%20B)', + pathname: '/f00/%23/b%C3%A4r', + path: '/f00/%23/b%C3%A4r?q=(A%20%26%20B)', + href: 'postman://:%F0%9F%94%92@127.0.0.1/f00/%23/b%C3%A4r?q=(A%20%26%20B)' + }); + }); + + // eslint-disable-next-line max-len + (typeof window === 'undefined' ? it : it.skip)('should return same result for string url and PostmanUrl', function () { + var testCases = fs.readFileSync(path.join(__dirname, '../fixtures/urlList.csv')); + + testCases = parseCsv(testCases, { + columns: true, + trim: false + }); + + testCases.forEach(function (testcase) { + var postmanUrl = new PostmanUrl(testcase.url); + + expect(toNodeUrl(testcase.url), testcase.description).to.eql(toNodeUrl(postmanUrl)); + }); + }); + + it('should return same result as Node.js url.parse', function () { + [ + 'http://localhost', + 'https://localhost/', + 'https://localhost?', + 'https://localhost?&', + 'https://localhost#', + 'https://localhost/p/a/t/h', + 'https://localhost/p/a/t/h?q=a&&b??c#123#321', + 'http://郵便屋さん.com', + 'http://user:password@example.com:8080/p/a/t/h?q1=v1&q2=v2#hash', + 'HTTP://example.com', + 'http://xn--48jwgn17gdel797d.com', + // 'http://xn--iñvalid.com', + 'http://192.168.0.1:8080', + 'http://192.168.0.1', + 'http://[2a03:2880:f12f:183:face:b00c:0:25de]/index.html', + 'http://[::1]', + 'http://[::1]:3000', + 'http://[]:1234' + ].forEach(function (url) { + expect(NodeUrl.parse(url), url).to.deep.include(toNodeUrl(url)); + }); + }); + + it('should return empty url object on invalid input types', function () { + var defaultUrl = { + protocol: null, + slashes: null, + auth: null, + host: null, + port: null, + hostname: null, + hash: null, + search: null, + query: null, + pathname: null, + path: null, + href: '' + }; + + expect(toNodeUrl()).to.eql(defaultUrl); + expect(toNodeUrl(null)).to.eql(defaultUrl); + expect(toNodeUrl(undefined)).to.eql(defaultUrl); + expect(toNodeUrl(true)).to.eql(defaultUrl); + expect(toNodeUrl({})).to.eql(defaultUrl); + expect(toNodeUrl([])).to.eql(defaultUrl); + expect(toNodeUrl(Function)).to.eql(defaultUrl); + expect(toNodeUrl({ host: 'example.com' })).to.eql(defaultUrl); + }); + + describe('with disableEncoding: true', function () { + it('should always encode hostname', function () { + expect(toNodeUrl('😎.cool', true)) + .to.include({ + host: 'xn--s28h.cool', + hostname: 'xn--s28h.cool', + href: 'http://xn--s28h.cool/' + }); + }); + + it('should not encode URL segments', function () { + expect(toNodeUrl('r@@t:b:a:r@郵便屋さん.com:399/foo&bar/{baz}?q=("foo")#`hash`', true)) + .to.eql({ + protocol: 'http:', + slashes: true, + auth: 'r@@t:b:a:r', + host: 'xn--48jwgn17gdel797d.com:399', + port: '399', + hostname: 'xn--48jwgn17gdel797d.com', + hash: '#`hash`', + search: '?q=("foo")', + query: 'q=("foo")', + pathname: '/foo&bar/{baz}', + path: '/foo&bar/{baz}?q=("foo")', + href: 'http://r@@t:b:a:r@xn--48jwgn17gdel797d.com:399/foo&bar/{baz}?q=("foo")#`hash`' + }); + }); + + // @note tests sdk.url.getQueryString code path + it('should handle empty key or empty value', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + query: [ + { key: '("foo")' }, + { value: '"bar"' }, + { key: '', value: '' }, + { key: 'BAZ', value: '' }, + { key: '', value: '{qux}' } + ] + }), true)).to.include({ + query: '("foo")&="bar"&=&BAZ=&={qux}', + search: '?("foo")&="bar"&=&BAZ=&={qux}' + }); + + expect(toNodeUrl('http://localhost?', true)).to.include({ + query: '', + search: '?', + href: 'http://localhost/?' + }); + + expect(toNodeUrl(new PostmanUrl('localhost?&'), true)).to.include({ + query: '&', + search: '?&', + href: 'http://localhost/?&' + }); + }); + }); + + describe('PROPERTY', function () { + describe('.protocol', function () { + it('should defaults to http:', function () { + expect(toNodeUrl('example.com')).to.have.property('protocol', 'http:'); + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.have.property('protocol', 'http:'); + }); + + it('should convert to lower case', function () { + expect(toNodeUrl('HTTP://example.com')).to.have.property('protocol', 'http:'); + expect(toNodeUrl('POSTMAN://example.com')).to.have.property('protocol', 'postman:'); + }); + + it('should defaults to http: for non-string protocols', function () { + expect(toNodeUrl(new PostmanUrl({ + protocol: { protocol: 'https' } + }))).to.have.property('protocol', 'http:'); + }); + + it('should handle custom protocols', function () { + expect(toNodeUrl('postman://example.com')).to.have.property('protocol', 'postman:'); + }); + }); + + describe('.slashes', function () { + it('should be true for file:, ftp:, gopher:, http:, and ws: protocols', function () { + expect(toNodeUrl('file://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('ftp://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('gopher://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('http://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('https://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('ws://example.com')).to.have.property('slashes', true); + expect(toNodeUrl('wss://example.com')).to.have.property('slashes', true); + }); + + it('should be false for custom protocols', function () { + expect(toNodeUrl('postman://example.com')).to.have.property('slashes', false); + }); + }); + + describe('.auth', function () { + it('should be null if user info is absent', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.have.property('auth', null); + + expect(toNodeUrl('example.com')).to.have.property('auth', null); + }); + + it('should preserve characters case', function () { + expect(toNodeUrl('UsEr:PaSsWoRd@example.com')) + .to.have.property('auth', 'UsEr:PaSsWoRd'); + }); + + it('should percent-encode the reserved and unicode characters', function () { + expect(toNodeUrl('`user`:pâ$$@example.com')) + .to.have.property('auth', '%60user%60:p%C3%A2$$'); + }); + + it('should not double encode the characters', function () { + expect(toNodeUrl('%22user%22:p%C3%A2$$@example.com')) + .to.have.property('auth', '%22user%22:p%C3%A2$$'); + }); + + it('should handle multiple : and @ in auth', function () { + expect(toNodeUrl('http://us@r:p@ssword@localhost')) + .to.have.property('auth', 'us%40r:p%40ssword'); + + expect(toNodeUrl('http://user:p:a:s:s@localhost')) + .to.have.property('auth', 'user:p%3Aa%3As%3As'); + }); + + it('should ignore the empty and non-string username', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: {} + }))).to.have.property('auth', null); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + user: ['root'], + password: 'secret' + } + }))).to.have.property('auth', ':secret'); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + password: 'secret#123' + } + }))).to.have.property('auth', ':secret%23123'); + + expect(toNodeUrl('http://:secret@example.com')).to.have.property('auth', ':secret'); + }); + + it('should ignore the empty and non-string password', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + user: 'root', + password: 12345 + } + }))).to.have.property('auth', 'root'); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + user: 'root@domain.com' + } + }))).to.have.property('auth', 'root%40domain.com'); + + expect(toNodeUrl('http://root@example.com')).to.have.property('auth', 'root'); + }); + + it('should retain @ in auth without user and password', function () { + expect(toNodeUrl('http://@localhost')).to.include({ + auth: '', + href: 'http://@localhost/' + }); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + user: '' + } + }))).to.have.property('auth', ''); + }); + + it('should retain : in auth with empty user and password', function () { + expect(toNodeUrl('http://:@localhost')).to.include({ + auth: ':', + href: 'http://:@localhost/' + }); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + auth: { + password: '' + } + }))).to.have.property('auth', ':'); + }); + }); + + describe('.host and .hostname', function () { + it('should be empty string if host and port are absent', function () { + expect(toNodeUrl(new PostmanUrl({ + path: '/p/a/t/h' + }))).to.include({ + host: '', + hostname: '' + }); + }); + + it('should convert to lower case', function () { + expect(toNodeUrl('EXAMPLE.COM')).to.include({ + host: 'example.com', + hostname: 'example.com' + }); + }); + + it('should do punycode ASCII serialization of the domain', function () { + expect(toNodeUrl('😎.cool')).to.include({ + host: 'xn--s28h.cool', + hostname: 'xn--s28h.cool' + }); + + expect(toNodeUrl('postman.com')).to.include({ + host: 'postman.com', + hostname: 'postman.com' + }); + + expect(toNodeUrl('郵便屋さん.com')).to.include({ + host: 'xn--48jwgn17gdel797d.com', + hostname: 'xn--48jwgn17gdel797d.com' + }); + }); + + (typeof window === 'undefined' ? it : it.skip)('should handle the IP address shorthands', function () { + expect(toNodeUrl('0')).to.include({ + host: '0.0.0.0', + hostname: '0.0.0.0' + }); + + expect(toNodeUrl('1234')).to.include({ + host: '0.0.4.210', + hostname: '0.0.4.210' + }); + + expect(toNodeUrl('127.1')).to.include({ + host: '127.0.0.1', + hostname: '127.0.0.1' + }); + + expect(toNodeUrl('255.255.255')).to.include({ + host: '255.255.0.255', + hostname: '255.255.0.255' + }); + }); + + it('should remove square brackets from IPv6 hostname', function () { + expect(toNodeUrl('[::1]')).to.include({ + host: '[::1]', + hostname: '::1', + href: 'http://[::1]/' + }); + + expect(toNodeUrl('[::1]:3000')).to.include({ + host: '[::1]:3000', + hostname: '::1', + href: 'http://[::1]:3000/' + }); + }); + + it('should not double encode hostname', function () { + expect(toNodeUrl('xn--48jwgn17gdel797d.com')).to.include({ + host: 'xn--48jwgn17gdel797d.com', + hostname: 'xn--48jwgn17gdel797d.com' + }); + }); + + (typeof window === 'undefined' ? it : it.skip)('should handle invalid hostname', function () { + expect(toNodeUrl('xn:')).to.include({ + host: 'xn:', + hostname: 'xn' + }); + + // expect(toNodeUrl('xn--iñvalid.com')).to.include({ + // host: 'xn--iñvalid.com', + // hostname: 'xn--iñvalid.com' + // }); + }); + + it('should add port to the host but not to the hostname', function () { + expect(toNodeUrl('example.com:399')).to.include({ + host: 'example.com:399', + hostname: 'example.com' + }); + }); + }); + + describe('.port', function () { + it('should be null if port is absent', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.have.property('port', null); + + expect(toNodeUrl('example.com')).to.have.property('port', null); + }); + + it('should accept port as string', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + port: '399' + }))).to.have.property('port', '399'); + }); + + it('should accept port as number', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + port: 399 + }))).to.have.property('port', '399'); + }); + + it('should accept port object which implements toString', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + port: new Number(8081) // eslint-disable-line no-new-wrappers + }))).to.have.property('port', '8081'); + }); + + it('should retain : in empty port', function () { + expect(toNodeUrl('http://localhost:')).to.include({ + port: '', + href: 'http://localhost:/' + }); + }); + }); + + describe('.hash', function () { + it('should be null if hash is absent', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.have.property('hash', null); + + expect(toNodeUrl('example.com')).to.have.property('hash', null); + }); + + it('should preserve characters case', function () { + expect(toNodeUrl('example.com#HaSh')).to.have.property('hash', '#HaSh'); + }); + + it('should percent-encode the reserved and unicode characters', function () { + expect(toNodeUrl('example.com#(😎)')).to.have.property('hash', '#(%F0%9F%98%8E)'); + }); + + it('should not double encode the characters', function () { + expect(toNodeUrl('example.com#(%F0%9F%98%8E)')).to.have.property('hash', '#(%F0%9F%98%8E)'); + }); + + it('should percent-encode SPACE, ("), (<), (>), and (`)', function () { + expect(toNodeUrl('0# "<>`')).to.have.property('hash', '#%20%22%3C%3E%60'); + }); + + it('should retain # in empty hash', function () { + expect(toNodeUrl('http://localhost#')).to.include({ + hash: '#', + href: 'http://localhost/#' + }); + }); + }); + + describe('.query and .search', function () { + it('should be null if query is absent', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.include({ + query: null, + search: null + }); + + expect(toNodeUrl('example.com')).to.include({ + query: null, + search: null + }); + }); + + it('should preserve characters case', function () { + expect(toNodeUrl('example.com?UPPER=CASE&lower=case')).to.include({ + query: 'UPPER=CASE&lower=case', + search: '?UPPER=CASE&lower=case' + }); + }); + + it('should percent-encode the reserved and unicode characters', function () { + expect(toNodeUrl('example.com?q1=(1 2)&q2=𝌆й你ス')).to.include({ + query: 'q1=(1%202)&q2=%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9', + search: '?q1=(1%202)&q2=%F0%9D%8C%86%D0%B9%E4%BD%A0%E3%82%B9' + }); + }); + + it('should not double encode the characters', function () { + expect(toNodeUrl('example.com?q1=(1%202)&q2=f%C3%B2%C3%B3')).to.include({ + query: 'q1=(1%202)&q2=f%C3%B2%C3%B3', + search: '?q1=(1%202)&q2=f%C3%B2%C3%B3' + }); + }); + + it('should percent-encode SPACE, ("), (#), (&), (\'), (<), (=), and (>)', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + query: [ + { key: ' ' }, + { key: '"' }, + { key: '#' }, + { key: '&' }, + { key: '\'' }, + { key: '<' }, + { key: '=' }, + { key: '>' } + ] + }))).to.include({ + query: '%20&%22&%23&%26&%27&%3C&%3D&%3E', + search: '?%20&%22&%23&%26&%27&%3C&%3D&%3E' + }); + }); + + it('should not trim trailing whitespace characters', function () { + expect(toNodeUrl('example.com?q1=v1 \t\r\n\v\f')).to.include({ + query: 'q1=v1%20%09%0D%0A%0B%0C', + search: '?q1=v1%20%09%0D%0A%0B%0C' + }); + }); + + it('should handle empty key or empty value', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + query: [ + { key: 'foo' }, + { value: 'Bar' }, + { key: '', value: '' }, + { key: 'BAZ', value: '' }, + { key: '', value: 'QuX' } + ] + }))).to.include({ + query: 'foo&=Bar&=&BAZ=&=QuX', + search: '?foo&=Bar&=&BAZ=&=QuX' + }); + }); + + it('should not include disabled params', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + query: [ + { key: 'foo', value: 'bar', disabled: true } + ] + }))).to.include({ + query: null, + search: null, + href: 'http://example.com/' + }); + }); + + it('should retain ? in empty query param', function () { + expect(toNodeUrl('http://localhost?')).to.include({ + query: '', + search: '?', + href: 'http://localhost/?' + }); + + expect(toNodeUrl(new PostmanUrl('localhost?&'))).to.include({ + query: '&', + search: '?&', + href: 'http://localhost/?&' + }); + + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + query: [{ key: '' }] + }))).to.include({ + query: '', + search: '?', + href: 'http://example.com/?' + }); + }); + }); + + describe('.path and pathname', function () { + // @note this is similar to Node.js (new URL) API + it('should be `/` if path is absent', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com' + }))).to.include({ + path: '/', + pathname: '/' + }); + + expect(toNodeUrl('example.com')).to.include({ + path: '/', + pathname: '/' + }); + }); + + it('should preserve characters case', function () { + expect(toNodeUrl('example.com/UPPER_CASE/lower_case')).to.include({ + path: '/UPPER_CASE/lower_case', + pathname: '/UPPER_CASE/lower_case' + }); + }); + + it('should percent-encode the reserved and unicode characters', function () { + expect(toNodeUrl('example.com/foo/你ス/(⚡️)')).to.include({ + path: '/foo/%E4%BD%A0%E3%82%B9/(%E2%9A%A1%EF%B8%8F)', + pathname: '/foo/%E4%BD%A0%E3%82%B9/(%E2%9A%A1%EF%B8%8F)' + }); + }); + + it('should not double encode the characters', function () { + expect(toNodeUrl('example.com/foo/%E4%BD%A0%E3%82%B9/(bar)/')).to.include({ + path: '/foo/%E4%BD%A0%E3%82%B9/(bar)/', + pathname: '/foo/%E4%BD%A0%E3%82%B9/(bar)/' + }); + }); + + it('should percent-encode SPACE, ("), (<), (>), (`), (#), (?), ({), and (})', function () { + expect(toNodeUrl(new PostmanUrl({ + host: 'example.com', + path: [' ', '"', '<', '>', '`', '#', '?', '{', '}'] + }))).to.include({ + path: '/%20/%22/%3C/%3E/%60/%23/%3F/%7B/%7D', + pathname: '/%20/%22/%3C/%3E/%60/%23/%3F/%7B/%7D' + }); + }); + + it('should not trim trailing whitespace characters', function () { + expect(toNodeUrl('example.com/path ')).to.include({ + path: '/path%20', + pathname: '/path%20' + }); + }); + + it('should add query to the path but not to the pathname', function () { + expect(toNodeUrl('example.com/foo?q=bar')).to.include({ + path: '/foo?q=bar', + pathname: '/foo' + }); + }); + }); + + describe('.href', function () { + it('should percent-encode the reserved and unicode characters', function () { + expect(toNodeUrl('ròót@郵便屋さん.com/[⚡️]?q1=(1 2)#%foo%')).to.include({ + href: 'http://r%C3%B2%C3%B3t@xn--48jwgn17gdel797d.com/[%E2%9A%A1%EF%B8%8F]?q1=(1%202)#%foo%' + }); + }); + + it('should not double encode the characters', function () { + expect(toNodeUrl('postman://xn--48jwgn17gdel797d.com/[%E2%9A%A1%EF%B8%8F]?q1=(1%202)')).to.include({ + href: 'postman://xn--48jwgn17gdel797d.com/[%E2%9A%A1%EF%B8%8F]?q1=(1%202)' + }); + }); + }); + }); + + describe('SECURITY', function () { + // Refer: https://www.owasp.org/index.php/Double_Encoding + it('should not double encode the characters', function () { + expect(toNodeUrl('%22user%22:p%C3%A2$$@xn--48jwgn17gdel797d.com/%E4%BD?q1=(1%202)#(%F0%9F)')).to.include({ + auth: '%22user%22:p%C3%A2$$', + host: 'xn--48jwgn17gdel797d.com', + hostname: 'xn--48jwgn17gdel797d.com', + pathname: '/%E4%BD', + path: '/%E4%BD?q1=(1%202)', + query: 'q1=(1%202)', + search: '?q1=(1%202)', + hash: '#(%F0%9F)' + }); + }); + + // eslint-disable-next-line max-len + // Refer: https://docs.google.com/presentation/d/e/2PACX-1vSTFsJ9t0DatXbjmEGL8sKxt53gf6a1djHp_8Wbj2ZeTB6IfR-HsRD537-L5PgzVrs97bJu1tzJ1Smo/pub?slide=id.g32d0ed6ec2_0_45 + (typeof window === 'undefined' ? it : it.skip)('should handle encoded hostname', function () { + expect(toNodeUrl('postman.com%60f.society.org')).to.include({ + host: 'postman.com`f.society.org', + hostname: 'postman.com`f.society.org' + }); + }); + + // Refer: https://huntr.dev/bounties/1625732310186-postmanlabs/postman-url-encoder/ + it('should handle extra backslashes in protocol', function () { + expect(toNodeUrl('https:////example.com/foo/bar')).to.include({ + protocol: 'https:', + host: 'example.com', + hostname: 'example.com', + pathname: '/foo/bar', + href: 'https://example.com/foo/bar' + }); + + expect(toNodeUrl('https:\\\\\\example.com/foo/bar')).to.include({ + protocol: 'https:', + host: 'example.com', + hostname: 'example.com', + pathname: '/foo/bar', + href: 'https://example.com/foo/bar' + }); + + expect(toNodeUrl('https:///\\example.com/foo/bar')).to.include({ + protocol: 'https:', + host: 'example.com', + hostname: 'example.com', + pathname: '/foo/bar', + href: 'https://example.com/foo/bar' + }); + + // eslint-disable-next-line no-useless-escape + expect(toNodeUrl('https:/\/\/\example.com/foo/bar')).to.include({ + protocol: 'https:', + host: 'example.com', + hostname: 'example.com', + pathname: '/foo/bar', + href: 'https://example.com/foo/bar' + }); + }); + + // Refer: https://en.wikipedia.org/wiki/File_URI_scheme#How_many_slashes? + it('should handle file://host/path and file:///path', function () { + expect(toNodeUrl('file://host/path')).to.include({ + host: 'host', + hostname: 'host', + pathname: '/path', + href: 'file://host/path' + }); + + expect(toNodeUrl('file:///path')).to.include({ + host: '', + hostname: '', + pathname: '/path', + href: 'file:///path' + }); + + expect(toNodeUrl('file:////foo/bar')).to.include({ + host: '', + hostname: '', + pathname: '/foo/bar', + href: 'file:///foo/bar' + }); + }); + }); +}); diff --git a/test/unit/toNodeUrl.test.js b/test/unit/toNodeUrl.test.js index 6b25635..4e0e5e8 100644 --- a/test/unit/toNodeUrl.test.js +++ b/test/unit/toNodeUrl.test.js @@ -82,7 +82,7 @@ describe('.toNodeUrl', function () { 'http://user:password@example.com:8080/p/a/t/h?q1=v1&q2=v2#hash', 'HTTP://example.com', 'http://xn--48jwgn17gdel797d.com', - 'http://xn--iñvalid.com', + // 'http://xn--iñvalid.com', 'http://192.168.0.1:8080', 'http://192.168.0.1', 'http://[2a03:2880:f12f:183:face:b00c:0:25de]/index.html', From 28a06a590a6609833e7f6bad31bb2c428f3883fb Mon Sep 17 00:00:00 2001 From: Patrick Sevat Date: Tue, 28 Oct 2025 11:04:28 +0100 Subject: [PATCH 2/3] chore(): clean up --- encoder/browser.js | 7 ------- test/unit/toNodeUrl.browser.test.js | 1 - 2 files changed, 8 deletions(-) diff --git a/encoder/browser.js b/encoder/browser.js index 95ed811..2f7a90d 100644 --- a/encoder/browser.js +++ b/encoder/browser.js @@ -87,13 +87,6 @@ const encodeSet = require('./encode-set'), catch (error) { return punycode.toASCII(domain); } - - // try { - // return punycode.toASCII(domain); - // } - // catch (error) { - // return ''; - // } }; /** diff --git a/test/unit/toNodeUrl.browser.test.js b/test/unit/toNodeUrl.browser.test.js index 09a3431..97170a9 100644 --- a/test/unit/toNodeUrl.browser.test.js +++ b/test/unit/toNodeUrl.browser.test.js @@ -37,7 +37,6 @@ describe('[browser] .toNodeUrl', function () { } }); - debugger; expect(toNodeUrl(url)).to.eql({ protocol: 'postman:', slashes: false, From d217f789bf7f60f78a02798288ef03b8bbfb3054 Mon Sep 17 00:00:00 2001 From: Patrick Sevat Date: Tue, 28 Oct 2025 11:20:14 +0100 Subject: [PATCH 3/3] fix(): correct package.json export paths and conditions --- package.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 1470c7a..1221b00 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,17 @@ "license": "Apache-2.0", "main": "index.js", "exports": { - "browser": { - ".": "./browser.js", - "./encoder": "./encoder/browser.js", - "./parser": "./parser/index.js" + ".": { + "browser": "./browser.js", + "default": "./index.js" }, - "default": { - ".": "./index.js", - "./encoder": "./encoder/index.js", - "./parser": "./parser/index.js" + "./encoder": { + "browser": "./encoder/browser.js", + "default": "./encoder/index.js" + }, + "./parser": { + "browser": "./parser/index.js", + "default": "./parser/index.js" } }, "homepage": "https://github.com/postmanlabs/postman-url-encoder#readme",