TDD course with AdonisJs - 5. Middlewares
Published 9/21/2019
This article is part of a series:
1 TDD course with AdonisJs - 1. Let's build a reddit clone
2 TDD course with AdonisJs - 2. Our first test
3 TDD course with AdonisJs - 3. Model factories & DB transactions
4 TDD course with AdonisJs - 4. Using the auth middleware
5 TDD course with AdonisJs - 5. Middlewares
6 TDD course with AdonisJs - 6. Validation
7 TDD course with AdonisJs - 7. Moderators
8 TDD course with AdonisJs - 8. Third party APIs, ioc and custom validators
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!
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.