Створення движка для блогу з допомогою Phoenix і Elixir / Частина 2. Авторизація



Від перекладача: «Elixir і Phoenix — прекрасний приклад того, куди рухається сучасна веб-розробка. Вже зараз ці інструменти надають якісний доступ до технологій реального часу для веб-додатків. Сайти з підвищеною інтерактивністю, багатокористувацькі браузерні ігри, микросервисы — ті напрямки, в яких дані технології послужать хорошу службу. Далі представлений переклад серії з 11 статей, докладно описують аспекти розробки на фреймворку Фенікс здавалося б такий тривіальної речі, як блоговый движок. Але не поспішайте кукситься, буде дійсно цікаво, особливо якщо статті спонукають вас звернути увагу на Еліксир або стати його послідовниками.

У цій частині ми доопрацюємо основу для блогу, трохи глибше поринемо в тестування і нарешті додамо авторизацію. Прошу вибачення за невеличку затримку, далі постараюся дотримуватися чіткого розкладу, або йти на випередження!
»

На даний момент наше додаток засноване на:
  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2
  • Comeonin: v2.5.2
Виправляємо деякі баги
Якщо ви слідували першій частині, у вас повинен бути деякою мірою функціонуючий блоговый движок, запущений на Elixir/Phoenix. Якщо ви схожі на мене, то навіть така, здавалося б, невелика частина виконаної роботи захоплює і змушує швидше просуватися далі, викликаючи бажання ще більше відполірувати код.

Якщо ви хочете стежити за ходом роботи, я вилив для вас весь код репозиторій на Github.

Перший баг досить легко відтворити, перейшовши за адресою http://localhost:4000/sessions/new натиснувши кнопку Submit. Ви повинні побачити повідомлення про помилку, подібне на:

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.

Якщо ми поглянемо на функцію create SessionController відразу стане зрозуміло в чому справа.

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

Отже, якщо ми відправимо в параметрах замість username рядок, що містить порожнє значення (або нічого), то отримаємо помилку. Давайте швиденько це поправимо. На щастя, це робиться легко за допомогою охоронних умов (guard clause) і зіставлення із зразком (pattern matching). Замінимо поточну функцію create:

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

Ми замінюємо аргумент params у другій функції create нижнім підкресленням, так як нам не потрібно його ніде використовувати. Ми також посилаємося на функцію failed_login, яку потрібно додати в якості приватної. У файлі web/controllers/session_controller.ex змінимо імпорт Comeonin:

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

Нам потрібно викликати dummy_checkpw() так, щоб ніхто не зміг здійснити атаку за часом простим перебором користувачів. Далі ми додамо функцію failed_login:

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

Знову ж таки, зверніть увагу на виклик dummy_checkpw() вгорі! Ми також очищаємо нашу сесію current_user, встановлюємо flash-повідомлення, яке говорить користувачеві про неправильному введенні логіна і пароля та перенаправляємо назад на головну сторінку. Під кінець викликаємо функцію halt, яка є розумною захистом від проблем подвійного рендера. І потім весь аналогічний код замінюємо викликами нашої нової функції.

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

Ці поправки повинні подбати про всі існуючі дивних баги зі входом, так що ми можемо рухатися далі, щоб зв'язати пости з додають їх користувачами.

Додамо міграцію
Почнемо з додавання в таблицю posts посилання на таблицю users. Для цього через Ecto-генератор створимо міграцію:

$ mix ecto.gen.migration add_user_id_to_posts

Висновок:

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

Якщо ми відкриємо тільки що створений файл, то нічого в ньому не побачимо. Так що додамо в функцію змінити наступний код:

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

Цим ми додамо колонку user_id, що посилається на таблицю користувачів, а також індекс для неї. Виконаємо команду
mix ecto.migrate
і приступимо до редагування наших моделей.

Пов'язуємо пости з користувачами
Давайте відкриємо файл web/models/post.ex і додамо посилання на модель User. Всередину схеми posts розмістимо рядок:

belongs_to :user, Pxblog.User

Нам потрібно додати в модель User зворотний зв'язок, що вказує назад на модель Post. Всередину схеми users у файлі web/models/user.ex розмістимо рядок:

has_many :posts, Pxblog.Post

Нам також потрібно відкрити контролер Posts і безпосередньо пов'язати пости з користувачами.

