Recently, I have found myself needing to build out some authorization modules for both Hapi and Express applications. Hapi has become my server of choice, and building some fully testable plugins has been a priority. In the process, I have learned several techniques that have really streamlined my testing.
There are a few tools I’ve settled on for the baseline structure of my testing:
For our use case, we will look at an authorization plugin that examines inbound request headers for an Authorization token – using the hapi-auth-header
module. If the token exists, the plugin will leverage a custom library to validate the token against our Oauth service. Here is an extremely simple example of our plugin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
const oauth = require('./my-oauth'); const myAuth = { register: function (server, options, next) { if (options.env == 'testing') { // expose our lib for mocking in test environments server.expose('Oauth', oauth); } server.register(require('hapi-auth-header'), (err) => { server.auth.strategy('my-auth', 'auth-header', true, { validateFunc: function (token, callback) { var tok = token.BEARER || token.Bearer || token.bearer; oauth.validate(tok).then(function (data) { callback(null, true, { token: tok }); }).catch(function (err) { callback(null, false, { token: tok }); }); } }); }); next(); } }; module.exports.register = myAuth.register; |
With the plugin, we have registered a new authorization strategy and have instructed the server to apply this strategy to all routes by default. If any requests come in without an Authorization header containing a bearer token, or if our Oauth library rejects the validation promise, the server will send a 401 Unauthorized
response.
Now, if we want to expose specific endpoints on our service that do not require authentication, such as health checks, we can do so with the config option on those routes:
1 2 3 4 5 6 |
server.route({ method: 'GET', path: '/health', config: { auth: false }, handler: healthCheck }); |
If we configure the plugin not to apply our custom authorization by default, we can use the same config property to apply our authorization by route as well. Since we have a newly named strategy, this will enable it for a route:
1 2 3 4 5 6 |
server.route({ method: 'GET', path: '/admin', config: { auth: 'my-auth' }, handler: adminHandler }); |
After some manual checks to verify things are working as expected, we need to write some automated tests against our plugin.
Without getting into the nuances of testing strategies, the previous example has essentially three major paths that need to be tested: one happy path and two failure cases (nonexistent auth token and invalid auth token). Since we want to be testing in a sandboxed environment, we will have to stub some of the underlying library methods that our plugin is using, namely the oauth.validate()
call.
Thorough testing doesn’t ever happen by accident! We actually need to design our code to be testable. Notice the following code from our plugin above:
1 2 3 4 |
if (options.env == 'testing') { // expose our lib for mocking in test environments server.expose('Oauth', oauth); } |
Only when our plugin is initialized with an environment option set to “testing”, we expose the underlying Oauth library in order to stub it out for thorough testing. In Hapi, when you use the expose()
method like this, you can now access the exposed property within the plugin attribute on the server itself. In our test suite, we will set up the server with this environment set:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
const server; describe ('Plugin test suite', function () { // This will startup our server fresh for each test beforeEach(function (done) { server = new Hapi.Server(); server.connection({ port: 8080 }); server.register({ register: require('../src/plugins/my-auth'), options: { env: 'testing' } }, function (err) { if (err) done(err); var handler = function (request, reply) { reply('ok'); }; server.route({ method: 'GET', path: '/protected', handler: handler }); server.route({ method: 'GET', path: '/open', config: { auth: false }, handler: handler }); server.start(done); }); }); afterEach(function () { server.stop(); }); // tests go here }); |
Notice what we have done is set the stage for all our testing. We have a route at /protected
that will apply our new authentication plugin to the request headers, a second route at /open
that will not, and both routes will reply with a simple “ok” string when they have allowed access.
First, take note that we will be using supertest
to make all our requests directly on our server. Be sure to use this module in your test suite:
1 |
const request = require('supertest'); |
Now, we need to set up our tests for all three of our main scenarios. This is where a really cool Mocha feature comes in handy: Promise mocking. Rather than having to jump through a lot of hoops to test this, we can simply stub out the oauth.validate()
call, and we have full control over our scenarios:
1 2 3 4 5 |
it ('should reject /protected on missing auth headers', function (done) { request(server.listener) .get('/protected') .expect(401, done); }); |
Incredibly simple, no? This tests only for the existence of auth headers and responds appropriately for both our configured routes. Now, we get just a bit more complex. Remember when we set up our plugin to expose the Oauth object to be mocked? Now is when we hook into that and test both resolution and rejection cases:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
it ('should reject when oauth.validate() fails', function (done) { var promise = Promise.reject('forced failure message'); var oauth = server.plugins['my-auth'].Oauth; sinon.stub(oauth, 'validate').returns(promise); request(server.listener) .get('/protected') .set('Authorization', 'bearer abc123') // set auth header .expect(401, done); }); it ('should allow when oauth.validate() succeeds', function (done) { var promise = Promise.resolve({ foo: 'bar' }); var oauth = server.plugins['my-auth'].Oauth; sinon.stub(oauth, 'validate').returns(promise); request(server.listener) .get('/protected') .set('Authorization', 'bearer abc123') // set auth header .expect(200, done); }); |
Mocha gives us an amazing boon here: the ability to create new Promise objects in a resolved or rejected state that we can then stub or inject into our different methods. I began this exercise by trying to mock the bluebird library, but there are implicit exceptions that will be thrown in failure cases, even if your tests fully pass. By using Mocha’s promises, we can write pretty robust tests in just a few lines of code!
At this point, we have covered our three major use cases without having to reach out to any external sources. To be able to claim full code coverage, though, we should write some mocked tests around our oauth library as well, but we will cover that in another post.
Hopefully this will help save someone else some of the time it took me to discover the different techniques.