4,000 emails/month for free | Mailtrap sends real emails now!

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
Bacancy

Outsource a dedicated Laravel developer for $3,200/month. With over a decade of experience in Laravel development, we deliver fast, high-quality, and cost-effective solutions at affordable rates.

Visit Bacancy
Curotec logo

Curotec

World class Laravel experts with GenAI dev skills. LATAM-based, embedded engineers that ship fast, communicate clearly, and elevate your product. No bloat, no BS.

Curotec
Bacancy logo

Bacancy

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

Bacancy
Tinkerwell logo

Tinkerwell

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

Tinkerwell
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

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

Laravel Cloud
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
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
Clawdbot Rebrands to Moltbot After Trademark Request From Anthropic image

Clawdbot Rebrands to Moltbot After Trademark Request From Anthropic

Read article
Automate Laravel Herd Worktrees with This Claude Code Skill image

Automate Laravel Herd Worktrees with This Claude Code Skill

Read article
Laravel Boost v2.0 Released with Skills Support image

Laravel Boost v2.0 Released with Skills Support

Read article
Laravel Debugbar v4.0.0 is released image

Laravel Debugbar v4.0.0 is released

Read article
Radiance: Generate Deterministic Mesh Gradient Avatars in PHP image

Radiance: Generate Deterministic Mesh Gradient Avatars in PHP

Read article
Speeding Up Laravel News With Cloudflare image

Speeding Up Laravel News With Cloudflare

Read article