Creating a blog engine using Phoenix and Elixir / Part 2. Authorization

Original author: Brandon Richey
  • Transfer
  • Tutorial


From a translator: “ Elixir and Phoenix are a great example of where modern web development is going. Already, these tools provide quality access to real-time technology for web applications. Sites with increased interactivity, multi-user browser games, microservices are those areas in which these technologies will serve well. The following is a translation of a series of 11 articles that describe in detail aspects of development on the Phoenix framework, it would seem like such a trivial thing as a blog engine. But do not rush to stumble, it will be really interesting, especially if the articles prompt you to pay attention to Elixir or become his followers.

In this part, we will finalize the basis for the blog, dive deeper into testing and finally add authorization. I apologize for the small delay, then I will try to adhere to a clear schedule, or go ahead of schedule!
»

At the moment, our application is based on:

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

We fix some bugs


If you followed the first part, you should have a functioning blog engine running on Elixir / Phoenix to some extent . If you look like me, then even such a seemingly small part of the work done excites and forces you to move forward more quickly, causing you to even more polish the code.

If you want to follow the progress of the work, I uploaded all the code for you to the repository on Github .

The first bug is quite easy to reproduce by going to the address http: // localhost: 4000 / sessions / new and clicking the Submit button . You should see an error message similar to:

nil given for :username, comparison with nil is forbidden as it always evaluates to false. Pass a full query expression and use is_nil/1 instead.

If we look at the create function in the SessionController, it’s immediately clear what’s the matter.

def create(conn, %{"user" => user_params}) do
  user = Repo.get_by(User, username: user_params["username"])
  user
  |> sign_in(user_params["password"], conn)
end

So, if we send in the parameters instead of username a string containing an empty value (or nothing), we get an error. Let's fix it quickly. Fortunately, this is done easily with the help of guard clause and pattern matching . Replace the current create function with the following:

def create(conn, %{"user" => %{"username" => username, "password" => password}})
when not is_nil(username) and not is_nil(password) do
  user = Repo.get_by(User, username: username)
  sign_in(user, password, conn)
end
def create(conn, _) do
  failed_login(conn)
end

We replace the params argument in the second create function with the underscore, since we don’t need to use it anywhere. We also refer to the failed_login function , which must be added as private. In the web / controllers / session_controller.ex file, we will modify the Comeonin import :

import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]

We need to call dummy_checkpw () so that no one can launch a time attack by simply enumerating users. Next we add the failed_login function :

defp failed_login(conn) do
  dummy_checkpw()
  conn
  |> put_session(:current_user, nil)
  |> put_flash(:error, "Invalid username/password combination!")
  |> redirect(to: page_path(conn, :index))
  |> halt()
end

Again, notice the call to dummy_checkpw () at the top! We also clear our current_user session , set a flash message telling the user about the incorrect username and password, and redirect it back to the main page. In the end, we call the halt function , which is a reasonable defense against double-rendering problems. And then we replace all the similar code with calls to our new function.

defp sign_in(user, _password, conn) when is_nil(user) do
  failed_login(conn)
end
defp sign_in(user, password, conn) do
  if checkpw(password, user.password_digest) do
    conn
    |> put_session(:current_user, %{id: user.id, username: user.username})
    |> put_flash(:info, "Sign in successful!")
    |> redirect(to: page_path(conn, :index))
  else
    failed_login(conn)
  end
end

These edits should take care of all existing weird login bugs so that we can move on to associate posts with the users who add them.

Add migration


Let's start by adding links to the users table to the posts table . To do this, through the ecto-generator, create a migration:

$ mix ecto.gen.migration add_user_id_to_posts

Conclusion:

Compiling 1 file (.ex)
* creating priv/repo/migrations
* creating priv/repo/migrations/20160720211140_add_user_id_to_posts.exs

If we open the file we just created, then we won’t see anything in it. So add the following code to the change function :

def change do
  alter table(:posts) do
    add :user_id, references(:users)
  end
  create index(:posts, [:user_id])
end

This will add the user_id column that refers to the user table, as well as the index for it. We will execute the command mix ecto.migrateand proceed to edit our models.

We connect posts with users


Let's open the web / models / post.ex file and add a link to the User model . Inside the posts scheme, place the line:

belongs_to :user, Pxblog.User

We need to add feedback to the User model that points back to the Post model . Inside the users schema, in the web / models / user.ex file, place the line:

has_many :posts, Pxblog.Post

We also need to open the Posts controller and directly associate posts with users.

We change the way


Let's start by updating the router by specifying posts within users. To do this, open the web / router.ex file and replace the paths / users and / posts with:

resources "/users", UserController do
  resources "/posts", PostController
end

We fix the controller


If we try to execute the command mix phoenix.routesright now, we will get an error. This is the norm! Since we changed the structure of the paths, we lost the post_path helper , the new version of which is called user_post_path and refers to the attached resource. Nested helpers allow us to access paths represented by resources that require another resource (such as posts require a user).

So, if we have a regular post_path helper , we call it this way:

post_path(conn, :show, post)

The conn object is the connection object, atom : show is the action we are referring to, and the third argument can be either a model or an object identifier. From here we have the opportunity to do so:

post_path(conn, :show, 1)

At the same time, if we have a nested resource, helpers will change along with changing our routes file. In our case:

user_post_path(conn, :show, user, post)

Note that the third argument now represents an external resource, and each nested one comes next.

Now that we have understood why errors occur, we can fix them. We need to have access to the requested user in each of the controller actions. The best way to get it is to use a plugin. To do this, open the web / controllers / post_controller.ex file and add a new plugin call at the very top:

plug :assign_user

And we will write it a little lower:

defp assign_user(conn, _opts) do
  case conn.params do
    %{"user_id" => user_id} ->
      user = Repo.get(Pxblog.User, user_id)
      assign(conn, :user, user)
    _ ->
      conn
  end
end

And then everywhere we replace post_path with user_post_path :

def create(conn, %{"post" => post_params}) do
 changeset = Post.changeset(%Post{}, post_params)
  case Repo.insert(changeset) do
    {:ok, _post} ->
      conn
      |> put_flash(:info, "Post created successfully.")
      |> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end
def update(conn, %{"id" => id, "post" => post_params}) do
  post = Repo.get!(Post, id)
  changeset = Post.changeset(post, post_params)
  case Repo.update(changeset) do
    {:ok, post} ->
      conn
      |> put_flash(:info, "Post updated successfully.")
      |> redirect(to: user_post_path(conn, :show, conn.assigns[:user], post))
    {:error, changeset} ->
      render(conn, "edit.html", post: post, changeset: changeset)
  end
end
def delete(conn, %{"id" => id}) do
  post = Repo.get!(Post, id)
  # Здесь мы используем delete! (с восклицательным знаком), потому что мы ожидаем
  # что код всегда будет работать (иначе возникнет ошибка).
  Repo.delete!(post)
  conn
  |> put_flash(:info, "Post deleted successfully.")
  |> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
end

Tidy up the templates


Our controller stopped spitting out the error message, so now we’ll work on our templates. We went a short way by implementing a plug-in that can be accessed from any controller action. Using the assign function on the connection object, we define a variable that we can work with in the template. Now let's change the templates a bit, replacing the post_path helper with user_post_path and making sure that the next argument after the action name is the user identifier. In the web / templates / post / index.html.eex file, write:

Listing posts

<%= for post <- @posts do %> <% end %>
TitleBody
<%= post.title %><%= post.body %> <%= link "Show", to: user_post_path(@conn, :show, @user, post), class: "btn btn-default btn-xs" %> <%= link "Edit", to: user_post_path(@conn, :edit, @user, post), class: "btn btn-default btn-xs" %> <%= link "Delete", to: user_post_path(@conn, :delete, @user, post), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>
<%= link "New post", to: user_post_path(@conn, :new, @user) %>

In the web / templates / post / show.html.eex file :

Show post

  • Title: <%= @post.title %>
  • Body: <%= @post.body %>
<%= link "Edit", to: user_post_path(@conn, :edit, @user, @post) %> <%= link "Back", to: user_post_path(@conn, :index, @user) %>

In the web / templates / post / new.html.eex file :

New post

<%= render "form.html", changeset: @changeset, action: user_post_path(@conn, :create, @user) %> <%= link "Back", to: user_post_path(@conn, :index, @user) %>

In the web / templates / post / edit.html.eex file :

Edit post

<%= render "form.html", changeset: @changeset, action: user_post_path(@conn, :update, @user, @post) %> <%= link "Back", to: user_post_path(@conn, :index, @user) %>

Now, as a health check, if we run mix phoenix.routes, we should see the output of the paths and a successful compilation!

Compiling 14 files (.ex)
     page_path  GET     /                               Pxblog.PageController :index
     user_path  GET     /users                          Pxblog.UserController :index
     user_path  GET     /users/:id/edit                 Pxblog.UserController :edit
     user_path  GET     /users/new                      Pxblog.UserController :new
     user_path  GET     /users/:id                      Pxblog.UserController :show
     user_path  POST    /users                          Pxblog.UserController :create
     user_path  PATCH   /users/:id                      Pxblog.UserController :update
                PUT     /users/:id                      Pxblog.UserController :update
     user_path  DELETE  /users/:id                      Pxblog.UserController :delete
user_post_path  GET     /users/:user_id/posts           Pxblog.PostController :index
user_post_path  GET     /users/:user_id/posts/:id/edit  Pxblog.PostController :edit
user_post_path  GET     /users/:user_id/posts/new       Pxblog.PostController :new
user_post_path  GET     /users/:user_id/posts/:id       Pxblog.PostController :show
user_post_path  POST    /users/:user_id/posts           Pxblog.PostController :create
user_post_path  PATCH   /users/:user_id/posts/:id       Pxblog.PostController :update
                PUT     /users/:user_id/posts/:id       Pxblog.PostController :update
user_post_path  DELETE  /users/:user_id/posts/:id       Pxblog.PostController :delete
  session_path  GET     /sessions/new                   Pxblog.SessionController :new
  session_path  POST    /sessions                       Pxblog.SessionController :create
  session_path  DELETE  /sessions/:id                   Pxblog.SessionController :delete

We connect the remaining parts to the controller


Now, all we need to do is finish the work on the controller to use the new associations. Let's start by launching the interactive console with a team iex -S mixto learn a little about how to select user posts. But before that, we need to set up a list of standard imports / aliases that will be loaded every time the iex console loads inside our project. Create a new .iex.exs file in the project root (note the dot at the beginning of the file name) and fill it with the following contents:

import Ecto.Query
alias Pxblog.User
alias Pxblog.Post
alias Pxblog.Repo
import Ecto

Now, when starting iex, we don’t need to do anything like this every time:

iex(1)> import Ecto.Query
nil
iex(2)> alias Pxblog.User
nil
iex(3)> alias Pxblog.Post
nil
iex(4)> alias Pxblog.Repo
nil
iex(5)> import Ecto
nil

Now we need to have at least one user in the repository. If it is not, then add it. Then we can run:

iex(8)> user = Repo.get(User, 1)
    [debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1] OK query=8.2ms
    %Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test", id: 1,
     inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
     password_confirmation: nil,
     password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
     posts: #Ecto.Association.NotLoaded,
     updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"}
