TDD course with AdonisJs - 5. Middlewares

Published 9/21/2019

In the last episode we added authorization to our cruddy ThreadController. However, just because a user is authenticated doesn't mean he is authorized to delete any thread. This should be restricted to moderators and the user who created the thread.

As always, you can find all the changes in this commit: https://github.com/MZanggl/tdd-adonisjs/commit/d845ed83700210ac1b520a25c702373df0782b69

Before we jump into testing a middleware, let's remember to keep it as simple as possible. Let's just add the authorization logic in the controller. So for that, let's extend our functional thread.spec.js file with the following test:

test('thread can not be deleted by a user who did not create it', async ({ client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const notOwner = await Factory.model('App/Models/User').create()
  const response = await client.delete(thread.url()).send().loginVia(notOwner).end()
  response.assertStatus(403)
})

Remember that the factory for threads is now also creating a user since it depends on it.

The test fails with the error

expected 204 to equal 403
  204 => 403

Let's go into the ThreadController and add the authorization logic there:

async destroy({ params, auth, response }) {
    const thread = await Thread.findOrFail(params.id)

    if (thread.user_id !== auth.user.id) {
        return response.status(403).send()
    }

    await thread.delete()
}

Now the test passes. However, we have broken the test "authorized user can delete threads" because it now returns a 403 even though we expect it to return 204.

That makes perfect sense. If we take a look at the test we authenticate using not the owner of the thread, but using a new user. Let's get that fixed.

We can replace

const user = await Factory.model('App/Models/User').create()
const thread = await Factory.model('App/Models/Thread').create()
const response = await client.delete(thread.url()).send().loginVia(user).end()

with

const thread = await Factory.model('App/Models/Thread').create()
const owner = await thread.user().first()
const response = await client.delete(thread.url()).send().loginVia(owner).end()

As you can see we will get the user from the thread instance. Since we haven't defined that relationship (only the other way around), we will receive the error thread.user is not a function. So let's add the relationship to "App/Models/Thread.js".

user() {
    return this.belongsTo('App/Models/User')
}

And there we go, the tests are green.

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

Let's get a quick refactoring in place. In the ThreadController we added return response.status(403).send(). Simply replace that bit with return response.forbidden() and you should still get green!

Before we abstract the authorization logic into a policy, let's first make it worth doing so. What I mean is, let's first create some duplication, before we abstract things, and what's a better fit for this than updating threads!

test('authorized user can update title and body of threads', async ({ assert, client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const owner = await thread.user().first()
  const attributes = { title: 'new title', body: 'new body' }
  const updatedThreadAttributes = { ...thread.toJSON(), ...attributes }

  const response = await client.put(thread.url()).loginVia(owner).send(attributes).end()
  await thread.reload()

  response.assertStatus(200)
  response.assertJSON({ thread: thread.toJSON() })
  assert.deepEqual(thread.toJSON(), updatedThreadAttributes)
})

So first we create a thread, define all the attributes we want to update and then merge the two together to create an image of how the thread should be updated. Then we send off the request and refresh our thread model.

Finally we assert the response status and text as well as check if the attributes were updated accordingly.

Running the test suite results in a 404, so let's add it to start/routes.js.

Route.resource('threads', 'ThreadController').only(['store', 'destroy', 'update']).middleware('auth')

You should already be familiar with the pattern at this point. You get a 500, so add console.log(response.error) in the unit test right after we fire the request. This should log RuntimeException: E_UNDEFINED_METHOD: Method update missing on App/Controllers/Http/ThreadController.

Time to add the method to our ThreadController

async update({ response }) {
    return response.json({ })
}

And we now get the error expected {} to deeply equal { Object (thread) }.

So time to get serious with the update method, here is the full code

async update({ request, params, response }) {
    const thread = await Thread.findOrFail(params.id)
    thread.merge(request.only(['title', 'body']))
    await thread.save()
    return response.json({ thread })
}

This gets the tests to pass.

Let's add a test to confirm that the auth middleware is applied

test('unauthenticated user cannot update threads', async ({ assert, client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const response = await client.put(thread.url()).send().end()
  response.assertStatus(401)
})

Passes!

And a test to check that only the owner of a thread can really update it.

test('thread can not be updated by a user who did not create it', async ({ client }) => {
  const thread = await Factory.model('App/Models/Thread').create()
  const notOwner = await Factory.model('App/Models/User').create()
  const response = await client.put(thread.url()).send().loginVia(notOwner).end()
  response.assertStatus(403)
})

Fails :/

Great, so let's copy over the authorization logic from the destroy method.

async update({ request, auth, params, response }) {
    const thread = await Thread.findOrFail(params.id)
    if (thread.user_id !== auth.user.id) {
        return response.forbidden()
    }

    thread.merge(request.only(['title', 'body']))
    await thread.save()
    return response.json({ thread })
}

The test passes, but now we have created duplication. Time to create a policy! For this we will go away from our feature test and break down to a unit test. Now Adonis doesn't have a concept of policies, so we will use a middleware for this, hence the title "Testing middlewares".

First let's create a new unit test for the non existing middleware.

adonis make:test ModifyThreadPolicy

and select "Unit test".

Now replace the example test with the following test case

test('non creator of a thread cannot modify it', async ({ assert, client }) => {
  
})

Great. So what is the best way to test a middleware? Well, we can simply import "Route" and dynamically create a route that is only valid during testing.

Let's do just that and pull in all traits and modules we need later on.

'use strict'

const { test, trait } = use('Test/Suite')('Modify Thread Policy')

const Route = use('Route')
const Factory = use('Factory')

trait('Test/ApiClient')
trait('Auth/Client')
trait('DatabaseTransactions')

test('non creator of a thread cannot modify it', async ({ assert, client }) => {
  const action = ({ response }) => response.json({ ok: true })
  Route.post('test/modify-thread-policy/:id', action).middleware(['auth', 'modifyThreadPolicy'])
})

Now that we have the route, let's send off a request and do some assertions!

  // ...
  const thread = await Factory.model('App/Models/Thread').create()
  const notOwner = await Factory.model('App/Models/User').create()
  let response = await client.post(`test/modify-thread-policy/${thread.id}`).loginVia(notOwner).send().end()
  console.log(response.error)
  response.assertStatus(403)

Running the test should throw the error RuntimeException: E_MISSING_NAMED_MIDDLEWARE: Cannot find any named middleware for {modifyThreadPolicy}. Make sure you have registered it inside start/kernel.js file..

So let's do as it says and add the following line in the namedMiddleware array in "start/kernel.js".

  modifyThreadPolicy: 'App/Middleware/ModifyThreadPolicy'

Running the test now returns an error that Adonis couldn't find the module.

Let's create the policy by running

adonis make:middleware ModifyThreadPolicy

and select 'For HTTP requests'.

Let's run the test again. Since we didn't add any logic to the middleware it will not do anything and forward the request to the action, which returns the status code 200.

expected 200 to equal 403
  200 => 403

Since we already have the logic we need in the controller, let's go ahead and copy it over to the middleware.

Altogether, our middleware looks like this

'use strict'


const Thread = use('App/Models/Thread')

class ModifyThreadPolicy {
  async handle ({ params, auth, response }, next) {
    const thread = await Thread.findOrFail(params.id)
    if (thread.user_id !== auth.user.id) {
      return response.forbidden()
    }

    await next()
  }
}

module.exports = ModifyThreadPolicy

And it passes!

Let's add another unit test in "modify-thread-policy.spec.js" to test the happy path.

test('creator of a thread can modify it', async ({ assert, client }) => {
  const action = ({ response }) => response.json({ ok: true })
  Route.post('test/modify-thread-policy/:id', action).middleware(['auth', 'modifyThreadPolicy'])

  const thread = await Factory.model('App/Models/Thread').create()
  const owner = await thread.user().first()
  let response = await client.post(`test/modify-thread-policy/${thread.id}`).loginVia(owner).send().end()
  response.assertStatus(200)
})

To avoid creating the route twice, let's add a before section to the test file.

Import it at the top of the file like so: const { test, trait, before } = use('Test/Suite')('Modify Thread Policy'), remove the route creation logic from each test and put the following code before the tests:

before(() => {
  const action = ({ response }) => response.json({ ok: true })
  Route.post('test/modify-thread-policy/:id', action).middleware(['auth', 'modifyThreadPolicy'])
})

Alright, with our unit test in place, let's go back to our functional test. Delete the authorization check from the ThreadController's destroy and update method.

// delete this

if (thread.user_id !== auth.user.id) {
    return response.forbidden()
}

And as expected, the two tests now fail

1. thread can not be deleted by a user who did not create it
  expected 204 to equal 403
  204 => 403

  2. thread can not be updated by a user who did not create it
  expected 200 to equal 403
  200 => 403

So let's head over to start/routes.js and add the middleware we created to the update and destroy route.

Route.resource('threads', 'ThreadController').only(['store', 'destroy', 'update']).middleware(new Map([
    [['store', 'destroy', 'update'], ['auth']],
    [['destroy', 'update'], ['modifyThreadPolicy']]
]))

While it looks complex, the tests pass again! We will refactor it soon...

Since we already check for the thread's existence in the middleware, we can refactor our ThreadController's destroy method to simply do

async destroy({ params }) {
    await Thread.query().where('id', params.id).delete()
}

And that's all there is for this episode! Next time let's take a look at validation since we are currently able to insert an empty title and body.