Testing Laravel Middleware with HTTP Tests
Laravel Tutorials / September 28, 2017

Testing Laravel Middleware with HTTP Tests

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.

This appeared first on Laravel News
Laravel News Partners

Newsletter

Join the weekly newsletter and never miss out on new tips, tutorials, and more.