Deduplicating authentication and authorization tests in Elixir and Phoenix using macros.
Martin Nijboer
Posted on October 19, 2021
Writing the same tests over and over again can be frustrating and error-prone work. Yet that’s what happens when writing authentication and authorization tests for controller actions in Phoenix.
Controller actions like index
, show
, create
, update
, delete
often require authentication and authorization tests, to check whether a User
is allowed to do a certain action
or access a route
. You wouldn’t want a random user editing and hijacking another user’s data, right?
In this post, I’ll present a method to safely deduplicate authentication and authorization tests for Phoenix controller actions, using macros
. We’ll use Elixir’s testing library ExUnit
to test protected controller actions with the Phoenix
framework.
A simple non-problematic example.
Let’s implement an example with two routes
, one requires authentication and the other doesn’t. We need authentication tests for the authenticated route, to ensure non-authenticated users cannot access it.
The router file in /lib/app_web/router.ex
:
defmodule AppWeb.Router do
use AppWeb, :router
# Public routes.
scope "/" do
pipe_through :api
post "/create", UserController, :create
end
# Authenticated routes.
scope "/user/:user_id" do
pipe_through [:api, :authenticated]
post "/update", UserController, :update
end
end
To guarantee that a non-authenticated user cannot access the authenticated route /user/:user_id/update
, we write the following test in /test/app_web/controllers/user_controller_test.exs
:
defmodule AppWeb.UserControllerTest do
use AppWeb.ConnCase
import App.UsersFixtures
describe "update/2" do
setup do
%{user: users_fixture()}
end
# Regular tests.
test "with valid params, updates user", context do
...
end
# The authentication test.
test "user is not authenticated, renders error", %{conn: conn, user: user} do
path = Routes.user_path(conn, :update, user.user_id)
assert conn
|> post(path)
|> json_response(401)
end
end
end
This test will make a post request to the UserController
function update/2
, which is represented in the router as the path /user/:user_id/update
. The test then asserts that the response has a 401
HTTP status code.
This test will pass. We wrote one test for one authenticated route. Easy enough.
The problem.
But what if we have multiple authenticated routes?
The updated router file in /lib/app_web/router.ex
:
defmodule AppWeb.Router do
use AppWeb, :router
# Public routes.
scope "/", AppWeb do
pipe_through :api
post "/create", UserController, :create
end
# Authenticated routes.
scope "/user/:user_id", AppWeb do
pipe_through [:api, :authenticated]
get "/show", UserController, :show
post "/update", UserController, :update
post "/delete", UserController, :delete
post "/posts/create", PostController, :create
# Post author routes.
scope "/posts/:post_id" do
pipe_through :is_post_author
post "/update", PostController, :update
post "/delete", PostController, :delete
end
post "/create", TeamController, :create
# Team member routes.
scope "/teams/:team_id" do
pipe_through :is_team_member
get "/index", TeamController, :index
get "/update", TeamController, :update
get "/delete", TeamController, :delete
end
end
end
Yeah… That’s a lot of authentication tests. Each route
requires almost the exact same authentication test, but each time we change only one variable; the route
.
The new situation.
In the updated example, a User
can own a Post
and, via Team.Member
, a Team
object.
We will now need to write authentication tests for each authenticated route; 10 in this example. Every time we write a new authentication test, it will be near-identical to the previous one.
We will also need to test that User
is not authorized to access Post
when User
does not own Post
, and that User
is not authorized to access Team
when User
is not a Team.Member
. Again, these tests will be near-identical.
Error-prone.
First, writing all these tests is a lot of work. Second, it’s error-prone because you might miss a test, a configuration, forget to update the route, to woefully start copy-pasting… You’ll understand my concerns.
In a typical enterprise production-deployed Phoenix-based application, we can see 100s and sometimes 1000s of authentication and authorization tests. That’s >100 and >1000 possible points of failure.
We need a solution to standardize our test-suite without giving up readability and maintainability.
The solution: Macros.
In short, macros
are special functions that insert a quoted expression into our application code (i.e. code that generates code). The long version of what macros
are, and how they internally work, is a bit more complicated; but you don’t need expert knowledge to follow the rest of this post, because our implementation is relatively straightforward.
You can read more about macros here: https://elixir-lang.org/getting-started/meta/macros.html.
In this post I will create one macro
that generates multiple authentication tests, and implement it in the original UserControllerTest
example.
A macro called AuthenticationTestsMacro.
First, we create a module containing the authentication tests macro in the file /test/support/macros/authentication_tests_macro.ex
.
defmodule AppWeb.AuthenticationTestsMacro do
import ExUnit.Assertions
defmacro test_user_authentication(:post, path) do
# Mark the code as `generated`.
quote generated: true do
# Make `path` available in the test blocks.
@path unquote(path)
# Implement the first test.
test "user is not authenticated, renders error", %{conn: conn} do
assert conn
|> post(path)
|> json_response(401)
end
# Implement the second test.
test "user is banned, render error", %{conn: conn} do
import App.UsersFixtures
import App.Users.SessionsFixtures
alias App.Users
user = user_fixture()
token = session_fixture(user)
Users.ban_user(user, true)
assert conn
|> put_req_header("authorization", token)
|> post(path)
|> json_response(401)
end
end
end
end
Do you recognise the first test inside the macro
? It’s from the original example, where we tested "user is not authenticated, renders error"
inside UserControllerTest
.
I added a second test "user is banned, renders error"
to the macro
. When we call the macro
in a test module, both tests will be executed for the given path
.
Let’s update UserControllerTest.
Now, we implement the macro
in the controller test module /test/app_web/controllers/user_controller.exs
. We can remove any authentication tests that were present; the macro
covers these now.
defmodule AppWeb.UserControllerTest do
use AppWeb.ConnCase
import App.UsersFixtures
# Import macros.
import AppWeb.AuthenticationTestsMacro
describe "update/2" do
setup do
%{user: users_fixture()}
end
# Regular tests.
test "with valid params, updates user", context do
...
end
# Implement the macro.
path = Routes.user_path(@endpoint, :update, "user_id")
test_user_authentication(:post, path)
end
end
We imported the macro
with import AppWeb.AuthenticationTestMacro
.
We generated the path
that needs to be tested for authentication, with path = Routes.user_path(@endpoint, :update, "user_id")
.
Lastly, we call the macro
with test_user_authentication(:post, path)
, where :post
is the HTTP-method we use to request the controller action.
We can now run our tests again. They will pass.
Conclusion.
The result is a more readable, maintainable, and accurate test-suite.
Instead of writing authorization tests for each controller function, we can now implement one or more macros
to test authentication and authorization for us. The macros
will generate the code we need to test the controller functions, based on the arguments we give it.
Do you need 10 authentication tests for 10 different routes? Just implement a macro
and pass it a different route each time.
Do you need 120 authentication and authorization tests to check whether User
is authenticated and that User
is a Member
of Team
? Just create two macros
(one for authentication, and one for team membership) and implement them in each controller action test.
Do you want to check how your test macros
are holding up? Just change a test in a macro
so it fails, and watch your test-suite blow up.
It’s as easy as that. No more duplication.
What do you think? Is this the best way to generate authentication and authorization tests? Do you have an even better method? Let me know in the comments.
You can find a GitHub repo with a working example here: https://github.com/martinthenth/deduplicate-tests-example
Posted on October 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 19, 2021