Hackernoon logoWriting a Blog Engine in Phoenix and Elixir: Part 3, Adding Roles to our Models by@diamondgfx

Writing a Blog Engine in Phoenix and Elixir: Part 3, Adding Roles to our Models

image
Brandon Richey Hacker Noon profile picture

Brandon Richey

software engineer

Latest Update: 07/21/2016

Previous Post in this series

Current Versions

As of the time of writing this, the current versions of our applications are:

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2
  • Comeonin: v2.5.2

If you are reading this and these are not the latest, let me know and I’ll update this tutorial accordingly.

Where We Left Off

When we last left off, we finished associating our Posts with Users, and began the process of properly restricting access to posts unless we had valid users (and were the user the created the post in the first place), but what if we want to have multiple users? We’ll want to keep things in check with sane user rules so that we do not end up in a scenarios where some rogue user goes off and deletes everyone’s account or posts across the board. We’ll solve this with a fairly standard solution: creating roles.

Creating Roles

We’ll start off by running the following command in our terminal:

$ mix phoenix.gen.model Role roles name:string admin:boolean

To which we should expect to see something similar to the following output:

* creating web/models/role.ex
* creating test/models/role_test.exs
* creating priv/repo/migrations/20160721151158_create_role.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate

We’ll follow the script’s advice and run mix ecto.migrate immediately. Assuming our DB is already setup properly, we should see output similar to the following:

Compiling 21 files (.ex)
Generated pxblog app
11:12:04.736 [info]  == Running Pxblog.Repo.Migrations.CreateRole.change/0 forward
11:12:04.736 [info]  create table roles
11:12:04.742 [info]  == Migrated in 0.0s

We’ll also run mix test to sanity check that our new model addition has not disrupted any other tests. If everything is all green, then we’re all set to move on to modifying our User model to have associated an associated Role.

Adding the Roles Associations

The general design I’m following for this particular feature implementation is that each User has one Role, and that each Role has multiple Users, so we’ll modify the web/models/user.ex file to reflect this:

In the schema “users” do section, we’ll add the following line:

belongs_to :role, Pxblog.Role

In this case, we’re going to place the role_id foreign key on the Users table, so we want to say that a User “belongs to” a Role. We’ll also open up web/models/role.ex and change the schema “roles” do section by adding the following line:

has_many :users, Pxblog.User

We’ll then run mix test again, but we should be expecting many failures. We told Ecto that our users table had a relationship to the roles table but we never defined that in our database, so we’re going to have to modify our users table to hold a reference to a role_id.

$ mix ecto.gen.migration add_role_id_to_users
Compiling 5 files (.ex)
* creating priv/repo/migrations
* creating priv/repo/migrations/20160721184919_add_role_id_to_users.exs

Let’s open up the migration file that it created. By default, it’ll contain:

defmodule Pxblog.Repo.Migrations.AddRoleIdToUsers do
use Ecto.Migration
  def change do
end
end

We need to add a few things. The first thing is that we need to alter the users table to add the reference to roles, so we’ll do this with the following code:

alter table(:users) do
add :role_id, references(:roles)
end

And we should add an index to role_id, so we’ll add the following line:

create index(:users, [:role_id])

Finally, we’ll run mix ecto.migrate again, and should see everything migrate successfully! If we run our tests now, everything should be green again! Unfortunately, our tests are not quite perfect. For one thing, we never modified our tests for the Post/User models to make sure that a Post, for example, must have a User defined. Similarly, we don’t want to be able to create a User when they don’t have a Role. In web/models/user.ex, we’ll change the changeset function to read as follows (note the two additions of :role_id):

def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:username, :email, :password, :password_confirmation, :role_id])
|> validate_required([:username, :email, :password, :password_confirmation, :role_id])
|> hash_password
end

Creating a Test Helper

Running our tests now will result in a lot of failures, but that’s okay! We’re going to be doing a lot of work to clean up our tests, and one of the things we’re going to need is some sort of test helper to avoid us having to write the same code over and over. We’re going to create a new file to help us build out these models. Create test/support/test_helper.ex and populate it with the following code:

defmodule Pxblog.TestHelper do
alias Pxblog.Repo
alias Pxblog.User
alias Pxblog.Role
alias Pxblog.Post
  import Ecto, only: [build_assoc: 2]
  def create_role(%{name: name, admin: admin}) do