iex(10)> Repo.all(assoc(user, :posts))
    [debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 WHERE (p0."user_id" IN ($1)) [1] OK query=3.5ms
    []

So far, we have not created a single post for this user, so it is logical to get an empty list here. We used the assoc function from Ecto to get a request linking posts to the user. We can also do the following:

iex(14)> Repo.all from p in Post,
...(14)>          join: u in assoc(p, :user),
...(14)>          select: p
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 INNER JOIN "users" AS u1 ON u1."id" = p0."user_id" [] OK query=0.9ms

Here a request is created with inner join instead of a direct condition for fetching by user ID. Pay particular attention to what the queries generated in both cases look like. It is very useful to understand the SQL that is created behind the scenes whenever you work with the code that generates the queries.

We can also use the preload function when fetching posts to preload users as well, as shown below:

iex(18)> Repo.all(from u in User, preload: [:posts])
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 [] OK query=0.9ms
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 WHERE (p0."user_id" IN ($1)) ORDER BY p0."user_id" [1] OK query=0.8ms
iex(20)> Repo.all(from p in Post, preload: [:user])
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 [] OK query=0.8ms
[]

We need to add posts so that we can tinker with requests. So, for this we are going to use an Ecto function called build_assoc . This function takes the first argument the model for which we want to add an association, and the second - the association itself in the form of an atom.

iex(1)> user = Repo.get(User, 1)
iex(2)> post = build_assoc(user, :posts, %{title: "Test Title", body: "Test Body"})
iex(3)> Repo.insert(post)
iex(4)> posts = Repo.all(from p in Post, preload: [:user])

And now, having completed the last request, we should get the following output:

iex(4)> posts = Repo.all(from p in Post, preload: [:user])
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 [] OK query=0.7ms
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" IN ($1)) [1] OK query=0.7ms
[%Pxblog.Post{__meta__: #Ecto.Schema.Metadata<:loaded>, body: "Test Body",
  id: 1, inserted_at: #Ecto.DateTime<2015-10-06T18:06:20Z>, title: "Test Title",
  updated_at: #Ecto.DateTime<2015-10-06T18:06:20Z>,
  user: %Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test",
   id: 1, inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
   password_confirmation: nil,
   password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
   posts: #Ecto.Association.NotLoaded,
   updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"},
  user_id: 1}]

And we just quickly check the first result:

iex(5)> post = List.first posts
%Pxblog.Post{__meta__: #Ecto.Schema.Metadata<:loaded>, body: "Test Body", id: 1,
 inserted_at: #Ecto.DateTime<2015-10-06T18:06:20Z>, title: "Test Title",
 updated_at: #Ecto.DateTime<2015-10-06T18:06:20Z>,
 user: %Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test",
  id: 1, inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
  password_confirmation: nil,
  password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
  posts: #Ecto.Association.NotLoaded,
  updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"},
 user_id: 1}
iex(6)> post.title
"Test Title"
iex(7)> post.user.username
"test"

Cool! Our experiment showed exactly what we expected, so go back to the controller (file web / controllers / post_controller.ex ) and start editing the code. In the index action, we want to get all posts related to the user. Let's start with it:

def index(conn, _params) do
  posts = Repo.all(assoc(conn.assigns[:user], :posts))
  render(conn, "index.html", posts: posts)
end

Now we can go see the list of posts for the first user! But if we try to get a list of posts for a user who does not exist, we get an error message that is bad UX, so let's tidy up our assign_user plugin :

defp assign_user(conn, _opts) do
  case conn.params do
    %{"user_id" => user_id} ->
      case Repo.get(Pxblog.User, user_id) do
        nil  -> invalid_user(conn)
        user -> assign(conn, :user, user)
      end
    _ -> invalid_user(conn)
  end
end
defp invalid_user(conn) do
  conn
  |> put_flash(:error, "Invalid user!")
  |> redirect(to: page_path(conn, :index))
  |> halt
end

Now, when we open the list of posts for a non-existent user, we will get a nice flash message and be kindly redirected to page_path . Next we need to change the new action :

def new(conn, _params) do
  changeset =
    conn.assigns[:user]
    |> build_assoc(:posts)
    |> Post.changeset()
  render(conn, "new.html", changeset: changeset)
end

We take the user model , pass it to the build_assoc function , saying that we need to create a post, and then pass the resulting empty model to the Post.changeset function to get an empty revision. We will go the same way for the create method (except for adding post_params ):

def create(conn, %{"post" => post_params}) do
  changeset =
    conn.assigns[:user]
    |> build_assoc(:posts)
    |> Post.changeset(post_params)
  case Repo.insert(changeset) do
    {:ok, _post} ->
      conn
      |> put_flash(:info, "Post created successfully.")
      |> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

And then change the actions of show , edit , update and delete :

def show(conn, %{"id" => id}) do
  post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
  render(conn, "show.html", post: post)
end
def edit(conn, %{"id" => id}) do
  post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
  changeset = Post.changeset(post)
  render(conn, "edit.html", post: post, changeset: changeset)
end
def update(conn, %{"id" => id, "post" => post_params}) do
  post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
  changeset = Post.changeset(post, post_params)
  case Repo.update(changeset) do
    {:ok, post} ->
      conn
      |> put_flash(:info, "Post updated successfully.")
      |> redirect(to: user_post_path(conn, :show, conn.assigns[:user], post))
    {:error, changeset} ->
      render(conn, "edit.html", post: post, changeset: changeset)
  end
end
def delete(conn, %{"id" => id}) do
  post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
  # Здесь мы используем delete! (с восклицательным знаком), потому что мы ожидаем
  # что оно всегда будет работать (иначе возникнет ошибка).
  Repo.delete!(post)
  conn
  |> put_flash(:info, "Post deleted successfully.")
  |> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
end

After running all the tests, we should see that everything works. Except that ... any user has the ability to delete / edit / create a new post under any user he wants!

We restrict the creation of posts by users


We cannot release a blog engine with such a security hole. Let's fix this by adding another plugin that ensures that the received user is also the current user.

Add a new function to the end of the web / controllers / post_controller.ex file :

defp authorize_user(conn, _opts) do
    user = get_session(conn, :current_user)
    if user && Integer.to_string(user.id) == conn.params["user_id"] do
      conn
    else
      conn
      |> put_flash(:error, "You are not authorized to modify that post!")
      |> redirect(to: page_path(conn, :index))
      |> halt()
    end
  end

And at the very top, add a plugin call:

plug :authorize_user when action in [:new, :create, :update, :edit, :delete]

Now everything should work great! Users must be registered in order to post, and then only work with them. All that remains for us is to update the test suite to process these changes, and everything will be ready. To get started, simply run mix test to evaluate the current situation. Most likely you will see this error:

** (CompileError) test/controllers/post_controller_test.exs:14: function post_path/2 undefined
    (stdlib) lists.erl:1337: :lists.foreach/2
    (stdlib) erl_eval.erl:669: :erl_eval.do_apply/6
    (elixir) lib/code.ex:363: Code.require_file/2
    (elixir) lib/kernel/parallel_require.ex:50: anonymous fn/4 in Kernel.ParallelRequire.spawn_requires/5

Unfortunately, we need to change each post_path call to user_post_path again. And in order to do this, we need to radically change our tests. We start by adding a settings block to the test / controllers / post_controller_text.exs file :

alias Pxblog.User
setup do
  {:ok, user} = create_user
  conn = build_conn()
  |> login_user(user)
  {:ok, conn: conn, user: user}
end
defp create_user do
  User.changeset(%User{}, %{email: "test@test.com", username: "test", password: "test", password_confirmation: "test"})
  |> Repo.insert
end
defp login_user(conn, user) do
  post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end

There is a lot going on here. The first thing we did was add a call to the create_user function that we need to write. We need some helpers for the tests, so let's add them. The create_user function simply adds a test user to Repo , which is why we use pattern matching {: ok, user} when calling this function.

Next, we call conn = build_conn () , as before. Next, pass the result of conn to the login_user function . This connects the posts with our login function, because all the basic actions with posts require a user. It is very important to understand that we need to return connand carry it with you in every single test. If we do not do this, the user will not remain logged in.

Finally, we changed the return of that function to the return of the standard values : ok and : conn , but now we will also include another entry : user in the dictionary. Let's take a look at the first test, which we will change:

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

Please note that we have changed the second argument of the test method to use a pattern matching to get a dictionary containing, in addition to the key : conn , also the key : user . This ensures that we use the : user key that we work with in the setup block . In addition, we changed the call to the post_path helper to user_post_path and added the user with the third argument. Now run only this test directly. This can be done by specifying a tag, or by specifying the number of the desired line by executing the command in this way:

$ mix test test/controller/post_controller_test.exs:[line number]

Our test should turn green! Sumptuously! Now let's change this piece:

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

Nothing new here, except changing the setup handler and path, so move on.

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

Do not forget that we had to receive every post associated with the user, so we will change all calls to post_path .

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

Another slightly modified test. There is nothing to watch, so let's move on to the next more interesting one. Recall again that we create / receive posts belonging to user associations, so we move on to changing the “shows chosen resource” test :

test "shows chosen resource", %{conn: conn, user: user} do
  post = build_post(user)
  conn = get conn, user_post_path(conn, :show, user, post)
  assert html_response(conn, 200) =~ "Show post"
end

Previously, we added posts with a simple one Repo.insert! %Post{}. This will not work anymore, so now we need to create them with the correct association. Since this line is used quite often in the remaining tests, we will write a helper to facilitate its use.

defp build_post(user) do
  changeset =
    user
    |> build_assoc(:posts)
    |> Post.changeset(@valid_attrs)
  Repo.insert!(changeset)
end

This method creates a valid post model associated with the user, and then inserts it into the database. Please note that Repo.insert! returns not {: ok, model} , but returns the model itself!

Let's go back to our test, which we changed. I want to lay out the rest of the tests, and you just repeat the corresponding changes one by one until all the tests begin to pass.

test "renders page not found when id is nonexistent", %{conn: conn, user: user} do
    assert_raise Ecto.NoResultsError, fn ->
      get conn, user_post_path(conn, :show, user, -1)
    end
  end
 test "renders form for editing chosen resource", %{conn: conn, user: user} do
    post = build_post(user)
    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} do
    post = build_post(user)
    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} do
    post = build_post(user)
    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} do
    post = build_post(user)
    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

