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 Spatie\Mailcoach\Http\App\Middleware;
use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Middleware\Authenticate as BaseAuthenticationMiddleware;
class Authenticate extends BaseAuthenticationMiddleware
{
/**
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string[] ...$guards
* @return mixed
*
* @throws \Illuminate\Auth\AuthenticationException
*/
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 Spatie\Mailcoach\Tests\Http\Middleware;
use Illuminate\Support\Facades\Route;
use Spatie\Mailcoach\Tests\TestCase;
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.
Top comments (0)