Role.changeset(%Role{}, %{name: name, admin: admin})
|> Repo.insert
end
  def create_user(role, %{email: email, username: username, password: password, password_confirmation: password_confirmation}) do
role
|> build_assoc(:users)
|> User.changeset(%{email: email, username: username, password: password, password_confirmation: password_confirmation})
|> Repo.insert
end
  def create_post(user, %{title: title, body: body}) do
user
|> build_assoc(:posts)
|> Post.changeset(%{title: title, body: body})
|> Repo.insert
end
end

Let’s talk about what that file is doing before we move on with fixing our tests. The first thing to note is where we’ve placed the file: test/support, where we can place any modules we want to make available to our test suite at large. We’ll still need to alias references to these in each of the files, but that’s okay!

We alias out our Repo, User, Role, and Post modules first so we can access them with shorter syntax and we also import Ecto so we can access the build_assoc method for building associations.

In create_role, we expect a map that includes a role name and whether it’s admin or not. We’re using Repo.insert here which means we’ll be returning the standard {:ok, model} response on successful insertions. Otherwise, it’s just a simple insert of a Role changeset.

In create_user, we take as our first argument the role we want to use and and as the second argument a map of parameters to use to create our user. We start with our role and then pipe that into our build function, creating a User model (since we specified :users as the association), which then gets piped into User.changeset with the parameters mentioned previously. The end result of that gets piped into Repo.insert(), and we’re done!

While being kind of complicated to explain, we end up with super readable and super understandable code. Take a role, build an associated user, prep it for insertion into the database, and then insert it!

For create_post we do the same thing, except with a post and user instead of a user and role!

Fixing Our Tests

We’ll start by fixing up test/models/user_test.exs. The first thing we need to do is add alias Pxblog.TestHelper to the top of our module definition so we can use those handy helpers we created earlier. Next, we’re going to create a setup block before our tests to reuse a role.

setup do
{:ok, role} = TestHelper.create_role(%{name: "user", admin: false})
{:ok, role: role}
end

And then in our first test, we’re going to change it around to pattern match against the role key from our setup block. We’re going to save ourselves a little bit of typing whenever we want this role included, so we’re going to write a helper function and modify the test:

defp valid_attrs(role) do
Map.put(@valid_attrs, :role_id, role.id)
end
test "changeset with valid attributes", %{role: role} do
changeset = User.changeset(%User{}, valid_attrs(role))
assert changeset.valid?
end

To recap, we pattern match on the role key coming from our setup block, and then we’re modifying the valid_attrs key to include a valid role id in our helper method! When we modify this spec and run it, we should now be back to green specs for test/models/user_test.exs!

Next, open up test/controllers/user_controller_test.exs and we’ll use the same lessons to get this file passing again. We’ll add an alias Pxblog.Role statement at the top of our controller file, as well as an alias Pxblog.TestHelper statement, and add setup code to create a role and return that out with conn.

setup do
{:ok, user_role} = TestHelper.create_role(%{name: "user", admin: false})
{:ok, admin_role} = TestHelper.create_role(%{name: "admin", admin: true})
{:ok, conn: build_conn(), user_role: user_role, admin_role: admin_role}
end

We’ll add a helper function called valid_create_attrs that takes in a role as an argument and returns out a new map with role_id set as well.

defp valid_create_attrs(role) do
Map.put(@valid_create_attrs, :role_id, role.id)
end

Finally, we’ll modify our create and update actions to use this new helper and pattern match on the user_role value from our map.

test "creates resource and redirects when data is valid", %{conn: conn, user_role: user_role} do
conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
assert redirected_to(conn) == user_path(conn, :index)
assert Repo.get_by(User, @valid_attrs)
end
test "updates chosen resource and redirects when data is valid", %{conn: conn, user_role: user_role} do
user = Repo.insert! %User{}
conn = put conn, user_path(conn, :update, user), user: valid_create_attrs(user_role)
assert redirected_to(conn) == user_path(conn, :show, user)
assert Repo.get_by(User, @valid_attrs)
end

Our user controller tests should now all be green! Running mix test is still going to give us failures, sadly.

Fixing the Post Controller Tests

In our Posts Controller, we ended up creating a lot of helper functions to facilitate our building posts with users, so we need to modify those helpers and add the concept of roles so that we can create a valid user. We’ll start by adding a reference to Pxblog.Role up at the top of our post controller. In test/controllers/post_controller_test.exs:

