diff --git a/README.md b/README.md index 002424f..fb03330 100644 --- a/README.md +++ b/README.md @@ -390,21 +390,48 @@ expect(req).to.not.have.param('limit'); ### .cookie -* **@param** _{String}_ parameter name +* **@param** _{String}_ parameter key * **@param** _{String}_ parameter value +* **@param** _{String}_ parameter attributes Assert that a `Request` or `Response` object has a cookie header with a -given key, (optionally) equal to value +given key, (optionally) equal to value and (also optionally) with the given +attributes. + +This assertion changes the context of the assertion to the cookie itself, +allowing chaining assertions about the cookie. ```js expect(req).to.have.cookie('session_id'); expect(req).to.have.cookie('session_id', '1234'); expect(req).to.not.have.cookie('PHPSESSID'); + expect(res).to.have.cookie('session_id'); expect(res).to.have.cookie('session_id', '1234'); +expect(res).to.have.cookie('session_id', '1234', {'Path': '/'}); expect(res).to.not.have.cookie('PHPSESSID'); ``` +### .attribute (chainable after .cookie) + +* **@param** _{String}_ parameter attr +* **@param** _{String}_ parameter expected + +Asserts that a cookie has a certain attribute `attr` equals to the value +`expected`. In case of boolean cookie flags (like HttpOnly and Secure), the +value can be omitted and the flag existence will be asserted instead. + +```js +expect(res).to.have.cookie('sessid').with.attribute('Path', '/'); +expect(res).to.have.cookie('sessid').with.attribute('Secure'); + +expect(res).to.have.cookie('sessid') + .with.attribute('Path', '/') + .and.with.attribute('Domain', '.abc.xyz'); + +expect(res).to.have.cookie('sessid').but.not.with.attribute('HttpOnly'); +``` + ## Releasing `chai-http` is released with [`semantic-release`](https://github.com/semantic-release/semantic-release) using the plugins: diff --git a/lib/http.js b/lib/http.js index 95e3a2e..be994d9 100644 --- a/lib/http.js +++ b/lib/http.js @@ -28,6 +28,8 @@ module.exports = function (chai, _) { */ var Assertion = chai.Assertion + , flag = chai.util.flag + , transferFlags = chai.util.transferFlags , i = _.inspect; /*! @@ -367,28 +369,41 @@ module.exports = function (chai, _) { /** * ### .cookie * - * Assert that a `Request`, `Response` or `Agent` object has a cookie header with a - * given key, (optionally) equal to value + * Assert that a `Request`, `Response` or `Agent` object has a cookie header + * with a given key. Optionally, the value and attributes of the cookie can + * also be checked. Usually, asserting cookie attributes only makes sense + * when in the context of the `Response` object. Attribute key comparison is + * case insensitive. + * + * This assertion changes the context of the assertion to the cookie itself + * in order to allow chaining with cookie specific assertions (see below). * * ```js * expect(req).to.have.cookie('session_id'); * expect(req).to.have.cookie('session_id', '1234'); * expect(req).to.not.have.cookie('PHPSESSID'); + * * expect(res).to.have.cookie('session_id'); * expect(res).to.have.cookie('session_id', '1234'); + * expect(res).to.have.cookie('session_id', '1234', { + * 'Path': '/', + * 'Domain': '.abc.xyz' + * }); * expect(res).to.not.have.cookie('PHPSESSID'); + * * expect(agent).to.have.cookie('session_id'); * expect(agent).to.have.cookie('session_id', '1234'); * expect(agent).to.not.have.cookie('PHPSESSID'); * ``` * - * @param {String} parameter name + * @param {String} parameter key * @param {String} parameter value + * @param {Object} parameter attributes * @name param * @api public */ - Assertion.addMethod('cookie', function (key, value) { + Assertion.addMethod('cookie', function (key, value, attributes) { var header = getHeader(this._obj, 'set-cookie') , cookie; @@ -404,9 +419,58 @@ module.exports = function (chai, _) { cookie = cookie.getCookie(key, Cookie.CookieAccessInfo.All); } - if (arguments.length === 2) { + // Unfortunatelly, we can't fully rely on the Cookie object retrieved + // above, as the library doesn't have support to some cookie attributes, + // like the `Max-Age`. Also, it uses some defaults, which are totally + // fine, but would affect the assertions and potentially give false + // positives (Path=/, for example, would pass the assertion, even if not + // set in the original cookie). + // For these reasons, we collect the raw cookie to parse it manually. + var rawCookie = ''; + header.forEach(function(cookieHeader) { + if (cookieHeader.startsWith(key + '=')) { + rawCookie = cookieHeader; + } + }); + + // If the above failed, we use this string representation of Cookie as a + // fallback. It's better than nothing... + if (!rawCookie && cookie instanceof Cookie.Cookie) { + rawCookie = cookie.toString(); + } + + // First check: cookie existence + var cookieExists = 'undefined' !== typeof cookie || null === cookie; + + // Second check: cookie value correctness + var isValueCorrect = true; + if (arguments.length >= 2) { + isValueCorrect = cookieExists && cookie.value == value; + } + + // Third check: all the cookie attributes should match + var areAttributesCorrect = isValueCorrect; + if (arguments.length >= 3 && 'object' === typeof attributes) { + // null can still be an object... + Object.keys(attributes || {}).forEach(function(attr) { + var expected = attributes[attr]; + var actual = getCookieAttribute(rawCookie, attr); + areAttributesCorrect = areAttributesCorrect && actual === expected; + }); + } + + if (arguments.length === 3) { + this.assert( + areAttributesCorrect + , "expected cookie '" + key + "' to have the following attributes:" + , "expected cookie '" + key + "' to not have the following attributes:" + , prepareAttributesOutput(attributes, key, value) + , rawCookieToObj(rawCookie) + , true + ); + } else if (arguments.length === 2) { this.assert( - cookie.value == value + isValueCorrect , 'expected cookie \'' + key + '\' to have value #{exp} but got #{act}' , 'expected cookie \'' + key + '\' to not have value #{exp}' , value @@ -414,10 +478,130 @@ module.exports = function (chai, _) { ); } else { this.assert( - 'undefined' !== typeof cookie || null === cookie + cookieExists , 'expected cookie \'' + key + '\' to exist' , 'expected cookie \'' + key + '\' to not exist' ); } + + // Change the assertion context to the cookie itself, + // in order to make chaining possible + var cookieCtx = new Assertion(); + transferFlags(this, cookieCtx); + flag(cookieCtx, 'object', cookie); + flag(cookieCtx, 'rawCookie', rawCookie); + flag(cookieCtx, 'key', key); + return cookieCtx; }); + + /** + * ### .attribute + * + * Assert existence or value of a cookie attribute. It requires to be + * chained after previous assertion about cookie existence or key/value. + * + * As this method doesn't change the assertion context, it can be chained + * multiple times. + * + * This method is created using `overwriteMethod` in order to avoid + * conflicts with other chai libraries potentially implementing custom + * assertions with the name "attribute". Of course, the other library + * would have to do the same, but here we are doing our part :) + * + * ```js + * expect(res).to.have.cookie('session_id') + * .with.attribute('Path', '/foo'); + * + * expect(res).to.have.cookie('session_id') + * .with.attribute('Path', '/foo') + * .and.with.attribute('Domain', '.abc.xyz'); + * + * expect(res).to.have.cookie('session_id', '123') + * .with.attribute('HttpOnly'); + * + * expect(res).to.have.cookie('session_id') + * .but.not.with.attribute('HttpOnly'); + * ``` + * + * @param {String} attr + * @param {String} [expected=true] + * @api public + */ + + Assertion.overwriteMethod('attribute', function (_super) { + return function(attr, expected) { + if (this._obj instanceof Cookie.Cookie) { + var cookie = this._obj; + var key = flag(this, 'key'); + var rawCookie = flag(this, 'rawCookie'); + var actual = getCookieAttribute(rawCookie, attr); + + // If only one argument was passed, we are checking + // for a boolean attribute + if (arguments.length === 1) expected = true; + + this.assert( + actual === expected + , "cookie '" + key + "' expected #{exp} but got #{act}" + , "cookie '" + key + "' expected attribute to not be #{exp}" + , attr + '=' + expected + , attr + '=' + actual + ); + } else { + _super.apply(this, arguments); + } + } + }); + + function getCookieAttribute(rawCookie, attr) { + // Try to capture attribute with value + var pattern = new RegExp('(?<=^|;) ?' + attr + '=([^;]+)(?:;|$)', 'i'); + var matches = rawCookie.match(pattern); + if (matches) return matches[1]; + + // If it didn't match the previous line, it can still be a boolean + pattern = new RegExp('(?<=^|;) ?' + attr + '(?:;|$)', 'i'); + matches = rawCookie.match(pattern); + if (matches) return true; + + return false; + } + + /** + * Prepares the raw cookie for the assertion failure output. All the keys + * will be converted to lowercase, with exception of the cookie key. The + * values won't pass through any conversion. + * + * @param {String} rawCookie + */ + function rawCookieToObj(rawCookie) { + var obj = {}; + rawCookie.split(';').forEach(function(pair, index) { + var entry = pair.trim().split('='); + // We shouldn't convert the case of the first key (the cookie key) + var key = index === 0 ? entry[0] : entry[0].toLowerCase(); + obj[key] = entry[1] ? entry[1] : true; + }); + return obj; + } + + /** + * This function prepares the "attributes" object (passed as an argument + * to .cookie(...)) for the assertion failure output. All keys are converted + * to lowercase and the cookie key/value is added to the output (without + * case convertion) in order to make inspection of the failure easier. + * + * @param {Object} attributes + * @param {String} key - the cookie key + * @param {String} value - the expected cookie value + * @api private + */ + function prepareAttributesOutput(obj, key, value) { + var newObj = {}; + newObj[key] = value; + Object.keys(obj).forEach(function(key) { + newObj[key.toLowerCase()] = obj[key]; + }); + return newObj; + } }; diff --git a/test/http.js b/test/http.js index 0002f9d..b433e6f 100644 --- a/test/http.js +++ b/test/http.js @@ -328,6 +328,16 @@ describe('assertions', function () { }).should.throw('expected cookie \'name2\' to have value \'value\' but got \'value2\''); }); + it('#cookie (changes assertion context)', function () { + var Cookie = require('cookiejar').Cookie; + var res = { + headers: {'set-cookie': ['name=value']} + }; + + var context = res.should.have.cookie('name'); + context._obj.should.be.instanceof(Cookie); + }); + it('#cookie (request)', function () { var req = { headers: { @@ -432,4 +442,247 @@ describe('assertions', function () { }).should.throw('expected content type to have utf-8 charset'); }); }); + + describe('#cookie attributes', function () { + function resWithCookie(cookie) { + return { + headers: {'set-cookie': [cookie]} + }; + } + + describe('as additional argument to #cookie', function () { + it('only matches required attributes (ignores the rest)', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz'); + + res.should.have.cookie('sessid', 'abc', {'Path': '/'}); + res.should.have.cookie('sessid', 'abc', {'Domain': '.abc.xyz'}); + res.should.have.cookie('sessid', 'abc', { + 'Path': '/', + 'Domain': '.abc.xyz', + }); + }); + + it('should work with boolean attributes (HttpOnly, Secure)', function () { + var res = resWithCookie('sessid=abc; Path=/; HttpOnly; Secure'); + + res.should.have.cookie('sessid', 'abc', {'Secure': true}); + res.should.have.cookie('sessid', 'abc', {'HttpOnly': true}); + }); + + it('should work with time attributes (Expires, Max-Age)', function () { + var res = resWithCookie('sessid=abc; Expires=Wed, 15 Jun 2031 14:20:00 GMT; Max-Age=2592000'); + res.should.have.cookie('sessid', 'abc', {'Max-Age': '2592000'}); + res.should.have.cookie('sessid', 'abc', { + 'Expires': 'Wed, 15 Jun 2031 14:20:00 GMT' + }); + }); + + it('should throw in case of failure', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz'); + + (function () { + res.should.have.cookie('sessid', 'abc', {'Path': '/wrong'}); + }).should.throw( + "expected cookie 'sessid' to have the following attributes:" + ); + + (function () { + res.should.have.cookie('sessid', 'abc', {'Domain': '.mno.ijk'}); + }).should.throw( + "expected cookie 'sessid' to have the following attributes:" + ); + + (function () { + res.should.have.cookie('sessid', 'abc', {'Secure': true}); + }).should.throw( + "expected cookie 'sessid' to have the following attributes:" + ); + }); + + it('should work with negation', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz; HttpOnly'); + + // This form is trickier then it seems. In these cases, is the user + // expecting to not find any of the cookie caracteristics or if any + // of them mismatches, should the test already succeed? + // + // By boolean logic, the positive form of this assertion would be: + // A && B && C + // Negating it sould result in: + // !(A && B && C) => !A || !B || !C + // Meaning that any mismatch should make the negative form to pass + // the assertion. + // + // The common sense follows this conclusion as the cookie: + // "key=val0; attr1=val1; attr2=val2" + // is definitely not the same as any of the following cookies: + // "key=XXXX; attr1=val1; attr2=val2" + // "key=val0; attr1=XXXX; attr2=val2" + // "key=val0; attr1=val1; attr2=XXXX" + res.should.not.have.cookie('sessid', 'abc', {'Path': '/foo'}); + res.should.not.have.cookie('sessid', 'abc', {'Domain': '.mno.ijk'}); + res.should.not.have.cookie('sessid', 'abc', {'HttpOnly': false}); + + // Wrong Path + res.should.not.have.cookie('sessid', 'abc', { + 'Path': '/foo', + 'Domain': '.abc.xyz', + 'HttpOnly': true + }); + + // Wrong Domain + res.should.not.have.cookie('sessid', 'abc', { + 'Path': '/', + 'Domain': '.mno.ijk', + 'HttpOnly': true + }); + + // Wrong HttpOnly flag + res.should.not.have.cookie('sessid', 'abc', { + 'Path': '/', + 'Domain': '.abc.xyz', + 'HttpOnly': false + }); + + // Correct attributes but wrong cookie value + res.should.not.have.cookie('sessid', 'WRONG-VALUE', { + 'Path': '/', + 'Domain': '.abc.xyz', + 'HttpOnly': true + }); + }); + + it('should throw in case of negated failure', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz; HttpOnly'); + + (function () { + res.should.not.have.cookie('sessid', 'abc', {'Path': '/'}); + }).should.throw( + "expected cookie 'sessid' to not have the following attributes:" + ); + + (function () { + res.should.not.have.cookie('sessid', 'abc', {'Domain': '.abc.xyz'}); + }).should.throw( + "expected cookie 'sessid' to not have the following attributes:" + ); + + (function () { + res.should.not.have.cookie('sessid', 'abc', { + 'Path': '/', + 'Domain': '.abc.xyz', + 'HttpOnly': true + }); + }).should.throw( + "expected cookie 'sessid' to not have the following attributes:" + ); + }); + }); + + describe('as chainable method after #cookie(key, val)', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz'); + + it('should match required attribute', function () { + res.should.have.cookie('sessid', 'abc').with.attribute('Path', '/'); + res.should.have.cookie('sessid', 'abc').with.attribute('Domain', '.abc.xyz'); + }); + + it('should work with boolean attributes (HttpOnly, Secure)', function () { + var res = resWithCookie('sessid=abc; Domain=.abc.xyz; HttpOnly; Secure'); + res.should.have.cookie('sessid', 'abc').with.attribute('HttpOnly'); + res.should.have.cookie('sessid', 'abc').with.attribute('Secure'); + }); + + it('should work with time attributes (Expires, Max-Age)', function () { + var res = resWithCookie('sessid=abc; Expires=Wed, 15 Jun 2031 14:20:00 GMT; Max-Age=2592000'); + res.should.have.cookie('sessid', 'abc').with.attribute('Max-Age', '2592000'); + res.should.have.cookie('sessid', 'abc').with.attribute('Expires', 'Wed, 15 Jun 2031 14:20:00 GMT'); + }); + + it('should allow multiple chained attributes', function () { + res.should.have.cookie('sessid', 'abc') + .with.attribute('Path', '/') + .and.with.attribute('Domain', '.abc.xyz'); + }); + + it('should throw in case of failure', function () { + (function () { + res.should.have.cookie('sessid', 'abc').with.attribute('Path', '/wrong'); + }).should.throw( + "cookie 'sessid' expected 'Path=/wrong' but got 'Path=/'" + ); + }); + + it('should work with negation', function () { + res.should.have.cookie('sessid', 'abc') + .but.not.with.attribute('Path', '/foo'); + + res.should.have.cookie('sessid', 'abc') + .but.not.with.attribute('Domain', '.mno.efg'); + }); + + it('should throw in case of negated failure', function () { + (function () { + res.should.have.cookie('sessid', 'abc') + .but.not.with.attribute('Path', '/'); + }).should.throw( + "cookie 'sessid' expected attribute to not be 'Path=/'" + ); + }); + }); + + describe('as chainable method after #cookie(key)', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz'); + + it('should match required attribute', function () { + res.should.have.cookie('sessid').with.attribute('Path', '/'); + res.should.have.cookie('sessid').with.attribute('Domain', '.abc.xyz'); + }); + + it('should work with boolean attributes (HttpOnly, Secure)', function () { + var res = resWithCookie('sessid=abc; Domain=.abc.xyz; HttpOnly; Secure'); + res.should.have.cookie('sessid').with.attribute('HttpOnly'); + res.should.have.cookie('sessid').with.attribute('Secure'); + }); + + it('should work with time attributes (Expires, Max-Age)', function () { + var res = resWithCookie('sessid=abc; Expires=Wed, 15 Jun 2031 14:20:00 GMT; Max-Age=2592000'); + res.should.have.cookie('sessid').with.attribute('Max-Age', '2592000'); + res.should.have.cookie('sessid').with.attribute('Expires', 'Wed, 15 Jun 2031 14:20:00 GMT'); + }); + + it('should allow multiple chained attributes', function () { + res.should.have.cookie('sessid') + .with.attribute('Path', '/') + .and.with.attribute('Domain', '.abc.xyz'); + }); + + it('should throw in case of failure', function () { + (function () { + res.should.have.cookie('sessid').with.attribute('Path', '/wrong'); + }).should.throw( + "cookie 'sessid' expected 'Path=/wrong' but got 'Path=/'" + ); + }); + + it('should work with negation', function () { + var res = resWithCookie('sessid=abc; Path=/; Domain=.abc.xyz'); + + res.should.have.cookie('sessid') + .but.not.with.attribute('Path', '/foo'); + + res.should.have.cookie('sessid') + .but.not.with.attribute('Domain', '.mno.efg'); + }); + + it('should throw in case of negated failure', function () { + (function () { + res.should.have.cookie('sessid') + .but.not.with.attribute('Path', '/'); + }).should.throw( + "cookie 'sessid' expected attribute to not be 'Path=/'" + ); + }); + }); + }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 628961d..ef2bc0e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -27,7 +27,9 @@ declare global { param(key: string, value?: string): Assertion; - cookie(key: string, value?: string): Assertion; + cookie(key: string, value?: string, attributes?: Object): Assertion; + + attribute(attr: string, expected?: string|boolean): Assertion; status(code: number): Assertion;