Testing Laravel Middleware with HTTP Tests
Published on by Paul Redmond
In this post, I’d like to demonstrate a practical example of testing a middleware using HTTP tests. Testing at the HTTP level can make your tests more resilient to change and more readable.
On a recent episode of Full Stack Radio (#72) with Adam Wathan and Taylor Otwell, it was refreshing to hear them find a lot of practical value in HTTP testing. I have found HTTP tests to be easier to write and maintain, but did feel like I was Doing Testing Wrong™ somehow or that I was cheating by not mocking and isolating everything. If you haven’t listened to this episode yet, give it a listen, it’s full of good, practical testing advice.
Introduction
Earlier this year I built a middleware for validating and securing Mailgun webhooks on one of my projects and wrote about it in inbound processing of email in Laravel with Mailgun on Laravel News. In summary, I demonstrate how you can validate Mailgun webhooks (to make sure the webhook is actually from Mailgun) with a Laravel middleware while processing inbound email.
At the heart of setting up a Mailgun webhook, it’s advisable to secure your webhooks by validating the signature that’s part of the HTTP POST payload using a provided signature, timestamp, and token from the rquest. Here’s the complete middleware from my post:
<?php namespace App\Http\Middleware; use Closure;use Illuminate\Http\Response; class ValidateMailgunWebhook{ public function handle($request, Closure $next) { if (!$request->isMethod('post')) { abort(Response::HTTP_FORBIDDEN, 'Only POST requests are allowed.'); } if ($this->verify($request)) { return $next($request); } abort(Response::HTTP_FORBIDDEN, 'The webhook signature was invalid.'); } protected function buildSignature($request) { return hash_hmac( 'sha256', sprintf('%s%s', $request->input('timestamp'), $request->input('token')), config('services.mailgun.secret') ); } protected function verify($request) { if (abs(time() - $request->input('timestamp')) > 15) { return false; } return $this->buildSignature($request) === $request->input('signature'); }}
This middleware only accepts POST
requests and compares the incoming signature to the generated signature using the Mailgun secret as the key.
I’ve seen various ways of testing middleware, such as constructing it directly in a unit test, mocking objects as needed, and running the middleware directly. In this post, I am going to show you how to test this middleware with a higher level HTTP test. Your entire stack will run in the test, giving you more confidence that your application works as expected.
A significant benefit for you to understand is that your test is not directly bound to a specific middleware implementation. We can completely refactor the middleware, and not need to change any tests or update mocks to verify that the middleware is working. I believe that you’ll find these tests can be rather robust.
Setting up
Let’s quickly build out a test for the above middleware with a sample Laravel 5.5 project:
$ laravel new middleware-tests # Change to the middleware-tests/ folder$ cd $_ $ php artisan make:middleware ValidateMailgunWebhook
Take the above middleware code and paste it into this middleware file.
Next, add this middleware to the app/Http/Kernel.php
file:
protected $routeMiddleware = [ // ... 'mailgun.webhook' => \App\Http\Middleware\ValidateMailgunWebhook::class,];
Writing the HTTP Tests
We are ready to write some tests against this middleware, and we don’t even have to define any routes in routes/api.php
to test it!
First, let’s create the feature test file:
$ php artisan make:test SecureMailgunWebhookTest
Looking at the Mailgun middleware, here are the things we are going to test to make sure the middleware works as expected:
- Any HTTP verb other than
POST
should cause a403 Forbidden
response. - Invalid signatures should create a
403 Forbidden
response. - A valid signature should pass and hit the route callable.
- An old timestamp should cause a
403 Forbidden
response.
Testing Invalid HTTP Methods
With that introduction out of the way, let’s write the first test and set up our test.
Update the SecureMailgunWebhookTest
file with the following:
<?php namespace Tests\Feature; use Tests\TestCase;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Exception\HttpException; class SecureMailgunWebhookTest extends TestCase{ protected function setUp() { parent::setUp(); config()->set('services.mailgun.secret', 'secret'); \Route::middleware('mailgun.webhook')->any('/_test/webhook', function () { return 'OK'; }); } /** @test */ public function it_forbids_non_post_methods() { $this->withoutExceptionHandling(); $exceptionCount = 0; $httpVerbs = ['get', 'put', 'patch', 'delete']; foreach ($httpVerbs as $httpVerb) { try { $response = $this->$httpVerb('/_test/webhook'); } catch (HttpException $e) { $exceptionCount++; $this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode()); $this->assertEquals('Only POST requests are allowed.', $e->getMessage()); } } if (count($httpVerbs) === $exceptionCount) { return; } $this->fail('Expected a 403 forbidden'); }}
In the setUp()
method, we define a fake Mailgun secret so we can write our tests against it and then define a catch-all route with the any()
route method. Our route will allow us to use a fake test route to make HTTP requests using the middleware.
Laravel 5.5 introduced the withoutExceptionHandling()
method which means that we’ll get a thrown exception in our test instead of an HTTP response representing the exception.
The try/catch will ensure that we capture the HttpException
for each HTTP verb, and then increment a caught exception counter. If the number of caught exceptions matches the count of HTTP verbs tested, the test passes. Otherwise, the $this->fail()
method is called if our requests don’t cause an exception.
I like the approach of catching and asserting exceptions more than using annotations. It feels clearer to me, and I can also make assertions on the exception to make sure the exception is what I expected.
You can run the Middleware feature test directly with the following PhpUnit command:
# Run all tests in the file$ ./vendor/bin/phpunit tests/Feature/SecureMailgunWebhookTest.php # Filter a specific method$ ./vendor/bin/phpunit \ tests/Feature/SecureMailgunWebhookTest.php \ --filter=it_forbids_non_post_methods
Testing an Invalid Signature
The next test verifies that an invalid signature causes a 403 Forbidden error
. This test is different than the first test, in that it uses the POST
method, but sends invalid request data:
/** @test */public function it_aborts_with_an_invalid_signature(){ $this->withoutExceptionHandling(); try { $this->post('/_test/webhook', [ 'timestamp' => abs(time() - 100), 'token' => 'invalid-token', 'signature' => 'invalid-signature', ]); } catch (HttpException $e) { $this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode()); $this->assertEquals('The webhook signature was invalid.', $e->getMessage()); return; } $this->fail('Expected the webhook signature to be invalid.');}
We pass fake data that will cause an invalid signature and then assert that the right response status and message are set in the HttpException
.
Testing a Valid Signature
When a webhook sends a valid signature the route will process the response without the middleware aborting. The middleware calls verify()
and then calls $next()
if the signatures match:
if ($this->verify($request)) { return $next($request);}
To write this test, we need to send a valid signature, timestamp, and token. We will build our version of the SHA-256 hash in the test class, which is almost a replica of the same method in the middleware. The middleware and our test will both use the services.mailgun.secret
key we configured in the setUp()
method:
/** @test */public function it_passes_with_a_valid_signature(){ $this->withoutExceptionHandling(); $timestamp = time(); $token = 'token'; $response = $this->post('/_test/webhook', [ 'timestamp' => $timestamp, 'token' => $token, 'signature' => $this->buildSignature($timestamp, $token), ]); $this->assertEquals('OK', $response->getContent());} protected function buildSignature($timestamp, $token){ return hash_hmac( 'sha256', sprintf('%s%s', $timestamp, $token), config('services.mailgun.secret') );}
Our test builds the signature using the same code from the middleware so we can generate a valid signature that our middleware expects. At the end of the test, we assert the response content returned equals “OK,” which is what we returned in our test route.
Test Failing With an Old Timestamp
Another precaution our middleware takes is not allowing requests to proceed if the timestamp
valid is stale. The test is similar to our other tests asserting for failure, but this time we make everything valid (the signature and token) except for the timestamp:
/** @test */public function it_fails_with_an_old_timestamp(){ try { $this->withoutExceptionHandling(); $timestamp = abs(time() - 16); $token = 'token'; $response = $this->post('/_test/webhook', [ 'timestamp' => $timestamp, 'token' => $token, 'signature' => $this->buildSignature($timestamp, $token), ]); } catch (HttpException $e) { $this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode()); $this->assertEquals('The webhook signature was invalid.', $e->getMessage()); return; } $this->fail('The timestamp should have failed verification.');}
Pay close attention to the $timestamp = abs(time() - 16);
which will make the Middleware’s timestamp comparison invalid.
Learn More
That was a pretty quick whirlwind tour of testing a middleware at the HTTP level. I prefer this level of testing because using mocks on a middleware can be tedious and bound to a particular implementation. If I choose to refactor later, it’s likely that my tests will need to be rewritten to match the new middleware. With an HTTP test, I am free to refactor the middleware and should expect the same outcome.
Writing HTTP tests in Laravel is so easy and convenient, and I find myself doing more testing at this level. I believe the tests I’ve written are easy to understand because we don’t mock anything. You should familiarize yourself with the assertions available to test suite through Laravel. These tools make your testing job easier, and I would daresay more fun.
If you are new to testing, we’ve also reviewed Test Driven Laravel here at Laravel News. I have personally gone through this course; it’s an excellent resource if you are just getting started in testing web applications.