alias Pxblog.Role
alias Pxblog.TestHelper

Then we’ll create our setup block, deviating slightly from what we’ve done in previous setup blocks.

setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "[email protected]", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, post} = TestHelper.create_post(user, %{title: "Test Post", body: "Test Body"})
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, role: role, post: post}
end

The first thing we need to do is create a role, and just a standard non-admin role will do just fine here. In the next line, we’ll also create a user using that role. Next, we’ll create a post for the user. We’ve already covered the login piece, so we’ll skip that. Finally, we return out all of the new models we created to allow each test to pattern match as they need to.

And we have one test that we’ll modify as well to get everything green. Our “redirects when trying to edit a post for a different user” test fails because it attempts to create a second user on the fly with no concept of a role. We’ll change it around ever so slightly:

test "redirects when trying to edit a post for a different user", %{conn: conn, user: user, role: role, post: post} do
{:ok, other_user} = TestHelper.create_user(role, %{email: "[email protected]", username: "test2", password: "test", password_confirmation: "test"})
conn = get conn, user_post_path(conn, :edit, other_user, post)
assert get_flash(conn, :error) == "You are not authorized to modify that post!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end

So here we’ve added the role: role bit to our test definition to pattern match on the role key, and changed the other_user creation bit to instead use TestHelper and reference the role we pattern matched on.

We have a quick opportunity to refactor since we helpfully included a post object from our TestHelper as one of the values we can pattern match. Everywhere we previously called build_post we can drop and instead pattern match on our post object. The full file after all of our modifications should be:

defmodule Pxblog.PostControllerTest do
use Pxblog.ConnCase
  alias Pxblog.Post
alias Pxblog.TestHelper
  @valid_attrs %{body: "some content", title: "some content"}
@invalid_attrs %{}
  setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "[email protected]", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, post} = TestHelper.create_post(user, %{title: "Test Post", body: "Test Body"})
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, role: role, post: post}
end
  defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end
  test "lists all entries on index", %{conn: conn, user: user} do
conn = get conn, user_post_path(conn, :index, user)
assert html_response(conn, 200) =~ "Listing posts"
end
  test "renders form for new resources", %{conn: conn, user: user} do
conn = get conn, user_post_path(conn, :new, user)
assert html_response(conn, 200) =~ "New post"
end
  test "creates resource and redirects when data is valid", %{conn: conn, user: user} do
conn = post conn, user_post_path(conn, :create, user), post: @valid_attrs
assert redirected_to(conn) == user_post_path(conn, :index, user)
assert Repo.get_by(assoc(user, :posts), @valid_attrs)
end
  test "does not create resource and renders errors when data is invalid", %{conn: conn, user: user} do
conn = post conn, user_post_path(conn, :create, user), post: @invalid_attrs
assert html_response(conn, 200) =~ "New post"
end
  test "shows chosen resource", %{conn: conn, user: user, post: post} do
conn = get conn, user_post_path(conn, :show, user, post)
assert html_response(conn, 200) =~ "Show post"
end
  test "renders page not found when id is nonexistent", %{conn: conn, user: user} do
assert_error_sent 404, fn ->
get conn, user_post_path(conn, :show, user, -1)
end
end
  test "renders form for editing chosen resource", %{conn: conn, user: user, post: post} do
conn = get conn, user_post_path(conn, :edit, user, post)
assert html_response(conn, 200) =~ "Edit post"
end
  test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user, post: post} do
conn = put conn, user_post_path(conn, :update, user, post), post: @valid_attrs
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Post, @valid_attrs)
end
  test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user, post: post} do
conn = put conn, user_post_path(conn, :update, user, post), post: %{"body" => nil}
assert html_response(conn, 200) =~ "Edit post"
end
  test "deletes chosen resource", %{conn: conn, user: user, post: post} do
conn = delete conn, user_post_path(conn, :delete, user, post)
assert redirected_to(conn) == user_post_path(conn, :index, user)
refute Repo.get(Post, post.id)
end
  test "redirects when the specified user does not exist", %{conn: conn} do
conn = get conn, user_post_path(conn, :index, -1)
assert get_flash(conn, :error) == "Invalid user!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end
  test "redirects when trying to edit a post for a different user", %{conn: conn, role: role, post: post} do
{:ok, other_user} = TestHelper.create_user(role, %{email: "[email protected]", username: "test2", password: "test", password_confirmation: "test"})
conn = get conn, user_post_path(conn, :edit, other_user, post)
assert get_flash(conn, :error) == "You are not authorized to modify that post!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end
end

