Skip to content

Instantly share code, notes, and snippets.

@adamtomat
Last active May 2, 2017 09:13
Show Gist options
  • Save adamtomat/f3d9b770f3d3caf7ed2783b2ae4de9b9 to your computer and use it in GitHub Desktop.
Save adamtomat/f3d9b770f3d3caf7ed2783b2ae4de9b9 to your computer and use it in GitHub Desktop.
Test auth middleware on API routes - Laravel 5.4

When following TDD you shouldn't be writing any application code without writing a test first. This applies to middleware on routes too, however writing a specific test for every route is time consuming.

I decided to write a single test file that knew which endpoints on my API can and cannot be accessed with valid auth credentials.

Here I'm using Passport's Client Credentials to protect endpoints, but you should be able to tweak this file for any auth method.

Data providers

This file only consists of 3 tests:

  • Given a valid access token, can access routes that should be accessible
  • Given a valid access token, cannot access routes that should be inaccessible
  • Given an invalid access token, cannot access any accessible or inaccessible routes

We then use PHPUnit's data providers to run these tests against a bunch of URL's

Setup data before a test

Quite quickly you'll need to run some setup before a test. For example, making sure a user exists in the database before trying to update the user over the API. Our route data providers allow us to pass in a closure as a 3rd parameter which will get run before the test and expects a valid URL to be returned. This URL will overwrite the 2nd parameter.

<?php
namespace Feature\Routing;
use App\User;
use Closure;
use Laravel\Passport\ClientRepository;
use Tests\DatabaseTestCase;
class ApiAuthTest extends DatabaseTestCase
{
/**
* List of all the routes that should allow access with a valid access token
* @return array
*/
public function accessibleRoutesProvider()
{
return [
// A simple route
['GET', '/api/users'],
// Some setup is required, as we need a user in our database before making the request
// (otherwise it'll 404). The 2nd parameter here will get overwritten by the return value of
// the closure in the 3rd parameter. We still provide it for readability, both here and in
// PHPUnit's output
['GET', '/api/users/{userId}', function () {
return '/api/users/'.$this->userId();
}],
];
}
/**
* List of all the routes that should not allow access with a valid access token
* @return array
*/
public function inaccessibleRoutesProvider()
{
return [
['POST', '/api/users'],
['PATCH', '/api/users/{userId}', function () {
return '/api/users/'.$this->userId();
}],
['DELETE', '/api/users/{userId}', function () {
return '/api/users/'.$this->userId();
}],
['GET', '/api/users/{userId}/roles', function () {
return '/api/users/'.$this->userId().'/roles';
}],
];
}
/**
* List of all the routes
* @return array
*/
public function allRoutesProvider()
{
return array_merge(
$this->accessibleRoutesProvider(),
$this->inaccessibleRoutesProvider()
);
}
/**
* Obtain a valid access token from the API and construct the correct header which should pass
* any auth middleware
* @return array
*/
private function getValidHeaders()
{
// We're using Client Credentials as an auth mechanism here (server-to-server)
// Create a new client in the database so we can auth against it
$clientRepository = new ClientRepository;
$client = $clientRepository->create(null, 'Test', 'http://localhost');
$response = $this->json('POST', '/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $client->id,
'client_secret' => $client->secret,
]);
$responseBody = $response->decodeResponseJson();
return ['Authorization' => 'Bearer '.$responseBody['access_token']];
}
/**
* Construct auth headers that should fail any auth middleware
* @return array
*/
public function getInvalidHeaders()
{
return ['Authorization' => 'Bearer invalid-token'];
}
/**
* @test
* @dataProvider accessibleRoutesProvider
*/
function can_make_an_api_call_with_valid_access_token($method, $url, Closure $dynamicUrl = null)
{
$url = $this->parseUrl($url, $dynamicUrl, $dynamicUrl);
$this->response = $this->withExceptionHandling()->json($method, $url, [], $this->getValidHeaders());
// Make sure we get a response that shows we successfully passed authentication
$this->assertContains($this->response->getStatusCode(), [200, 201, 204, 400, 422]);
}
/**
* @test
* @dataProvider inaccessibleRoutesProvider
*/
function cannot_make_an_api_call_with_valid_access_token($method, $url, Closure $dynamicUrl = null)
{
$url = $this->parseUrl($url, $dynamicUrl);
$this->response = $this->withExceptionHandling()->json($method, $url, [], $this->getValidHeaders());
$this->assertUnauthorised();
}
/**
* @test
* @dataProvider allRoutesProvider
*/
function cannot_make_an_api_call_with_invalid_access_token($method, $url, Closure $dynamicUrl = null)
{
$url = $this->parseUrl($url, $dynamicUrl);
$this->response = $this->withExceptionHandling()->json($method, $url, [], $this->getInvalidHeaders());
$this->assertUnauthorised();
}
/**
* Some endpoints need to run some set-up before we can call them,
* for example setting up a product ID before updating that product over the API
* Passing in a $dynamicUrl will overwrite the $url and run it before attempting to
* hit the API.
* @param string $url
* @param Closure|null $dynamicUrl if provided, run and overwrite the $url
* @return string The final URL to call
*/
private function parseUrl($url, $dynamicUrl)
{
return !empty($dynamicUrl) ? $dynamicUrl() : $url;
}
/**
* Helper function to assert that the response was "401 Unauthorized"
*/
private function assertUnauthorised()
{
$this->response->assertStatus(401);
$this->response->assertExactJson([
'errors' => [
[
'code' => 'ERR-401',
'title' => 'AuthenticationException',
'details' => 'Unauthenticated.',
],
],
]);
}
/**
* Create a user in the database and get their ID
* @return integer
*/
private function userId()
{
return factory(User::class)->create()->id;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment