From b7f0cca65c0381c269c492564481137bce2b6d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Mon, 4 Nov 2019 23:00:02 +0100 Subject: [PATCH 01/10] feat: add inline cookie attributes assertion Adds the possibility to assert cookie attributes with the following syntax: ``` expect(res).to.have.cookie('key', 'value', { 'AttrOne': 'value-one', 'AttrTwo': 'value-two', // ... }); ``` --- lib/http.js | 47 ++++++++++++++++++++++++++++++++++++++++++----- test/http.js | 27 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/http.js b/lib/http.js index 95e3a2e..7267d74 100644 --- a/lib/http.js +++ b/lib/http.js @@ -367,28 +367,38 @@ 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. * * ```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,7 +414,7 @@ module.exports = function (chai, _) { cookie = cookie.getCookie(key, Cookie.CookieAccessInfo.All); } - if (arguments.length === 2) { + if (arguments.length >= 2) { this.assert( cookie.value == value , 'expected cookie \'' + key + '\' to have value #{exp} but got #{act}' @@ -419,5 +429,32 @@ module.exports = function (chai, _) { , 'expected cookie \'' + key + '\' to not exist' ); } + + if ('object' === typeof attributes) { + var pass = true; + + Object.keys(attributes).forEach(function(attr) { + var expected = attributes[attr]; + var actual = cookie[attr.toLowerCase()] + if (actual !== expected) pass = false; + }); + + this.assert( + pass + , "expected cookie '" + key + "' to have the following attributes:" + , "expected cookie '" + key + "' to not have the following attributes:" + , normalizeKeys(attributes) + , cookie + , true + ); + } }); + + function normalizeKeys(obj) { + var newObj = {}; + 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..fb67866 100644 --- a/test/http.js +++ b/test/http.js @@ -432,4 +432,31 @@ 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', + }); + + (function () { + res.should.have.cookie('sessid', 'abc', {'Path': '/wrong'}); + }).should.throw( + "expected cookie 'sessid' to have the following attributes:" + ); + }); + }); + }); }); From 16191fc8351002a09dac58dd63536060b751ec11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Tue, 5 Nov 2019 00:26:50 +0100 Subject: [PATCH 02/10] changes the assertion context after #cookie When using .cookie(key, val, attributes), the assertion context will change from the original object to the cookie itself, in order to allow readable chained assertions about the cookie properties. --- lib/http.js | 9 +++++++++ test/http.js | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/http.js b/lib/http.js index 7267d74..8f1bd7f 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; /*! @@ -448,6 +450,13 @@ module.exports = function (chai, _) { , true ); } + + // 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); + return cookieCtx; }); function normalizeKeys(obj) { diff --git a/test/http.js b/test/http.js index fb67866..b082b4b 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: { From 65b51a6e75fa8e741445c703cbafce4a57049a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Tue, 5 Nov 2019 00:30:14 +0100 Subject: [PATCH 03/10] adds chainable assertions for cookie attribute --- lib/http.js | 21 ++++++++++++++ test/http.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/http.js b/lib/http.js index 8f1bd7f..094cf9b 100644 --- a/lib/http.js +++ b/lib/http.js @@ -456,9 +456,30 @@ module.exports = function (chai, _) { var cookieCtx = new Assertion(); transferFlags(this, cookieCtx); flag(cookieCtx, 'object', cookie); + flag(cookieCtx, 'key', key); return cookieCtx; }); + 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 actual = cookie[attr.toLowerCase()] + + 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 normalizeKeys(obj) { var newObj = {}; Object.keys(obj).forEach(function(key) { diff --git a/test/http.js b/test/http.js index b082b4b..1213e05 100644 --- a/test/http.js +++ b/test/http.js @@ -468,5 +468,87 @@ describe('assertions', function () { ); }); }); + + 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 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 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=/'" + ); + }); + }); }); }); From fb35aa02df456238e51886b95bde6fc69a2c23c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Tue, 5 Nov 2019 01:34:14 +0100 Subject: [PATCH 04/10] adds support to boolean cookie attributes --- lib/http.js | 18 ++++++++++++++++-- test/http.js | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/http.js b/lib/http.js index 094cf9b..9072c3a 100644 --- a/lib/http.js +++ b/lib/http.js @@ -437,7 +437,7 @@ module.exports = function (chai, _) { Object.keys(attributes).forEach(function(attr) { var expected = attributes[attr]; - var actual = cookie[attr.toLowerCase()] + var actual = getCookieAttribute(cookie, attr); if (actual !== expected) pass = false; }); @@ -465,7 +465,11 @@ module.exports = function (chai, _) { if (this._obj instanceof Cookie.Cookie) { var cookie = this._obj; var key = flag(this, 'key'); - var actual = cookie[attr.toLowerCase()] + var actual = getCookieAttribute(cookie, attr); + + // If only one argument was passed, we are checking + // for a boolean attribute + if (arguments.length === 1) expected = true; this.assert( actual === expected @@ -480,6 +484,16 @@ module.exports = function (chai, _) { } }); + function getCookieAttribute(cookie, attr) { + attr = attr.toLowerCase(); + + // In the Cookie class, the standard 'HttpOnly' attribute is parsed + // as a non-standard 'noscript' attribute... + if (attr === 'httponly') return cookie.noscript; + + return cookie[attr]; + } + function normalizeKeys(obj) { var newObj = {}; Object.keys(obj).forEach(function(key) { diff --git a/test/http.js b/test/http.js index 1213e05..875e165 100644 --- a/test/http.js +++ b/test/http.js @@ -467,6 +467,13 @@ describe('assertions', function () { "expected cookie 'sessid' to have the following attributes:" ); }); + + 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}); + }); }); describe('as chainable method after #cookie(key, val)', function () { @@ -477,6 +484,12 @@ describe('assertions', function () { 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 allow multiple chained attributes', function () { res.should.have.cookie('sessid', 'abc') .with.attribute('Path', '/') @@ -517,6 +530,12 @@ describe('assertions', function () { 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 allow multiple chained attributes', function () { res.should.have.cookie('sessid') .with.attribute('Path', '/') From 6bf5fb2809a1374d56b702fe5410c484ff2815c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Tue, 5 Nov 2019 02:20:03 +0100 Subject: [PATCH 05/10] adds support to cookie time attributes --- lib/http.js | 48 +++++++++++++++++++++++++++++++++++++++--------- test/http.js | 20 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/http.js b/lib/http.js index 9072c3a..3a6c76a 100644 --- a/lib/http.js +++ b/lib/http.js @@ -432,12 +432,26 @@ module.exports = function (chai, _) { ); } + var rawCookie = ''; + header.forEach(function(cookieHeader) { + if (cookieHeader.startsWith(key + '=')) { + rawCookie = cookieHeader; + } + }); + + // .toString() implementation of Cookie actually makes some + // cookie attributes to disappear (like Max-Age, for example). + // We use this value only as a fallback (it's better than nothing). + if (!rawCookie && cookie instanceof Cookie.Cookie) { + rawCookie = cookie.toString(); + } + if ('object' === typeof attributes) { var pass = true; Object.keys(attributes).forEach(function(attr) { var expected = attributes[attr]; - var actual = getCookieAttribute(cookie, attr); + var actual = getCookieAttribute(rawCookie, attr); if (actual !== expected) pass = false; }); @@ -446,7 +460,7 @@ module.exports = function (chai, _) { , "expected cookie '" + key + "' to have the following attributes:" , "expected cookie '" + key + "' to not have the following attributes:" , normalizeKeys(attributes) - , cookie + , rawCookieToObj(rawCookie) , true ); } @@ -456,6 +470,7 @@ module.exports = function (chai, _) { var cookieCtx = new Assertion(); transferFlags(this, cookieCtx); flag(cookieCtx, 'object', cookie); + flag(cookieCtx, 'rawCookie', rawCookie); flag(cookieCtx, 'key', key); return cookieCtx; }); @@ -465,7 +480,8 @@ module.exports = function (chai, _) { if (this._obj instanceof Cookie.Cookie) { var cookie = this._obj; var key = flag(this, 'key'); - var actual = getCookieAttribute(cookie, attr); + var rawCookie = flag(this, 'rawCookie'); + var actual = getCookieAttribute(rawCookie, attr); // If only one argument was passed, we are checking // for a boolean attribute @@ -484,14 +500,28 @@ module.exports = function (chai, _) { } }); - function getCookieAttribute(cookie, attr) { - attr = attr.toLowerCase(); + 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; - // In the Cookie class, the standard 'HttpOnly' attribute is parsed - // as a non-standard 'noscript' attribute... - if (attr === 'httponly') return cookie.noscript; + return false; + } - return cookie[attr]; + function rawCookieToObj(rawCookie) { + var obj = {}; + rawCookie.split(';').forEach(function(pair) { + var entry = pair.trim().split('='); + var key = entry[0].toLowerCase(); + obj[key] = entry[1] ? entry[1] : true; + }); + return obj; } function normalizeKeys(obj) { diff --git a/test/http.js b/test/http.js index 875e165..0f53768 100644 --- a/test/http.js +++ b/test/http.js @@ -474,6 +474,14 @@ describe('assertions', function () { 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' + }); + }); }); describe('as chainable method after #cookie(key, val)', function () { @@ -490,6 +498,12 @@ describe('assertions', function () { 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', '/') @@ -536,6 +550,12 @@ describe('assertions', function () { 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', '/') From 4d9513e0cb43a808cdc08c1a784a64945f592242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Wed, 6 Nov 2019 00:35:50 +0100 Subject: [PATCH 06/10] makes negated version of cookie attributes work --- lib/http.js | 64 ++++++++++++++++++------------ test/http.js | 107 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 30 deletions(-) diff --git a/lib/http.js b/lib/http.js index 3a6c76a..a9be6e9 100644 --- a/lib/http.js +++ b/lib/http.js @@ -416,22 +416,13 @@ module.exports = function (chai, _) { cookie = cookie.getCookie(key, Cookie.CookieAccessInfo.All); } - if (arguments.length >= 2) { - this.assert( - cookie.value == value - , 'expected cookie \'' + key + '\' to have value #{exp} but got #{act}' - , 'expected cookie \'' + key + '\' to not have value #{exp}' - , value - , cookie.value - ); - } else { - this.assert( - 'undefined' !== typeof cookie || null === cookie - , 'expected cookie \'' + key + '\' to exist' - , 'expected cookie \'' + key + '\' to not exist' - ); - } - + // 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 + '=')) { @@ -439,30 +430,55 @@ module.exports = function (chai, _) { } }); - // .toString() implementation of Cookie actually makes some - // cookie attributes to disappear (like Max-Age, for example). - // We use this value only as a fallback (it's better than nothing). + // 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(); } - if ('object' === typeof attributes) { - var pass = true; + // First check: cookie existence + var cookieExists = 'undefined' !== typeof cookie || null === cookie; - Object.keys(attributes).forEach(function(attr) { + // 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); - if (actual !== expected) pass = false; + areAttributesCorrect = areAttributesCorrect && actual === expected; }); + } + if (arguments.length === 3) { this.assert( - pass + areAttributesCorrect , "expected cookie '" + key + "' to have the following attributes:" , "expected cookie '" + key + "' to not have the following attributes:" , normalizeKeys(attributes) , rawCookieToObj(rawCookie) , true ); + } else if (arguments.length === 2) { + this.assert( + isValueCorrect + , 'expected cookie \'' + key + '\' to have value #{exp} but got #{act}' + , 'expected cookie \'' + key + '\' to not have value #{exp}' + , value + , cookie.value + ); + } else { + this.assert( + cookieExists + , 'expected cookie \'' + key + '\' to exist' + , 'expected cookie \'' + key + '\' to not exist' + ); } // Change the assertion context to the cookie itself, diff --git a/test/http.js b/test/http.js index 0f53768..b433e6f 100644 --- a/test/http.js +++ b/test/http.js @@ -460,12 +460,6 @@ describe('assertions', function () { 'Path': '/', 'Domain': '.abc.xyz', }); - - (function () { - res.should.have.cookie('sessid', 'abc', {'Path': '/wrong'}); - }).should.throw( - "expected cookie 'sessid' to have the following attributes:" - ); }); it('should work with boolean attributes (HttpOnly, Secure)', function () { @@ -482,6 +476,107 @@ describe('assertions', function () { '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 () { From 82165cc1b095eb23a8190e7b344cb5742e51711d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Wed, 6 Nov 2019 00:36:39 +0100 Subject: [PATCH 07/10] includes cookie key/val in attributes output --- lib/http.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/http.js b/lib/http.js index a9be6e9..ccdecde 100644 --- a/lib/http.js +++ b/lib/http.js @@ -461,7 +461,7 @@ module.exports = function (chai, _) { areAttributesCorrect , "expected cookie '" + key + "' to have the following attributes:" , "expected cookie '" + key + "' to not have the following attributes:" - , normalizeKeys(attributes) + , prepareAttributesOutput(attributes, key, value) , rawCookieToObj(rawCookie) , true ); @@ -540,8 +540,9 @@ module.exports = function (chai, _) { return obj; } - function normalizeKeys(obj) { + function prepareAttributesOutput(obj, key, value) { var newObj = {}; + newObj[key] = value; Object.keys(obj).forEach(function(key) { newObj[key.toLowerCase()] = obj[key]; }); From 15c6765bb4bc2ec082ea92a1bd57490114f1565b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Wed, 6 Nov 2019 01:00:51 +0100 Subject: [PATCH 08/10] adds more comments and jsdocs --- lib/http.js | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/http.js b/lib/http.js index ccdecde..be994d9 100644 --- a/lib/http.js +++ b/lib/http.js @@ -375,6 +375,9 @@ module.exports = function (chai, _) { * 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'); @@ -491,6 +494,40 @@ module.exports = function (chai, _) { 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) { @@ -530,16 +567,35 @@ module.exports = function (chai, _) { 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) { + rawCookie.split(';').forEach(function(pair, index) { var entry = pair.trim().split('='); - var key = entry[0].toLowerCase(); + // 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; From 7bb65d67adead0bac7ca9d11cdd84ae723c64cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Wed, 6 Nov 2019 01:11:16 +0100 Subject: [PATCH 09/10] updates README --- README.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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: From e4fabbafd241a74d10980a05681a74f9fd627af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Schr=C3=B6der?= Date: Wed, 6 Nov 2019 01:16:34 +0100 Subject: [PATCH 10/10] updates TypeScript interface --- types/index.d.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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;