Fixing the Session Controller Tests

test/controllers/session_controller_test.exs has some failing tests as well since we’ve not updated it to use our TestHelper. We’ll add a aliases at the top and modify the setup block, as we have elsewhere:

defmodule Pxblog.SessionControllerTest do
use Pxblog.ConnCase
  alias Pxblog.User
alias Pxblog.TestHelper
  setup do
{:ok, role} = TestHelper.create_role(%{name: "User", admin: false})
{:ok, _user} = TestHelper.create_user(role, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})
{:ok, conn: build_conn()}
end

This should be sufficient to make these tests pass! Hooray!

Fixing the Rest of Our Tests

We still have two failing tests. Let’s get those green!

1) test current user returns the user in the session (Pxblog.LayoutViewTest)
test/views/layout_view_test.exs:13
Expected truthy, got nil
code: LayoutView.current_user(conn)
stacktrace:
test/views/layout_view_test.exs:15
2) test current user returns nothing if there is no user in the session (Pxblog.LayoutViewTest)
test/views/layout_view_test.exs:18
** (ArgumentError) cannot convert nil to param
stacktrace:
(phoenix) lib/phoenix/param.ex:67: Phoenix.Param.Atom.to_param/1
(pxblog) web/router.ex:1: Pxblog.Router.Helpers.session_path/4
test/views/layout_view_test.exs:20

Open up test/views/layout_view_test.exs, and up at the top we see a User getting created without a Role! In our setup block, we’re also not passing our created User along, so we have to look it up over and over again! Gross! We’re going to refactor the whole file:

defmodule Pxblog.LayoutViewTest do
use Pxblog.ConnCase, async: true
  alias Pxblog.LayoutView
alias Pxblog.TestHelper
  setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "[email protected]", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, conn: build_conn(), user: user}
end
  test "current user returns the user in the session", %{conn: conn, user: user} do
conn = post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
assert LayoutView.current_user(conn)
end
  test "current user returns nothing if there is no user in the session", %{conn: conn, user: user} do
conn = delete conn, session_path(conn, :delete, user)
refute LayoutView.current_user(conn)
end
end

We add an alias for our Role model, create a valid Role, create a valid User with that role, and then return out user with the conn. Finally, in both of our test methods, we pattern match on user so that we can reuse that model. Now, run mix test and…

Everything is green! But we ARE getting a couple of warnings when running our tests (since we made everything so lovely and clean).

test/controllers/post_controller_test.exs:20: warning: function create_user/0 is unused
test/views/layout_view_test.exs:6: warning: unused alias Role
test/views/layout_view_test.exs:5: warning: unused alias User
test/controllers/user_controller_test.exs:5: warning: unused alias Role
test/controllers/post_controller_test.exs:102: warning: variable user is unused
test/controllers/post_controller_test.exs:6: warning: unused alias Role

Just go in to each of those files and remove the offending aliases and functions since we don’t need them anymore!

$ mix test
.........................................
Finished in 0.4 seconds
41 tests, 0 failures
Randomized with seed 588307

Creating an Admin Seed

Eventually we’ll be restricting new user creation to admins only. For us to do so, however, means that we’ll be in a weird catch-22 state where we have no members or admins, thus meaning we cannot create members or admins, and so on. We’ll remedy this by providing a seed for a default admin user. Open up priv/repo/seeds.exs and insert the following code:

alias Pxblog.Repo
alias Pxblog.Role
alias Pxblog.User
role = %Role{}
|> Role.changeset(%{name: "Admin Role", admin: true})
|> Repo.insert!
admin = %User{}
|> User.changeset(%{username: "admin", email: "[email protected]", password: "test", password_confirmation: "test", role_id: role.id})
|> Repo.insert!

And then we’ll run our seed file by invoking the following command:

$ mix run priv/repo/seeds.exs

Coming Up Next

Now that we have our models set up and ready for dealing with our roles and have our tests back to green, we need to start modifying the functionality in our controllers to restrict certain operations unless you’re the appropriate user or an admin. In the next post we’ll explore how best to implement this functionality, how to add a helper module, and of course, we’ll keep our tests green!

Next post in this series

Check out my new book!

Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:

I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!

Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!

Tags

Join Hacker Noon

Create your free account to unlock your custom reading experience.