Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds cookie attribute assertions (fixes #259) #261

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
198 changes: 191 additions & 7 deletions lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ module.exports = function (chai, _) {
*/

var Assertion = chai.Assertion
, flag = chai.util.flag
, transferFlags = chai.util.transferFlags
, i = _.inspect;

/*!
Expand Down Expand Up @@ -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;

Expand All @@ -404,20 +419,189 @@ 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
, cookie.value
);
} 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;
}
};
Loading