When you fix them all, you can run the mix test command and get green tests!

Finally, we wrote some new code, such as plugins to handle user search and authorization, and we tested successful cases quite well, but we need to add tests for negative cases as well. We will start by testing what happens when we try to access posts from a user who does not exist.

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

We did not include : user in comparison with the sample from the setup block , because we do not use it here. We also check that the connection closes at the end.

And finally, we need to write a test in which we try to edit someone else's post.

test "redirects when trying to edit a post for a different user", %{conn: conn, user: user} do
  other_user = User.changeset(%User{}, %{email: "test2@test.com", username: "test2", password: "test", password_confirmation: "test"})
  |> Repo.insert!
  post = build_post(user)
  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

We create another user who will become our bad user and add him to Repo . Then we try to access the edit action for the post of our first user. This will make the negative case of our authorize_user plugin work ! Save the file, run the command mix testand wait for the results:

.......................................
Finished in 0.4 seconds
39 tests, 0 failures
Randomized with seed 102543

There you go! We have done a lot! But now we have a functional (and more secure blog) with posts created for users. And we still have good test coverage! It's time to take a break. We will continue this series of training materials by adding the role of administrator, comments, Markdown support, and finally break into channels with a live commenting system!

Important conclusion from the translator


I have done a great job of translating both this article and the translation of the entire series. What I continue to do now. Therefore, if you liked the article itself or the endeavors to popularize Elixir in RuNet, please support the article with pluses, comments, and reposts. This is incredibly important both for me personally and for the entire Elixir community as a whole.

Other articles in the series


  1. Introduction
  2. Login
  3. Add Roles
  4. We process roles in controllers
  5. We connect ExMachina
  6. Markdown Support
  7. Add comments
  8. Finish with comments
  9. Channels
  10. Channel testing
  11. Conclusion


About all inaccuracies, errors, poor translation, please write by personal messages, I will promptly correct it. I thank everyone in advance.

Also popular now: