★ Pragmatically testing multi-guard authentication in Laravel

Last week our team launched Mailcoach, a self-hosted solution to send out email campaigns and newsletters. Rather than being the end, laughing something is the beginning of a journey. Users start encountering bugs and ask for features that weren’t considered before.

One of those features requests we got, is the ability the set the guard to be used when checking if somebody is allowed to access the Mailcoach UI.

In this blog post, I’d like to show you how we implemented and tested this.

Implementing a setting to specify a guard

In a Laravel app, a guard defines how users are authenticated for each request. Some apps, like the one from Lee Overy, who opened that issue, need multiple ways to authenticate. Luckily Laravel offers support for multiple guards.

In the initial release of Mailcoach, we just used the default guard. To offer multi guard support, we added a config value to our the mailcoach.php config file.

/*
 *  This configuration option defines the authentication guard that will
 *  be used to protect the Mailcoach UI. This option should match one
 *  of the authentication guards defined in the "auth" config file.
 */
'guard' => env('MAILCOACH_GUARD', null),

Next, we created a new Authenticate middleware.

<?php

namespace SpatieMailcoachHttpAppMiddleware;

use Closure;
use IlluminateAuthAuthenticationException;
use IlluminateAuthMiddlewareAuthenticate as BaseAuthenticationMiddleware;

class Authenticate extends BaseAuthenticationMiddleware
{
    /**
     * @param  IlluminateHttpRequest  $request
     * @param  Closure  $next
     * @param  string[]  ...$guards
     * @return mixed
     *
     * @throws IlluminateAuthAuthenticationException
     */
    public function handle($request, Closure $next, ...$guards)
    {
        try {
            $guard = config('mailcoach.guard');

            if (! empty($guard)) {
                $guards[] = $guard;
            }

            return parent::handle($request, $next, ...$guards);
        } catch (AuthenticationException $e) {
            throw new AuthenticationException('Unauthenticated.', $e->guards());
        }
    }
}

The code above is mostly taken from Nova. In this middleware, we add the guard name that was used in the mailcoach.php config file to the array of guards to check.

The last thing we needed to do was use this middleware on all Mailcoach routes. This is done in the MailcoachServiceProvider:

// in MailcoachServiceProvider.php

protected function bootRoutes()
{
    Route::macro('mailcoach', function (string $url = '') {
        Route::get($url, HomeController::class)->name('mailcoach.home');

        Route::prefix($url)->group(function () {
            Route::prefix('')->group(__DIR__ . '/../routes/mailcoach-api.php');
            Route::middleware([
                'web',
                Authenticate::class,
                Authorize::class,
                SetMailcoachDefaults::class,
            ])->group(__DIR__ . '/../routes/mailcoach-ui.php');
        });
    });

    return $this;
}

And with that, the feature is completed.

Testing the Authenticate middleware

Because this added functionality concerns security, I most certainly wanted to have tests around this feature. A first test proves that the normal behavior, without using a custom guard, works.

/** @test */
public function when_not_authenticated_it_redirects_to_the_login_route()
{
    $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
}

By default, Mailcoach will redirect unauthenticated users to a route called login. The test above proves that that works. To make sure that the test actually works, I needed to set up some stuff in the setup method.

public function setUp(): void
{
    parent::setUp();

    Route::get('login')->name('login');

    $this->withExceptionHandling();
}

Adding that login route is necessary because, by default, Mailcoach itself doesn’t have a login route. We assume that the application you install Mailcoach will have that. So, in our tests, we need to set that up. That withExceptionHandling is necessary because otherwise, the test will blow up with an AuthenticationException.

Let’s look at a second test. This one makes sure that when you are logged in, you can view the campaign screen of the mailcoach UI.

/** @test */
public function when_authenticated_it_can_view_the_mailcoach_ui()
{
    $this->authenticate();

    $this->get(route('mailcoach.campaigns'))->assertSuccessful();
}

This is what that authenticate method looks like.

public function authenticate(string $guard = null)
{
    $user = factory(User::class)->create();

    $this->actingAs($user, $guard);
}

We instantiate a new user and make it the logged in user with actingAs. The $guard in the test above will be null, meaning the default guard, called web will be used. (This default guard is set up in the auth.php config file.

Up until now, we’ve only tested the default behavior and nothing about the code we added. I still like having those tests, to be 100% sure that our new feature doesn’t mess with the default behavior.

Let’s take a look at our third test.

/** @test */
public function it_will_redirect_to_the_login_page_when_authenticated_with_the_wrong_guard()
{
    config()->set('mailcoach.guard', 'api');

    $this->authenticate('web');

    $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
}

In this test, we make sure that, if you’re logged in with the wrong guard, you get redirected. First, we set the guard that mailcoach should use to api (api is also one of the guards that’s being set up by default in a regular Laravel app). We use web to authenticate, and because that doesn’t match with the guard that mailcoach uses, it will redirect to the login page.

Finally, let’s look a the last test, in which we make sure that if mailcoach uses an alternative guard and you are logged in via that guard, you can see the UI.

/** @test */
public function when_authenticated_with_the_right_guard_it_can_view_the_mailcoach_ui()
{
    config()->set('mailcoach.guard', 'api');

    $this->authenticate('api');

    $this->get(route('mailcoach.campaigns'))->assertSuccessful();
}

And with that, we’re sure that our Authenticate middleware works as expected.

Here’s the full test.

namespace SpatieMailcoachTestsHttpMiddleware;

use IlluminateSupportFacadesRoute;
use SpatieMailcoachTestsTestCase;

class AuthenticateTest extends TestCase
{
    public function setUp(): void
    {
        parent::setUp();

        Route::get('login')->name('login');

        $this->withExceptionHandling();
    }

    /** @test */
    public function when_not_authenticated_it_redirects_to_the_login_route()
    {
        $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
    }

    /** @test */
    public function when_authenticated_it_can_view_the_mailcoach_ui()
    {
        $this->authenticate();

        $this->get(route('mailcoach.campaigns'))->assertSuccessful();
    }

    /** @test */
    public function it_will_redirect_to_the_login_page_when_authenticated_with_the_wrong_guard()
    {
        config()->set('mailcoach.guard', 'api');

        $this->authenticate('web');

        $this->get(route('mailcoach.campaigns'))->assertRedirect(route('login'));
    }

    /** @test */
    public function when_authenticated_with_the_right_guard_it_can_view_the_mailcoach_ui()
    {
        config()->set('mailcoach.guard', 'api');

        $this->authenticate('api');

        $this->get(route('mailcoach.campaigns'))->assertSuccessful();
    }
}

In closing

What I like about these tests is that they are straightforward. Some might think it’s better to test all of this in isolation (so you’d mock a request and let that go through the middleware), but I feel that pragmatic, more feature test like approach gives enough confidence that everything works as intended.

A big thank you to my colleague Alex who helped with figuring out these tests.

Source: Freek Van der Herten

Leave a Reply

Your email address will not be published.


*