DEV Community

Martin
Martin

Posted on • Updated on

Deduplicating authentication and authorization tests in Elixir and Phoenix using macros.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)