Змінюємо шляху
Почнемо з оновлення роутера, вказавши пости всередині користувачів. Для цього відкриємо файл web/router.ex і замінимо шляху /users та /posts:

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

Виправляємо контролер
Якщо ми спробуємо виконати команду
mix phoenix.routes
прямо зараз, то отримаємо помилку. Це норма! Так як ми змінили структуру шляхів, то втратили хелпер post_path, нова версія якого називається user_post_path і посилається на вкладений ресурс. Вкладені хелпери дозволяють нам отримувати доступ до шляхів, представленим ресурсами, які вимагають наявність іншого ресурсу (наприклад пости вимагають наявність користувача).

Отже, якщо у нас звичайний хелпер post_path, ми викликаємо його таким способом:

post_path(conn, :show, post)

Об'єкт conn — це об'єкт з'єднання, атом :show — це дія, на яке ми посилаємося, а третій аргумент може ми або моделлю, або ідентифікатором об'єкта. Звідси у нас з'являється можливість робити так:

post_path(conn, :show, 1)

У той же час, якщо у нас вкладений ресурс, хелпери зміняться разом зі зміною нашого файлу routes. У нашому випадку:

user_post_path(conn, :show user, post)

Зверніть увагу, що третій аргумент тепер представляє зовнішній ресурс, а кожен вкладений йде слідом.

Тепер, коли нам стало зрозуміло, чому виникають помилки, ми можемо їх виправити. Нам потрібно мати доступ до запитуваного користувачеві в кожному з дій контролера. Кращий спосіб отримати його — використовувати плаг. Для цього відкриємо файл web/controllers/post_controller.ex і в самому верху додамо виклик нового плага:

plug :assign_user

А напишемо ми його трохи нижче:

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

І потім скрізь замінимо post_path 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

Наводимо в порядок шаблони
Наш контролер перестав «випльовувати» повідомлення про помилку, так що тепер попрацюємо над нашими шаблонами. Ми пішли короткою дорогою, реалізувавши плаг, до якої є доступ з будь-якого дії контролера. Використовуючи функцію assign на об'єкті з'єднання, ми визначаємо змінну, з якої зможемо працювати в шаблоні. Тепер трохи змінимо шаблони, замінивши хелпер post_path на user_post_path і переконавшись, що наступний після назви дії аргумент є ідентифікатором користувача. У файлі web/templates/post/index.html.eex напишемо:

<h2>Listing posts</h2>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Body</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for post <- @posts do %>
<tr>
<td><%= post.title %></td>
<td><%= post.body %></td>
<td class="text-right">
<%= 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" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= link "New post", to: user_post_path(@conn, :new @user) %>

У файлі web/templates/post/show.html.eex:

<h2>Show post</h2>
<ul>
<li>
<strong>Title:</strong>
<%= @post.title %>
</li>
<li>
<strong>Body:</strong>
<%= @post.body %>
</li>
</ul>
<%= link "Edit", to: user_post_path(@conn, :edit @user, @post) %>
<%= link "Back", to: user_post_path(@conn, :index, @user) %>

У файлі web/templates/post/new.html.eex:

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

У файлі web/templates/post/edit.html.eex:

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

Тепер, в якості перевірки працездатності, якщо ми запустимо
mix phoenix.routes
, ми повинні побачити висновок шляхів та успішне поєдання!

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

Підключаємо інші частини до контролера
Тепер, все що нам потрібно — це закінчити роботу над контролером для використання нових асоціацій. Почнемо з запуску інтерактивної консолі командою
iex -S mix
, щоб дізнатися трохи про те, як вибирати пости користувачів. Але перед цим нам потрібно налаштувати список стандартних импортов/аліасів, які будуть завантажуватися кожен раз при завантаженні консолі iex межах нашого проекту. Створіть в корені проекту новий файл .iex.exs (зверніть увагу на крапку на початку імені файлу) і заповніть його наступним вмістом:

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

Тепер, при запуску iex нам не потрібно кожен раз робити нічого подібного:

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

Зараз нам потрібно мати у репозиторії як мінімум одного користувача. Якщо це не так, то додайте його. Потім ми можемо запустити:

iex(8)> user = Repo.get(User, 1)
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" З "users" AS u0 WHERE (u0."id" = $1) [1] OK query=8.2 ms
%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<association :posts is not loaded>,
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" З "posts" AS p0 WHERE (p0."user_id" IN ($1)) [1] OK query=3.5 ms
[]

Поки у нас не створено жодного поста для цього користувача, так що логічно отримати тут порожній список. Ми використовували функцію assoc з Ecto, щоб отримати запит, що зв'язує пости з користувачем. Ми також можемо зробити наступне:

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" З "posts" AS p0 INNER JOIN "users" AS u1 ON u1."id" = p0."user_id" [] OK query=0.9 ms

Тут створюється запит з inner join замість прямого умови на вибірку за ідентифікатором користувача. Зверніть особливу увагу на те, як виглядають запити, які генеруються в обох випадках. Дуже корисно розуміти SQL, створюваний «за лаштунками» завжди, коли ви працюєте з кодом, який генерує запити.
Ми також можемо використовувати функцію preload при вибірці постів, щоб предзагрузить також і користувачів, як показано нижче:

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" З "users" AS u0 [] OK query=0.9 ms
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" З "posts" AS p0 WHERE (p0."user_id" IN ($1)) ORDER BY p0."user_id" [1] OK query=0.8 ms
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" З "posts" AS p0 [] OK query=0.8 ms
[]


Нам потрібно додати постів, щоб з'явилася можливість повозитися з запитами. Отже, для цього ми збираємося використовувати функцію Ecto під назвою build_assoc. Дана функція приймає першим аргументом модель, для якої ми хочемо додати асоціацію, і другим — саму асоціацію у вигляді атома.

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])

І тепер, виконавши останній запит, ми повинні отримати наступний висновок:

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" З "posts" AS p0 [] OK query=0.7 ms
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" З "users" AS u0 WHERE (u0."id" IN ($1)) [1] OK query=0.7 ms
[%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<association :posts is not loaded>,
updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"},
user_id: 1}]

І ми просто швидко перевіримо перший результат:

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<association :posts is not loaded>,
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"

Круто! Наш експеримент показав рівно те, що ми очікували, так що повернемося назад до контролера (файл web/controllers/post_controller.ex) і почнемо правити код. В дії index ми хочемо отримувати всі пости, пов'язані з користувачем. З нього і почнемо:

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

Тепер ми можемо сходити подивитися список постів для першого користувача! Але якщо ми спробуємо отримати список постів для користувача, якого не існує, ми отримаємо повідомлення про помилку, що є поганим UX, так що давайте приведемо в порядок наш плаг assign_user:

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

Тепер, коли ми відкриємо список постів для неіснуючого користувача, ми отримаємо миле flash-повідомлення і будемо люб'язно переадресовані на page_path. Далі нам потрібно змінити дію new:

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

