collection/cookie.js

var _ = require('../util').lodash,
    PropertyBase = require('./property-base').PropertyBase,
    PropertyList = require('./property-list').PropertyList,

    E = '',
    EQ = '=',
    PAIR_SPLIT_REGEX = /; */,
    COOKIES_SEPARATOR = '; ',

    /**
     * Enum for all the Cookie attributes.
     *
     * @private
     * @readonly
     * @enum {string} CookieAttributes
     */
    cookieAttributes = {
        httponly: 'httpOnly',
        secure: 'secure',
        domain: 'domain',
        path: 'path',
        'max-age': 'maxAge',
        session: 'session',
        expires: 'expires'
    },

    Cookie;

/**
 * The following is the object structure accepted as constructor parameter while calling `new Cookie(...)`. It is
 * also the structure exported when {@link Property#toJSON} or {@link Property#toObjectResolved} is called on a
 * Cookie instance.
 *
 * @typedef Cookie.definition
 *
 * @property {String=} [key] The name of the cookie. Some call it the "name".
 * @property {String=} [value] The value stored in the Cookie
 * @property {String=} [expires] Expires sets an expiry date for when a cookie gets deleted. It should either be a
 * date object or timestamp string of date.
 * @property {Number=} [maxAge] Max-age sets the time in seconds for when a cookie will be deleted.
 * @property {String=} [domain] Indicates the domain(s) for which the cookie should be sent.
 * @property {String=} [path] Limits the scope of the cookie to a specified path, e.g: "/accounts"
 * @property {Boolean=} [secure] A secure cookie will only be sent to the server when a request is made using SSL and
 * the HTTPS protocol.
 * The idea that the contents of the cookie are of high value and could be potentially damaging to transmit
 * as clear text.
 * @property {Boolean=} [httpOnly] The idea behind HTTP-only cookies is to instruct a browser that a cookie should never
 * be accessible via JavaScript through the document.cookie property. This feature was designed as a security measure
 * to help prevent cross-site scripting (XSS) attacks perpetrated by stealing cookies via JavaScript.
 * @property {Boolean=} [hostOnly] Indicates that this cookie is only valid for the given domain (and not its parent or
 * child domains.)
 * @property {Boolean=} [session] Indicates whether this is a Session Cookie. (A transient cookie, which is deleted at
 * the end of an HTTP session.)
 * @property {Array=} [extensions] Any extra attributes that are extensions to the original Cookie specification can be
 * specified here.
 * @property {String} [extensions[].key] Name of the extension.
 * @property {String} [extensions[].value] Value of the extension
 *
 * @example <caption>JSON definition of an example cookie</caption>
 * {
 *     "key": "my-cookie-name",
 *     "expires": "1464769543832",
 *      // UNIX timestamp, in *milliseconds*
 *     "maxAge": "300",
 *      // In seconds. In this case, the Cookie is valid for 5 minutes
 *     "domain": "something.example.com",
 *     "path": "/",
 *     "secure": false,
 *     "httpOnly": true,
 *     "session": false,
 *     "value": "my-cookie-value",
 *     "extensions": [{
 *         "key": "Priority",
 *         "value": "HIGH"
 *     }]
 * }
 */
_.inherit((

    /**
     * A Postman Cookie definition that comprehensively represents an HTTP Cookie.
     *
     * @constructor
     * @extends {PropertyBase}
     *
     * @param {Cookie.definition} [options] Pass the initial definition of the Cookie.
     * @example <caption>Create a new Cookie</caption>
     * var Cookie = require('postman-collection').Cookie,
     *     myCookie = new Cookie({
     *          name: 'my-cookie-name',
     *          expires: '1464769543832', // UNIX timestamp, in *milliseconds*
     *          maxAge: '300',  // In seconds. In this case, the Cookie is valid for 5 minutes
     *          domain: 'something.example.com',
     *          path: '/',
     *          secure: false,
     *          httpOnly: true,
     *          session: false,
     *          value: 'my-cookie-value',
     *          extensions: [{
     *              key: 'Priority',
     *              value: 'HIGH'
     *          }]
     *     });
     *
     * @example <caption>Parse a Cookie Header</caption>
     * var Cookie = require('postman-collection').Cookie,
     *     rawHeader = 'myCookie=myValue;Path=/;Expires=Sun, 04-Feb-2018 14:18:27 GMT;Secure;HttpOnly;Priority=HIGH'
     *     myCookie = new Cookie(rawHeader);
     *
     * console.log(myCookie.toJSON());
     */
    Cookie = function PostmanCookie (options) {
        // this constructor is intended to inherit and as such the super constructor is required to be executed
        Cookie.super_.call(this, options);

        _.isString(options) && (options = Cookie.parse(options));

        options && this.update(options);
    }), PropertyBase);

