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

Create and implement async/sync test helpers #523

Merged
merged 1 commit into from
Oct 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 37 additions & 59 deletions test/claim-iat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,20 @@ const testUtils = require('./test-utils');
const base64UrlEncode = testUtils.base64UrlEncode;
const noneAlgorithmHeader = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0';

function signWithIssueAtSync(issueAt, options) {
const payload = {};
if (issueAt !== undefined) {
payload.iat = issueAt;
}
const opts = Object.assign({algorithm: 'none'}, options);
return jwt.sign(payload, undefined, opts);
}

function signWithIssueAtAsync(issueAt, options, cb) {
function signWithIssueAt(issueAt, options, callback) {
const payload = {};
if (issueAt !== undefined) {
payload.iat = issueAt;
}
const opts = Object.assign({algorithm: 'none'}, options);
// async calls require a truthy secret
// see: https://github.com/brianloveswords/node-jws/issues/62
return jwt.sign(payload, 'secret', opts, cb);
testUtils.signJWTHelper(payload, 'secret', opts, callback);
}

function verifyWithIssueAtSync(token, maxAge, options) {
function verifyWithIssueAt(token, maxAge, options, callback) {
const opts = Object.assign({maxAge}, options);
return jwt.verify(token, undefined, opts)
}

function verifyWithIssueAtAsync(token, maxAge, options, cb) {
const opts = Object.assign({maxAge}, options);
return jwt.verify(token, undefined, opts, cb)
testUtils.verifyJWTHelper(token, undefined, opts, callback);
}

describe('issue at', function() {
Expand All @@ -53,22 +39,22 @@ describe('issue at', function() {
{foo: 'bar'},
].forEach((iat) => {
it(`should error with iat of ${util.inspect(iat)}`, function (done) {
expect(() => signWithIssueAtSync(iat, {})).to.throw('"iat" should be a number of seconds');
signWithIssueAtAsync(iat, {}, (err) => {
expect(err.message).to.equal('"iat" should be a number of seconds');
done();
signWithIssueAt(iat, {}, (err) => {
testUtils.asyncCheck(done, () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit weird to use this, however, it is better than the try-catch, and more stable than simply... adding the asserts. So, good, let's keep this solution for now until we find a better one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long term, promises solves this problem and that appears to be direction that the JavaScript/Node.js community is moving. But that would mean converting the project to not use callbacks, which would be well outside the scope of fixing the tests.

expect(err).to.be.instanceOf(Error);
expect(err.message).to.equal('"iat" should be a number of seconds');
});
});
});
});

// undefined needs special treatment because {} is not the same as {iat: undefined}
it('should error with iat of undefined', function (done) {
expect(() => jwt.sign({iat: undefined}, undefined, {algorithm: 'none'})).to.throw(
'"iat" should be a number of seconds'
);
jwt.sign({iat: undefined}, undefined, {algorithm: 'none'}, (err) => {
expect(err.message).to.equal('"iat" should be a number of seconds');
done();
testUtils.signJWTHelper({iat: undefined}, 'secret', {algorithm: 'none'}, (err) => {
testUtils.asyncCheck(done, () => {
expect(err).to.be.instanceOf(Error);
expect(err.message).to.equal('"iat" should be a number of seconds');
});
});
});
});
Expand All @@ -92,14 +78,11 @@ describe('issue at', function() {
it(`should error with iat of ${util.inspect(iat)}`, function (done) {
const encodedPayload = base64UrlEncode(JSON.stringify({iat}));
const token = `${noneAlgorithmHeader}.${encodedPayload}.`;
expect(() => verifyWithIssueAtSync(token, '1 min', {})).to.throw(
jwt.JsonWebTokenError, 'iat required when maxAge is specified'
);

verifyWithIssueAtAsync(token, '1 min', {}, (err) => {
expect(err).to.be.instanceOf(jwt.JsonWebTokenError);
expect(err.message).to.equal('iat required when maxAge is specified');
done();
verifyWithIssueAt(token, '1 min', {}, (err) => {
testUtils.asyncCheck(done, () => {
expect(err).to.be.instanceOf(jwt.JsonWebTokenError);
expect(err.message).to.equal('iat required when maxAge is specified');
});
});
});
})
Expand Down Expand Up @@ -163,25 +146,17 @@ describe('issue at', function() {
},
].forEach((testCase) => {
it(testCase.description, function (done) {
const token = signWithIssueAtSync(testCase.iat, testCase.options);
expect(jwt.decode(token).iat).to.equal(testCase.expectedIssueAt);
signWithIssueAtAsync(testCase.iat, testCase.options, (err, token) => {
// node-jsw catches the error from expect, so we have to wrap it in try/catch and use done(error)
try {
signWithIssueAt(testCase.iat, testCase.options, (err, token) => {
testUtils.asyncCheck(done, () => {
expect(err).to.be.null;
expect(jwt.decode(token).iat).to.equal(testCase.expectedIssueAt);
done();
}
catch (e) {
done(e);
}
});
});
});
});
});

describe('when verifying a token', function() {
let token;
let fakeClock;

beforeEach(function() {
Expand Down Expand Up @@ -213,10 +188,14 @@ describe('issue at', function() {
},
].forEach((testCase) => {
it(testCase.description, function (done) {
const token = signWithIssueAtSync(undefined, {});
const token = jwt.sign({}, 'secret', {algorithm: 'none'});
fakeClock.tick(testCase.clockAdvance);
expect(verifyWithIssueAtSync(token, testCase.maxAge, testCase.options)).to.not.throw;
verifyWithIssueAtAsync(token, testCase.maxAge, testCase.options, done)
verifyWithIssueAt(token, testCase.maxAge, testCase.options, (err, token) => {
testUtils.asyncCheck(done, () => {
expect(err).to.be.null;
expect(token).to.be.a('object');
});
});
});
});

Expand Down Expand Up @@ -256,16 +235,15 @@ describe('issue at', function() {
].forEach((testCase) => {
it(testCase.description, function(done) {
const expectedExpiresAtDate = new Date(testCase.expectedExpiresAt);
token = signWithIssueAtSync(undefined, {});
const token = jwt.sign({}, 'secret', {algorithm: 'none'});
fakeClock.tick(testCase.clockAdvance);
expect(() => verifyWithIssueAtSync(token, testCase.maxAge, {}))
.to.throw(jwt.TokenExpiredError, testCase.expectedError)
.to.have.property('expiredAt').that.deep.equals(expectedExpiresAtDate);
verifyWithIssueAtAsync(token, testCase.maxAge, {}, (err) => {
expect(err).to.be.instanceOf(jwt.TokenExpiredError);
expect(err.message).to.equal(testCase.expectedError);
expect(err.expiredAt).to.deep.equal(expectedExpiresAtDate);
done();

verifyWithIssueAt(token, testCase.maxAge, testCase.options, (err) => {
testUtils.asyncCheck(done, () => {
expect(err).to.be.instanceOf(jwt.JsonWebTokenError);
expect(err.message).to.equal(testCase.expectedError);
expect(err.expiredAt).to.deep.equal(expectedExpiresAtDate);
});
});
});
});
Expand Down
114 changes: 113 additions & 1 deletion test/test-utils.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,125 @@
'use strict';

const jwt = require('../');
const expect = require('chai').expect;
const sinon = require('sinon');

/**
* Correctly report errors that occur in an asynchronous callback
* @param {function(err): void} done The mocha callback
* @param {function(): void} testFunction The assertions function
*/
function asyncCheck(done, testFunction) {
try {
testFunction();
done();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we want this call (done()) outside of the try to avoid double called in case of error inside of it, right? (because we are passing the done from mocha)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ Silly thing, I'm also fine if you leave it as is, just let me know and I'll merge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both options have different problems.

The way the code is currently means that if the done() call throws an error then done(err) will be called. This would be a double call to done which is generally considered a bad practice. In the case of Mocha, it will warn about the double call to done. Also, in this case anyways, for done() to fail, it would mean that Mocha is broken in some way.

By moving the done() call outside the try block then an error thrown by done() could be swallowed and not reported by Mocha or logged to the console.

I decided on what is there now, because I would rather Mocha tell the developer that something went wrong, then ignoring the error, or reporting that done was not called.

I think it's fine to keep things this way and try to fix the library later to avoid this problem altogether.

}
catch(err) {
done(err);
}
}

/**
* Assert that two errors are equal
* @param e1 {Error} The first error
* @param e2 {Error} The second error
*/
// chai does not do deep equality on errors: https://github.com/chaijs/chai/issues/1009
function expectEqualError(e1, e2) {
// message and name are not always enumerable, so manually reference them
expect(e1.message, 'Async/Sync Error equality: message').to.equal(e2.message);
expect(e1.name, 'Async/Sync Error equality: name').to.equal(e2.name);

// compare other enumerable error properties
for(const propertyName in e1) {
expect(e1[propertyName], `Async/Sync Error equality: ${propertyName}`).to.deep.equal(e2[propertyName]);
}
}

/**
* Base64-url encode a string
* @param str {string} The string to encode
* @returns {string} The encoded string
*/
function base64UrlEncode(str) {
return Buffer.from(str).toString('base64')
.replace(/\=/g, "")
.replace(/[=]/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_")
;
}

/**
* Verify a JWT, ensuring that the asynchronous and synchronous calls to `verify` have the same result
* @param {string} jwtString The JWT as a string
* @param {string} secretOrPrivateKey The shared secret or private key
* @param {object} options Verify options
* @param {function(err, token):void} callback
*/
function verifyJWTHelper(jwtString, secretOrPrivateKey, options, callback) {
// freeze the time to ensure the clock remains stable across the async and sync calls
const fakeClock = sinon.useFakeTimers({now: Date.now()});
let error;
let syncVerified;
try {
syncVerified = jwt.verify(jwtString, secretOrPrivateKey, options);
}
catch (err) {
error = err;
}
jwt.verify(jwtString, secretOrPrivateKey, options, (err, asyncVerifiedToken) => {
try {
if (error) {
expectEqualError(err, error);
callback(err);
}
else {
expect(syncVerified, 'Async/Sync token equality').to.deep.equal(asyncVerifiedToken);
callback(null, syncVerified);
}
}
finally {
if (fakeClock) {
fakeClock.restore();
MitMaro marked this conversation as resolved.
Show resolved Hide resolved
}
}
});
}

/**
* Sign a payload to create a JWT, ensuring that the asynchronous and synchronous calls to `sign` have the same result
* @param {object} payload The JWT payload
* @param {string} secretOrPrivateKey The shared secret or private key
* @param {object} options Sign options
* @param {function(err, token):void} callback
*/
function signJWTHelper(payload, secretOrPrivateKey, options, callback) {
// freeze the time to ensure the clock remains stable across the async and sync calls
const fakeClock = sinon.useFakeTimers({now: Date.now()});
let error;
let syncSigned;
try {
syncSigned = jwt.sign(payload, secretOrPrivateKey, options);
}
catch (err) {
error = err;
}
jwt.sign(payload, secretOrPrivateKey, options, (err, asyncSigned) => {
fakeClock.restore();
if (error) {
expectEqualError(err, error);
callback(err);
}
else {
expect(syncSigned, 'Async/Sync token equality').to.equal(asyncSigned);
callback(null, syncSigned);
}
});
}

module.exports = {
asyncCheck,
base64UrlEncode,
signJWTHelper,
verifyJWTHelper,
};