Ми беремо модель user, передаємо її в функцію build_assoc, кажучи, що нам потрібно створити пост, і потім передаємо отриману порожню модель в функцію Post.changeset, щоб отримати порожню ревізію. Ми підемо тим же шляхом для методу create (за винятком додавання 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

І потім змінимо дії show, edit, update та 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

Після прогону всіх тестів ми повинні побачити, що все працює. За винятком того, що… будь-який користувач має можливість видалити/змінити/створити новий пост під будь-яким юзером, яким захоче!

Обмежуємо створення постів користувачами
Ми не можемо випустити блоговый движок з такою дірою в безпеці. Давайте виправимо це, додавши ще один плаг, який гарантує, що отриманий користувач є також поточним користувачем.

Додамо нову функцію в кінець файлу web/controllers/post_controller.ex:

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

А в самому верху додамо виклик плага:

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

Тепер все має прекрасно працювати! Користувачі повинні бути зареєстровані, щоб залишати пости, а потім працювати з ними. Все, що нам залишилося — оновити набір тестів для обробки цих змін, і все буде готово. Для початку просто запустимо mix test, щоб оцінити поточну ситуацію. Швидше за все ви побачите таку помилку:

** (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

На жаль, нам потрібно змінити кожен виклик post_path user_post_path знову. І для того, щоб зробити це, нам потрібно радикально змінити наші тести. Почнемо з додавання блоку налаштувань в файл test/controllers/post_controller_text.exs:

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

Тут відбувається багато всього. Перше, що ми зробили — додали виклик функції create_user, яку нам потрібно написати. Нам потрібно кілька хелперів для тестів, так що давайте їх додамо. Функція create_user просто додає тестового користувача Repo, саме тому ми використовуємо зіставлення зі зразком {:ok, user} при виклику цієї функції.

Далі ми викликаємо conn = build_conn(), як і раніше. Далі передаємо результат conn у функцію login_user. Це з'єднує пости з нашою функцією входу, оскільки всі основні дії з посадами вимагають наявність користувача. Дуже важливо зрозуміти, що нам необхідно повертати conn і тягати його з собою в кожен окремий тест. Якщо ми не зробимо це, то користувач не буде залишатися ввійшли в систему.

Нарешті, ми змінили повернення тієї функції на повернення стандартних значень :ok та :conn, але тепер ми також включимо ще одну запис :user словник. Давайте поглянемо на перший тест, який змінимо:

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, щоб за допомогою зіставлення із зразком отримувати словник, що містить крім ключа :conn, також і ключ :user. Це гарантує, що ми використовуємо ключ :user, з яким ми працюємо в блоці setup. Крім цього ми змінили виклик хелперу post_path user_post_path і додали користувача третім аргументом. Запустимо зараз тільки безпосередньо цей тест. Це можна зробити за допомогою вказівки тега, або вказавши номер потрібної рядки виконавши команду таким чином:

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

Наш тест повинен позеленіти! Чудово! Тепер давайте змінимо цей шматок:

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

Тут нічого нового, крім зміни обробника setup і шляхи, так що йдемо далі.

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

Не забувайте, що ми повинні були отримувати кожен пост, пов'язаний з користувачем, так що змінимо всі виклики 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

Інший злегка змінений тест. Дивитися нема чого, так що давайте перейдемо до наступного більш цікавого. Згадаймо знову, що ми створюємо/отримуємо пости, що належать асоціації користувачів, так що переходимо до зміни тесту «shows chosen resource»:

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

Раніше ми додавали пости з допомогою простого
Repo.insert! %Post{}
. Це більше не буде працювати, так що тепер нам потрібно створювати їх з правильною асоціацією. Так як цей рядок використовується досить часто в останніх тестах, ми напишемо хелпер, щоб полегшити її використання.

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

Даний метод створює валідність модель посту, пов'язану з користувачем, і потім вставляє її в базу даних. Зверніть увагу, що Repo.insert! повертає {:ok, model}, а повертає саму модель!

Повернемося до нашого тесту, який ми змінювали. Я хочу викласти залишок тестів, а ви просто повторіть відповідні зміни одне за іншим, поки всі тести не почнуть проходити.

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

Коли ви поправите їх всі, то зможете запустити команду mix test і отримати зелені тести!

Нарешті, ми написали трохи нового коду, такого як плаги для обробки пошуку користувачів і авторизації, і ми протестиовали успішні випадки досить добре, але нам потрібно додати тести і негативні випадки. Ми почнемо з тесту на те, що станеться, коли ми спробуємо отримати доступ до постів користувача, який не існує.

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

Ми не включили :user зіставлення зі зразком з блоку setup, т. що тут його не використовуємо. Також перевіряємо, що з'єднання закривається в кінці.

І нарешті, нам потрібно написати тест, в якому ми спробуємо редагувати чужий пост.

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

Ми створюємо іншого користувача, який стане нашим поганим користувачем, і додаємо його в Repo. Потім ми пробуємо отримати доступ до дії edit для поста нашого першого користувача. Це змусить спрацювати негативний випадок нашого плага authorize_user! Збережіть файл, запустіть команду
mix test
і дочекайтеся результатів:

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

Ось так-то! Виконали чимало! Зате тепер у нас є функціональний і більш захищений блог) з постами, тими що створюються під користувачами. І у нас хороше покриття тестами! Настав час перепочити. Ми продовжимо цю серію навчальних матеріалів додаванням ролі адміністратора, коментарів, підтримки Markdown, і нарешті ринемося в канали з живою системою коментування!

Важливе висновок від перекладача
Мною була виконана величезна робота по переведенню як цієї статті, так і переведення всієї серії. Чому я продовжую займатися і зараз. Тому, якщо вам сподобалась сама стаття або починання в популяризації Еліксиру в рунеті, будь ласка, підтримайте статтю плюсами, коментарями та репостами. Це неймовірно важливо як для мене особисто, так і для всього співтовариства Еліксиру в цілому.

Про всі неточності, помилки, поганому перекладі, будь ласка, пишіть особистими повідомленнями, буду оперативно виправляти. Заздалегідь дякую всіх учасників.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.