_.assign(Cookie.prototype, /** @lends Cookie.prototype */ {
    update (options) {
        _.mergeDefined(this, /** @lends Cookie.prototype */ {
            /**
             * The name of the cookie.
             *
             * @type {String}
             */
            name: _.choose(options.name, options.key),

            /**
             * Expires sets an expiry date for when a cookie gets deleted. It should either be a date object or
             * timestamp string of date.
             *
             * @type {Date|String}
             *
             * @note
             * The value for this option is a date in the format Wdy, DD-Mon-YYYY HH:MM:SS GMT such as
             * "Sat, 02 May 2009 23:38:25 GMT". Without the expires option, a cookie has a lifespan of a single session.
             * A session is defined as finished when the browser is shut down, so session cookies exist only while the
             * browser remains open. If the expires option is set to a date that appears in the past, then the cookie is
             * immediately deleted in browser.
             *
             * @todo Accept date object and convert stringified date (timestamp only) to date object
             * @todo Consider using Infinity as a default
             */
            expires: _.isString(options.expires) ? new Date(options.expires) : options.expires,

            /**
             * Max-age sets the time in seconds for when a cookie will be deleted.
             *
             * @type {Number}
             */
            maxAge: _.has(options, 'maxAge') ? Number(options.maxAge) : undefined,

            /**
             * Indicates the domain(s) for which the cookie should be sent.
             *
             * @type {String}
             *
             * @note
             * By default, domain is set to the host name of the page setting the cookie, so the cookie value is sent
             * whenever a request is made to the same host name. The value set for the domain option must be part of the
             * host name that is sending the Set-Cookie header. The SDK does not perform this check, but the underlying
             * client that actually sends the request could do it automatically.
             */
            domain: options.domain,

            /**
             * @type {String}
             *
             * @note
             * On server, the default value for the path option is the path of the URL that sent the Set-Cookie header.
             */
            path: options.path,

            /**
             * A secure cookie will only be sent to the server when a request is made using SSL and the HTTPS protocol.
             * The idea that the contents of the cookie are of high value and could be potentially damaging to transmit
             * as clear text.
             *
             * @type {Boolean}
             */
            secure: _.has(options, 'secure') ? Boolean(options.secure) : undefined,

            /**
             * The idea behind HTTP-only cookies is to instruct a browser that a cookie should never be accessible via
             * JavaScript through the document.cookie property. This feature was designed as a security measure to help
             * prevent cross-site scripting (XSS) attacks perpetrated by stealing cookies via JavaScript.
             *
             * @type {Boolean}
             */
            httpOnly: _.has(options, 'httpOnly') ? Boolean(options.httpOnly) : undefined,

            /**
             * @type {Boolean}
             */
            hostOnly: _.has(options, 'hostOnly') ? Boolean(options.hostOnly) : undefined,

            /**
             * Indicates whether this is a Session Cookie.
             *
             * @type {Boolean}
             */
            session: _.has(options, 'session') ? Boolean(options.session) : undefined,

            /**
             * @note The commonly held belief is that cookie values must be URL-encoded, but this is a fallacy even
             * though it is the de facto implementation. The original specification indicates that only three types of
             * characters must be encoded: semicolon, comma, and white space. The specification indicates that URL
             * encoding may be used but stops short of requiring it. The RFC makes no mention of encoding whatsoever.
             * Still, almost all implementations perform some sort of URL encoding on cookie values.
             * @type {String}
             */
            value: options.value ? _.ensureEncoded(options.value) : undefined,

            /**
             * Any extra parameters that are not strictly a part of the Cookie spec go here.
             *
             * @type {Array}
             */
            extensions: options.extensions || undefined
        });
    },

    /**
     * Get the value of this cookie.
     *
     * @returns {String}
     */
    valueOf () {
        try {
            return decodeURIComponent(this.value);
        }
        // handle malformed URI sequence
        // refer: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Malformed_URI
        catch (error) {
            /* istanbul ignore next */
            return this.value;
        }
    },

    /**
     * Converts the Cookie to a single Set-Cookie header string.
     *
     * @returns {String}
     */
    toString () {
        var str = Cookie.unparseSingle(this);

        if (this.expires && this.expires instanceof Date) {
            // check for valid date
            if (!Number.isNaN(this.expires.getTime())) {
                str += '; Expires=' + this.expires.toUTCString();
            }
        }
        else if (this.expires) {
            str += '; Expires=' + this.expires;
        }

        if (this.maxAge && this.maxAge !== Infinity) {
            str += '; Max-Age=' + this.maxAge;
        }

        if (this.domain && !this.hostOnly) {
            str += '; Domain=' + this.domain;
        }

        if (this.path) {
            str += '; Path=' + this.path;
        }

        if (this.secure) {
            str += '; Secure';
        }

        if (this.httpOnly) {
            str += '; HttpOnly';
        }

        if (this.extensions) {
            this.extensions.forEach(({ key, value }) => {
                str += `; ${key}`;
                str += value === true ? '' : `=${value}`;
            });
        }

        return str;
    }
});

