Testing Laravel Middleware with HTTP Tests

Published on by

Testing Laravel Middleware with HTTP Tests image

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:

  1. Any HTTP verb other than POST should cause a 403 Forbidden response.
  2. Invalid signatures should create a 403 Forbidden response.
  3. A valid signature should pass and hit the route callable.
  4. 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.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Paragraph

Manage your Laravel app as if it was a CMS – edit any text on any page or in any email without touching Blade or language files.

Visit Paragraph
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
LoadForge logo

LoadForge

Easy, affordable load testing and stress tests for websites, APIs and databases.

LoadForge
Paragraph logo

Paragraph

Manage your Laravel app as if it was a CMS – edit any text on any page or in any email without touching Blade or language files.

Paragraph
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
DocuWriter.ai logo

DocuWriter.ai

Save hours of manually writing Code Documentation, Comments & DocBlocks, Test suites and Refactoring.

DocuWriter.ai
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
Launch your Startup Fast with LaraFast image

Launch your Startup Fast with LaraFast

Read article
Embed Livewire Components on Any Website image

Embed Livewire Components on Any Website

Read article
Statamic announces next Flat Camp retreat (EU edition) image

Statamic announces next Flat Camp retreat (EU edition)

Read article
Laravel Herd releases v1.5.0 with new services. No more Docker, DBNGIN, or even homebrew! image

Laravel Herd releases v1.5.0 with new services. No more Docker, DBNGIN, or even homebrew!

Read article
Resources for Getting Up To Speed with Laravel 11 image

Resources for Getting Up To Speed with Laravel 11

Read article
Laravel 11 is now released! image

Laravel 11 is now released!

Read article