_.assign(Cookie, /** @lends Cookie */ {

    /**
     * Defines the name of this property for internal use.
     *
     * @private
     * @readOnly
     * @type {String}
     */
    _postman_propertyName: 'Cookie',

    // define behaviour of this object when put in list
    _postman_propertyIndexKey: 'name',
    _postman_propertyIndexCaseInsensitive: true,
    _postman_propertyAllowsMultipleValues: true,

    /**
     * Check whether an object is an instance of PostmanCookie.
     *
     * @param {*} obj -
     * @returns {Boolean}
     */
    isCookie: function (obj) {
        return Boolean(obj) && ((obj instanceof Cookie) ||
            _.inSuperChain(obj.constructor, '_postman_propertyName', Cookie._postman_propertyName));
    },

    /**
     * Stringifies an Array or {@link PropertyList} of Cookies into a single string.
     *
     * @param {Cookie[]} cookies - List of cookie definition object
     * @returns {String}
     */
    unparse: function (cookies) {
        if (!_.isArray(cookies) && !PropertyList.isPropertyList(cookies)) {
            return E;
        }

        return cookies.map(Cookie.unparseSingle).join(COOKIES_SEPARATOR);
    },

    /**
     * Unparses a single Cookie.
     *
     * @param {Cookie} cookie - Cookie definition object
     * @returns {String}
     */
    unparseSingle: function (cookie) {
        if (!_.isObject(cookie)) { return E; }

        var value = _.isNil(cookie.value) ? E : cookie.value;

        // for the empty name, return just the value to match the browser behavior
        if (!cookie.name) {
            return value;
        }

        return cookie.name + EQ + value;
    },

    /**
     * Cookie header parser
     *
     * @param {String} str -
     * @returns {*} A plain cookie options object, use it to create a new Cookie
     */
    parse: function (str) {
        if (!_.isString(str)) {
            return str;
        }

        var obj = {},
            pairs = str.split(PAIR_SPLIT_REGEX),
            nameval;

        nameval = Cookie.splitParam(pairs.shift()); // The first kvp is the name and value
        obj.key = nameval.key;
        obj.value = nameval.value;

        pairs.forEach(function (pair) {
            var keyval = Cookie.splitParam(pair),
                value = keyval.value,
                keyLower = keyval.key.toLowerCase();

            if (cookieAttributes[keyLower]) {
                obj[cookieAttributes[keyLower]] = value;
            }
            else {
                obj.extensions = obj.extensions || [];
                obj.extensions.push(keyval);
            }
        });
        // Handle the hostOnly flag
        if (!obj.domain) {
            obj.hostOnly = true;
        }

        return obj;
    },

    /**
     * Converts the Cookie to a single Set-Cookie header string.
     *
     * @param {Cookie} cookie - Cookie definition object
     * @returns {String}
     */
    stringify: function (cookie) {
        return Cookie.prototype.toString.call(cookie);
    },

    /**
     * Splits a Cookie parameter into a key and a value
     *
     * @private
     * @param {String} param -
     * @returns {{key: *, value: (Boolean|*)}}
     */
    splitParam: function (param) {
        var split = param.split('='),
            key, value;

        key = split[0].trim();
        value = _.isString(split[1]) ? split[1].trim() : true;

        if (_.isString(value) && value[0] === '"') {
            value = value.slice(1, -1);
        }

        return { key, value };
    }
});

module.exports = {
    Cookie
};