はじめに
Railsチュートリアル10章の内容を、少しでも理解しやすくなるよう、割としっかり目に整理しました。
備忘録です。
前提
Railsチュートリアル1〜9章までの内容が実装されている事。
1.ユーザーの更新をする
1-1.編集フォームを作成する。
●Usersコントローラにeditアクションを作成し、アクション内に処理を記述していく。
➡︎DBから適切なユーザーデータを取得する処理を記述する。
→findメソッドにより送信されたユーザーIDから取得する。
●editアクションに対応するビューを作成する。
➡︎edit.html.erbファイルで処理を記述。
➡︎「/users/<ユーザーのid>/edit」というURLで、リクエストが送信されるように実装を行っていく。
→form_forメソッドを使用し、@userを参照するよう記述する。
→しかし、form_for(@user)という記述は、新規登録フォームと重複するため、パーシャルへ記述することにする。後述。
➡︎エラーメッセージを表示させる。エラーメッセージには、パーシャルを再利用する。後述。
➡︎画像編集リンクを作成する。
→ERb内でgravatar_forメソッドの引数にはeditアクションで取得した@userを渡し、現在の画像を表示させる。
→単純なaタグを用意し、href属性=ユーザーのgravatarリンク、target属性="_blank"に設定、rel属性="noopener"に設定、テキスト=「画像の変更」などにしておけばOK。
【注意点】:target="_blank"を使用して新しいページを開いた場合、HTMLドキュメントのwindowオブジェクトを操作できてしまうため、悪意のあるコンテンツを挿入されてしまうリスクがある。
→rel属性に"noopener"を追加することで、そのリスクを回避している。
➡︎それ以外の名前フィールドやらメアドフィールドやらの入力欄は、新規登録ページの物と変わらないので、パーシャルでまとめる。後述。
●レイアウトに、編集用のリンクを追加する。
➡︎_header.html.erbファイルへ移動し、ドロップダウンメニューリスト内にリンクを追加する。
→edit_user_pathに、ログイン中のユーザーを表すcurrent_userメソッドを渡す。
●パーシャルによるリファクタリング。
➡︎新規登録フォーム&編集フォーム用のパーシャルを作成する。
→views/usersフォルダに、_form.html.erbファイルを作成し、このファイルに新規登録・編集フォーム用のパーシャルを記述していく。
→データを送信するためのform_for(@user)メソッドを記述する。
→renderメソッドで、sharedフォルダの_error_messagesパーシャルを参照する処理を記述する。
→名前、メールアドレス、パスワード、パスワード確認入力欄を記述する。
→submitメソッドでデータ送信ボタンを作成する。
→新規登録と編集とでテキストが異なると思うので、yieldを使って、それぞれのタイトルを代入させるようにする。
➡︎編集ページにパーシャルを記述する。
→provideメソッドで、編集用のタイトルとボタンテキストを記述する。
→renderメソッドで、_form.html.erbファイルのパーシャルを反映させる。
➡︎ついでに新規登録ページにパーシャルを記述する。
→provideメソッドで、新規登録用のタイトルとボタンテキストを記述する。
→renderメソッドで、_form.html.erbファイルのパーシャルを反映させる。
controllers/users_controller.rb#1-1.ユーザーの取得。 def edit @user = User.find(params[:id]) end
views/users/_form.html.erb#1-1.新規登録ページと編集ページのパーシャル <%= form_for(@user, url: signup_path) do |f| %> <%= render "shared/error_messages" %> <%= f.label :name %> <%= f.text_field :name %> <%= f.label :email %> <%= f.email_field :email %> <%= f.label :password %> <%= f.password_field :password %> <%= f.label :password_confirmation, %> <%= f.password_field :password_confirmation, %> <%= f.submit yield(:button_text), %> <% end %>
edit.html.erb#1-1.編集ページ <% provide(:title, "編集") %> <% provide(:button_text, "変更を保存") %> <%= render 'form' %> <%= gravatar_for @user %> <a href="URL" target="_blank">Change</a>
new.html.erb#1-1.新規登録ページ <% provide(:title, "新規登録") %> <% provide(:button_text, "登録") %> <%= render 'form' %>
1-2.編集の失敗
●編集が失敗した場合の機能について実装していく。
●updateアクション作成し、失敗した場合の処理を記述していく。
➡︎送信されてきたデータのidと一致するユーザーを取得し、@userインスタンス変数に代入する処理を記述。
➡︎取得したユーザーが、update_attributes(user_params)メソッドを使用して更新されて、保存されたかどうかを判定する条件式を記述する。
→update_attributesメソッドでuser_paramsを呼び出すことによって、許可している情報以外を弾くようにしている。
→成功した場合の処理は後述。
➡︎保存されなかった場合の記述。
→renderメソッドで、編集フォームへのレンダリング。
●編集失敗時のテスト
➡︎エラーを検知するための統合テスト作成。
→$ rails g integration_test users_edit
➡︎編集失敗時の簡単なテストを追加する。
→setupメソッドを定義し、テスト用のユーザーを取得する記述。
➡︎編集失敗時のテストを記述していく。
→GETリクエストでユーザー編集ページ取得。
→編集ページが表示されているかどうかを検証する記述。
→無効なユーザーデータを、ユーザーパスへPATCHリクエストを送信する処理を記述する。
→レンダリングされ、ユーザー編集ページが表示されるかどうかを検証する記述。
➡︎テストGREEN。
ターミナル#1-2.統合テストファイルの作成 $rails g integration_test users_edit
controllers/users_controller.rb#1-2.編集失敗時の処理。 def update @user = User.find(params[:id]) if @user.update_attributes(user_params) #成功時の処理(後述) else render 'edit' end end
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest #1-2.編集失敗時のテスト test "unsuccessfull edit" do get edit_users_path(@user) assert_template 'users/edit' post edit_users_path(@user), params: {user: {name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar"}} assert_template 'users/edit' end end
1-3.TDDで編集を成功させる
●今度は、編集フォームが動作するようにする。
●テスト駆動開発を使って、正しいテストを記述してから、それに沿って編集機能を実装してみる。
●ユーザー情報を更新すると、成功するテストを記述する。
➡︎ユーザー編集ページのパスヘ、GETリクエストを送信する。
➡︎ユーザー編集ページが表示されているかどうかを検証する記述。
➡︎有効な名前を変数に代入する。
➡︎有効なメールアドレスを変数に代入する。
➡︎有効なユーザーデータを、ユーザーパスへPATCHリクエストを送信する処理を記述をする。
→パスワードは要求しないので、パスワードとパスワード確認は空に設定する。
➡︎フラッシュが空でないことを検証をする。
➡︎ユーザー詳細ページへリダイレクトされているかどうかを検証する。
➡︎reloadメソッドで、テストユーザー用のデータを更新する。
➡︎編集時に送信した名前と、現在のユーザーの名前が一致しているかどうかを検証する。
➡︎編集時に送信したメールアドレスと、現在のユーザーのメールアドレスが一致しているかどうかを検証する。
➡︎RED。
●テストがパスするように、updateアクションを記述してあげる。
➡︎ユーザーのデータが正しく更新され、保存された場合の条件式を記述する。
→フラッシュで、成功した場合のメッセージを表示する。
→ユーザー詳細ページへリダイレクトさせる処理を記述する。
➡︎正しく更新されなかった場合。
→編集ページへレンダリングさせる。
➡︎しかし、このままではRED。
➡︎【理由】パスワード&パスワード確認の入力欄を空に設定していることにより、パスワードの長さに対するバリデーションが発動してしまっているため。
→なので、パスワード&パスワード確認欄が空だった場合の例外処理を、パスワードのバリデーションに加える必要がある。
➡︎【解決策】:allow_nil: trueオプションを使って、入力欄が空の場合に対する例外処理を行う記述をする。
→ちなみに、has_secure_passwordがオブジェクトの生成時に、パスワードの存在性を自動で検証してくれるので、新規ユーザー登録時にこのオプションが発動することはない。
➡︎パスワードが空欄でも正常に動作するようになり、テストスイートはGREENとなる。
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest #1-3.編集成功時(テスト駆動) test "successful edit" do get edit_user_path(@user) assert_template 'users/edit' name = "Foo Bar" email = "user@valid.com" patch user_path(@user), params: {user: {name: name, email: email, password: "", password_confirmation: ""}} assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end
controllers/users_controller.rbdef update @user = User.find(params[:id]) if @user.update_attributes(user_params) #1-3.成功時の処理。 flash[:success] = "編集完了" redirect_to @user else render 'edit' end end
models/user.rbvalidates :password, presence: true, length: { minimum: 6 }, allow_nil: true #1-3.入力欄が空欄の場合の例外処理
2.認可
●どのユーザーでも、あらゆるアクションにアクセスできてしまうため、ユーザーが実行可能な操作を制御する必要がある。
2-1.ユーザーログインを要求する
●ログインしていないユーザーが、保護されたページにアクセスした場合、ログインページに転送させる。
➡︎保護されたページへアクセスした場合に転送させる場合には、Usersコントローラ内でbeforeフィルター使用してあげればOK。
→beforeフィルターは、before_actionメソッドに対しメソッドを適用させて使用するのだが、何かしらの処理が実行される前に適用させたメソッドを実行させることができる。
➡︎beforeフィルターに適用させるメソッドとして、ユーザーにログインを要求するためのメソッドを定義する。
→Usersコントローラ内で、privateキーワード内に記述する。
→メソッド名をlogged_in_userメソッドとする。
→ユーザーがログイン済みかどうかを判定する条件式を記述する。
→ユーザーがログインしていない場合は、フラッシュメッセージを表示させ、ルートURLへリダイレクトさせる。
➡︎beforeフィルターに、logged_in_userメソッドを適用させ、ユーザーにログインを要求させる。
→Usersコントローラ内に記述する。
→before_actionメソッドに、logged_in_userメソッドを適用させる。
→これにより、Usersコントローラ内で実行される全てのアクションが実行される前に、logged_in_userメソッドが実行されるようになった。
→:onlyオプションを付与し、引数に[:edit, :update]という風に渡してあげると、指定したアクションに対してbeforeフィルターを適用することができるので、そうしましょ。
●ログインを要求する機能を実装したため、 editアクションとupdateアクションのテストにて、テスト用のユーザーにログインさせる必要が出たので、ログインさせる。
➡︎編集成功時、編集失敗時両方のテストに、ログイン用のメソッドを使って、ユーザーをログインさせる処理を記述する。
→log_in_as(@user)
➡︎テストスイートGREEN。
➡︎次に、beforeフィルターがしっかり適用されていることを検証するテストを記述していく。
●editとupdateアクションが保護されていることを検証するテストを記述する。
➡︎ログインしていないユーザーが、編集ページへ移動しようとした場合のテスト。
→ユーザー編集ページのパスへGETリクエストを送信する記述をする。
→フラッシュが表示されることを検証する記述をする。
→ログインページへリダイレクトされているかどうかを検証する記述をする。
➡︎ログインしていないユーザーが、実際に編集を実行しようとした場合のテスト。
→有効なユーザー情報で、ユーザーパスヘPATCHリクエストを送信する記述をする。
→フラッシュが表示されていることを検証する記述をする。
→ログインページへリダイレクトされていることを検証する記述をする。
➡︎テストスイートGREEN。
controllers/users_controllers.rbclass UsersController < ApplicationController #2-1.beforeフィルター。 before_action :logged_in_user, only: [:edit, :update] #省略 private #2-1.ログインを要求するためのメソッドを定義。 def logged_in_user unless logged_in? flash[:danger] = "ログインしてください" redirect_to login_url end end end
integration/users_edit_test.rb#2-1.beforeフィルターにより保護されていることを確認する。 test "unsuccessfull edit" do log_in_as(@user) #テスト用のユーザーをログインさせる。 get edit_user_path(@user) #省略 end test "successful edit" do log_in_as(@user) #テスト用のユーザーをログインさせる。 get edit_user_path(@user) #省略 end
2-2.正しいユーザーを要求する。
●ただユーザーを要求するだけでは不十分であり、 ユーザーが自分の情報だけを編集できるようにもする必要がある。
●セキュリティモデルが正しく実装されていることに確信を持つために、テスト駆動開発で進めていくことにする。
➡︎まず、ユーザーの情報が互いに編集できないことを確認したいので、fixtureファイルにテスト用のユーザーをもう1人作成する必要がある。
→fixtureファイル内に、もう1人ユーザーを追加する処理を記述する。
→ユーザーを定義する際、ちゃんと有効なユーザーを定義してあげないと、エラーになるので気を付けてください。私はここでメールアドレスに大文字を加えていたせいで、エラーになり1時間ほど悩みました。
➡︎Usersコントローラの単体テストへ移動。
→setupメソッド内で、先ほど追加したもう1人のユーザーを取得してあげる。
➡︎ログインしていても自分以外の編集ページへアクセスできないことを確認するテストを記述していく。
→先ほど取得したテストユーザーでログインさせる処理を記述する。
→自分以外のユーザーページのパスヘGETリクエストを送信する記述をする。
→フラッシュが表示されないことを検証する記述をする。
→ルートURLへリダイレクトされることを検証する記述をする。
➡︎ログインしていても自分の情報以外、編集できないことを確認するテストを記述していく。
→先ほど取得したテストユーザーでログインさせる処理を記述する。
→自分以外のユーザー情報を更新するパスヘPATCHリクエストを送信する記述をする。
→フラッシュが表示されないことを検証する記述をする。
→ルートURLへリダイレクトされることを検証する記述をする。
●上記で記述したテストに則り、beforeフィルターを使って編集・更新ページを保護する機能を実装する。
➡︎Usersコントローラへ移動。
➡︎編集・更新ページへアクセスするのに正しいユーザーかどうかを確認するメソッドを定義する。
→privateキーワード内で定義する。
→メソッド名はcorrect_userとする。
→paramsで受け取ったidと一致するユーザーを、@userインスタンス変数で取得する処理を記述する
→@userインスタンス変数で取得したユーザーと、ログインしているユーザーが一致しなかった場合の条件式を記述する。
→一致しなかったら、ルートURLへリダイレクトさせる。
➡︎正しいユーザーかどうかを確認するメソッドを、beforeフィルターを使いedit・updateアクションに適用させる。
→correct_userメソッドで@userインスタンス変数がすでに定義され、これが再利用されるので、edit・updateアクション内の@userインスタンス変数は削除しておく。
➡︎テストスイートGREEN。
●correct_userメソッドをリファクタリング。
➡︎ログイン中のユーザーと取得したユーザーが一致するかどうかを判定する条件式を、渡されたユーザーがログイン済みユーザーであればtrueを返すメソッドに書き換える。
→この論理値で返すメソッドは、correct_userメソッド内で使いたいので、Sessionsヘルパーの中に追加する。
test/fixtures/users.yml#1人目省略 #2-2.テスト用のユーザー追加。 Tanaka: name: Taro Tanaka email: tanaka@example.com password_digest: <%= User.digest('password') %>
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest #2-2.2人目のテスト用ユーザーを取得。 def setup @user = users(:michael) @other_user = users(:Tanaka) end #省略 #2-2.自分以外の編集ページへアクセスできないことをテスト。 test "should redirect edit when logged in as wrong user" do log_in_as(@other_user) get edit_user_path(@user) assert flash.empty? assert_redirected_to root_url end #2-2.自分以外の情報は編集きないことをテスト。 test "should redirect update when logged in as wrong user" do log_in_as(@user) patch user_path(@user), params: {user: {name: @user.name, email: @user.email }} assert flash.empty? assert_redirected_to root_url end end
helpers/sessions_helper.rbmodule SessionsHelper #2-2.渡されたユーザーがログイン済みユーザーであればtrueを返すメソッドに書き換える。 def current_user?(user) user == current_user end end
controllers/users_controller.rbclass UsersController < ApplicationController #2-2.正しいユーザーであることを判定するメソッドをbeforeフィルターへ適用させる。 before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] #省略 #2-2.正しいユーザーかどうかを確認する。 def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless current_user?(@user) end end
2-3.フレンドフォワーディング
●認可機能の実装を完成させるには、あと1つだけ問題が存在していて、保護されたページにアクセスしようした場合、リダイレクト先が全て同じページであるということ。なので、これを解決していく。
●具体的に言うと例えば、ログインしていないユーザーが編集ページへアクセスしよとする ➡︎ そのユーザーがログインする ➡︎ 編集ページへリダイレクトする動作させるようにする。この方がユーザーにとって親切そうである。
➡︎このような機能をフレンドリーフォワーディングと呼んでいる。次はこのような機能を実装していく。
●それではまず、テストから記述していくことにする(簡単なので)。
➡︎integration/users_edit_test.rbファイルへ移動。
➡︎編集ページ用のフレンドフォワーディングテストを記述していく。
→まずは、ユーザーページのパスヘアクセスするために、GETリクエストを送信する記述をする。
→次に、ユーザーをログインさせる記述をする。
→ログインした後、そのユーザーページへリダイレクトされていることを検証する記述をする。
→他の記述は編集用のテストと一緒だが、編集ページへのリダイレクトにより、ログイン後にeditビューのテンプレートが表示されなくなったので、その部分だけ削除してあげる。
➡︎RED。
●次は、テストに沿って実際にフレンドリーフォワーディングの機能を実装していく。
➡︎ユーザーを希望のページに転送したい場合は、リクエストを送信したページを保存しておき、その保存されたページへリダイレクトさせるといった手法を取る必要がある。
➡︎上記の動作を、「リクエスト送信したページを保存しておく動作」と、「保存されたページへリダイレクトさせる動作」の2つに分け、その2つのメソッドを定義していくことにする。
→Sessionsヘルパーで定義する。
➡︎まずは、リクエストを送信したページを保存する機能から記述していく。
→helpers/sessionsa_helper.rbへ移動。
→メソッド名をstore_locationとする。
→GETリクエストが送信されたかどうかの条件式を記述する。
→requestオブジェクトを使うことで、要求されたリクエストの値を取得できる。
→さらに、GETリクエストに限定する。フォームなどを使って転送先のURLが保存される可能性があるため。
→送信された場合、そのURLをsession変数へ保存する処理を記述する。
→requestオブジェクトに対して、original_urlメソッドを使う。そうすると、現在取得しているURLを取得できるので、その取得したURLをsession変数へ代入すれば、URLを保存することができるようになる。
➡︎次に、保存したページへリダイレクトさせる機能を記述していく。
→sessions_helper.rbファイルで。
→メソッド名をredirect_back_or(default)として、デフォルト値を渡す。
→session変数に保存したURLへリダイレクトさせ、保存していない場合はデフォルトで設定したURLへリダイレクトさせる。
→セッションに保存したURLを削除しておく処理もしっかり記述しておく。
→そうしないと次回ログインする際に、この保存されたページへ転送されてしまい、尚且つブラウザを閉じるまでこのループが起こることになる。
→このメソッドは、Sessionsコントローラのcreateアクションに追加し、ログイン成功後にリダイレクトさせるようにする。
➡︎ログインユーザーかどうかを確認するbeforeフィルターに、store_locationメソッドを適用させ、URLが保存されるようにする。
→controllers/users_controller.rbファイルへ移動。
→privateキーワード内に定義されている、logged_in_userメソッド内に、store_locationメソッドを追加する。
→こうすることで、ログインしていないユーザーがeditまたはupdateアクションへアクセスしようとした際に、editページへのURLが保存されるようになる。
→ちなみに、store_locationメソッドでGETリクエストのみを取得するよう定義したので、updateアクションは無視される。
メモ:別のユーザーが、何故かログインしていないユーザー扱いをされ、logged_in_userメソッドが適用されてしまい、correct_userがうまく動作しない。
integration/users_edit_test.rb#2-3.フレンドリーフォワーディングのテスト。 test "successful edit with friendly forwarding" do get edit_user_oath(@user) #まずユーザー編集ページへアクセス。 log_in_as(@user) #次にログイン。 assert_redirected_to edit_user_path(@user) #ユーザー編集ページへリダイレクト。 name = "Foo Bar" email = "user@valid.com" patch user_path(@user), params: {user: {name: name, email: email, password: "", password_confirmation: ""}} assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end
app/helpers/sessions_helper.rb#2-3.保存したURL(もしくはデフォルト値のURL)へリダイレクトさせる。 def redirect_back_or(default) redirect_to(session[:forwarding_url] || default) session.delete(:forwarding_url) end #2-3.GETリクエストを送信したURLを保存する。 def store_location session[:forwarding_url] = request.original_url if request.get? end
controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] #省略 def edit end #省略 private def logged_in_user unless logged_in? store_location flash[:danger] = "ログインしてください" redirect_to login_url end end def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless current_user?(@user) end end
controllers/sessions_controller.rbclass SessionsController < ApplicationController #2-3.ログイン時に、保存されていたURLへ転送する。 def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_back_or user #保存されているURLが存在するならそのURLへ、ないならユーザーのページへ転送する。 else flash.now[:danger] = "失敗" render 'new' end end end
3.全てのユーザーを表示する。
●indexアクションを追加して、全てのユーザーを一覧で表示する。
●データベースにサンプルデータを追加する。
●ページネーションを実装する。
●ユーザーの一覧リンク、ページネーション用のリンクを追加する。
●管理者権限を実装して、管理者であればユーザーを削除できる機能を実装する。
3-1.ユーザーの一覧ページを実装する。
●ユーザーの一覧ページを実装するための、セキュリティモデルについて2つ挙げる。
➡︎ユーザーの詳細ページは、ログインしていなくても見れるようにしておく。
➡︎ユーザーの一覧ページは、ログインしたユーザーにしか見れないようにする。
●まずは、ユーザーの一覧ページへ不正なアクセスがあった場合に、正しくリダイレクトするかどうかを確認するテストから記述していく。
➡︎ログインしていない状態で保護されているページへアクセスしようとした場合、ログインページへリダイレクトさせるテストを定義する。
→users_pathへGETリクエストを送信する処理を記述。
→ログインページへリダイレクトしているかを検証する記述。
➡︎RED。
●上記のテストをパスさせる。
➡︎ユーザーがログインしていない場合には、ユーザーの一覧ページへのアクセスを防ぎ、ログインを要求するようにする。
→beforeフィルターのlogged_in_userにindexアクションを追加する。
➡︎GREEN。
●次は、全てのユーザーを表示させる機能を実装していく。
➡︎まずは全ユーザーデータが格納されたインスタンス変数を、indexアクションに作成する。
→ただし、全てのユーザーを一気に読み出すと、データ量が多い場合に危険なので、あとで修正。
➡︎次にindexビューを作成し、順々に表示していく。
→qpp/views/usersフォルダに、index.html.erbファイルを作成する。
→provideメソッドでタイトル取得。
→ulタグを作成し、取得したユーザーとその画像をliタグで囲むことで表示させる。
→ユーザーの名前をクリックすると、プロフィールページに飛ぶようにリンクも記述する。
→この時、eachメソッドで一つずつユーザーのデータを取得する必要がある。
➡︎ユーザーの一覧ページ用のリンクを作成。
→_header.html.erbファイルへ移動。
→ログインユーザーだけがアクセスできるように、logged_in?内に記述する。
→URLにはusers_pathを与える。
➡︎GREEN。
test/controllers/users_controller_test.rb#3-1.ユーザーの一覧ページへの不正アクセスはリダイレクト行き。 test "should redirect index when not logged in" do get users_path assert_redirected_to login_url end
app/controllers/users_controller.rbclass UsersController < ApplicationController #3-1.indexアクションに、beforeフィルターを適用して不正アクセス防止する。 before_action :logged_in_user, only: [:index, :edit, :update] before_action :correct_user, only: [:edit, :update] #3-1.indexアクションの追加。 def index @user = User.all #ユーザーデータ取得。 end end
app/views/users/index.html.erb#3-1.ユーザーの一覧を表示。 <% provide(:title, 'ユーザーの一覧') %> <ul> <% @users.each do |user| %> <li> <%= gravatar_for user %> <%= link_to user.name, user %> </li> <% end %> </ul>
layouts/_header.html.erb#3-1.ユーザーの一覧ページへのリンク(他のリンクやタグは省略)。 <header> <% if logged_in? %> <li> <%= link_to "Users", users_path %> </li> <% end %> </header>
3-2.データベースに、サンプルユーザーを追加する。
●indexページに、複数のユーザーを表示させたいので、サンプルユーザーを作成する。
➡︎ブラウザで1人1人作成するよりも簡単に作成する方法があるので、それで作成していく。
●実際にいそうなユーザー名の架空のユーザーを作成するgemを使って、ユーザーを作成する。
➡︎Gemfileに移動し、faker gemを追加する。
→Gemfileに、gem 'faker', '1.7.3'を記述する。
→$ bundle installのコマンドを実行する。
→開発環境以外では本来使えないが、今回は本番でも適用させる。
➡︎DB上にサンプルユーザーを生成するための、Railsタスクを追加する。
→dbフォルダにある、seeds.rbというファイルを使う。
➡︎Railsタスクを実行して、サンプルユーザーを追加する。
→一旦DBをリセットするためのコマンドを実行する。
→$ rails db:migrate:reset
→コマンドを実行して、Railsタスクの情報をDBへ反映させる。
→$ rails db:seed
→このコマンドを実行する前に、膨大なデータのやりとりが起こるので、railsサーバを止めてから実行すること。
Gemfilesource 'https://rubygems.org' #3-2.Fake gemの追加。 gem 'rails', '5.1.6' gem 'bcrypt', '3.1.12' gem 'faker', '1.7.3'
ターミナル#3-2.Gemfileに追加したFaker gemをインストール。 $ bundle install
db/seeds#3-2.ユーザーを1人、個別で作成。 User.create!(name: "Example User", email: "user@example.com", password: "123456", password_confirmation: "123456") #3-2.99人のサンプルユーザーを作成。メアドは一意なのでメアドは変える記述をしてあげる。 99.times do |n| name = Faker::Name.name email = "user-#{n+1}@example.com" password = "123456" User.create!(name: name, email: email, password: password, password_confirmation: password) end
ターミナル#3-2.DBリセット。 $ rails db:migrate:reset #3-2.Railsタスク実行。 $ rails db:seed
3-3.ページネーション
●大量のユーザーが作成されたが、それにより大量のユーザーが表示されるようになってしまっているので、ページネーションを使って分割していく。
●ページネーション実装のためのメソッドを準備する。
➡︎Railsには多くのページネーションメソッドが存在するが、最もシンプルで堅牢なwill_paginateメソッドを使うことにする。
➡︎Gemfileに、gem 'will_paginate', '3.1.6'を追加する。
➡︎Bootstrapのページネーションスタイルで実装する必要がある。
→gem 'bootstrap-will_paginate', '1.0.0'を追加する。
➡︎コマンドを実行して、gemをインストールする。
→$ bundle install
●ページネーションを動作させる。
➡︎ページネーションを動作させるためには、ページネーションを動作させてくれという指示をRailsに指示する必要がある。
→<%= will_paginate %>というコードを、ページネーションを動作させたいindexビューに記述すれば、ページネーションが表示される。
→ちなみに、will_paginateメソッドには特殊能力が備わっており、usersビューに記述されている@userオブジェクトを自動的に見つけ出し、他のページにアクセスするためのページネーションリンクを作成してくれる。
➡︎さらに、indexアクションで記述したUser.allを、ページネーションを適用してくれるオブジェクトに書き換えする必要がある。
→peginateメソッドによってUserオブジェクトを作成することで、ページネーションに対応したオブジェクトが生成される。
→要するに、allメソッドをpeginateメソッドに書き換えればいい。
→peginateメソッドは、キーを:page、値をページ番号としたハッシュを引数にとることが必要となる。
→paginateメソッドは、:pageパラメータに基づいて、つまり存在するページ数に応じてDBからひとかたまりのデータを取得する(デフォルトで、1ページ30個)。
→:pageが0の場合は、単に最初のページを返す。
Gemfilesource 'https://rubygems.org' gem 'rails', '5.1.6' gem 'bcrypt', '3.1.12' gem 'faker', '1.7.3' #3-3.will_paginate gemと、bootstrap-will_paginate gemの追加。 gem 'will_paginate' '3.1.6' gem 'bootstrap-will_paginate' '1.0.0'
app/views/users/index.html.erb#3-1.ページネーション追加。上下に記述することでページネーションリンクが上下に配置される。 <% provide(:title, 'ユーザーの一覧') %> <%= will_paginate %> <ul> <% @users.each do |user| %> <li> <%= gravatar_for user %> <%= link_to user.name, user %> </li> <% end %> </ul> <%= will_paginate %>
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update] #3-3.Userオブジェクトをページネーションに対応させる。 def index @users = User.paginate(page: params[:page]) end end
3-4.ユーザーの一覧をテストする
●ログイン、indexページへのアクセス、最初のページにユーザーがいる事、ページネーションのリンクがあることを確認するテストを行っていく。
➡︎最後の2項目に関しては、DBに31人以上のユーザーが必要なので、テスト用にユーザーを30人ほど追加する必要がある。
→fixtures/users.ymlファイルに追加する。
→fixtureでは埋め込みRubyを使えるので、埋め込みRubyを使ってユーザーを30人作成する。
➡︎ユーザーを作成したら、indexページをテストするために、統合テストを生成する。
→$ rails g integration_test users_index
➡︎ページネーションを含めたindexページのテストを記述していく。
→setupメソッドを定義して、テスト用のログインユーザーを用意。
→ページネーションを含めたindexページのテストを定義。
→テスト用のユーザーをログインさせる処理を記述。
→ユーザーリソースへGETリクエストを送信する。
→ユーザー一覧のテンプレートビューが表示されていることを検証する記述をする。
→paginationクラスを持つタグの存在を検証する記述をする。
→ページネーションリンクの存在を検証する記述をする。
→paginateメソッドによるUserオブジェクトを作成をし、、繰り返し処理でユーザーを取得する記述を行う。
→リンク先が、ユーザーのページに指定され、テキストがユーザー名になっていることを検証する記述を行う。
➡︎GREEN
test/fixtures/users.yml#省略 #あとで必要になるので、2人ばかりユーザーを追加する。 lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> #3-4.30人のサンプルユーザーを追加する。 <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> <% end %>
ターミナル#3-4.統合テスト作成。 $ rails g integration_test users_index
test/integration/users_index_test.rbrequire 'test_helper' class UsersIndexTest < ActionDispatch::IntegrationTest #3-4.ページネーションを含めたユーザー一覧ページのテスト。 def setup @user = users(:michael) end test "index including pagination" do log_in_as(@user) get users_path assert_template 'users/index' assert_select 'div.pagination' User.paginate(page: 1).each do |user| assert_select 'a[href=?]', user_path(user), text: user.name end end end
3-5.パーシャルのリファクタリング
●Railsには、コンパクトなビューを作成するためのツールが存在するので、そのツールを使い一覧ページのリファクタリングを行う。
●indexビューに対するリファクタリングを行う。
➡︎各ユーザーの呼び出し(liタグ)をrenderの呼び出しにかえる。
➡︎renderで指定するパーシャルは、Userクラスのuser変数に対して実行する。
→この時Railsは、自動的に_user.html.erbという名前のパーシャルを探しにいくので、app/views/usersフォルダに、そのパーシャルを作成する。
→_user.html.erbに、各ユーザーを表示するパーシャルを記述する。
➡︎each文を丸ごと削除し、render内のパーシャルに@usersと指定する。
→こうすることでRailsは、@usersをUserオブジェクトのリストであると、推測してくれる。
➡︎GREEN。
app/views/users/index.html.erb#3-5.ユーザー一覧ページの最初のリファクタリング <% provide(:title, 'ユーザーの一覧') %> <%= will_paginate %> <ul> <% @users.each do |user| %> <%= render user %> <% end %> </ul> <%= will_paginate %>
app/views/user/_users.html.erb#3-5.各ユーザーを表示させるパーシャル。 <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li>
app/views/users/index.html.erb#3-5.ユーザー一覧ページの完全なリファクタリング。 <% provide(:title, 'ユーザーの一覧') %> <%= will_paginate %> <ul> <%= render @users %> </ul> <%= will_paginate %>
4.ユーザーを削除する。
●まず、ユーザーを削除ができるのは管理者のみとし、権限を持つ管理(admin)ユーザーのクラスを作成する。
●ユーザーを削除するためのリンクを追加する。
●削除するのに必要なdestroyアクションを実装する。
4-1.管理ユーザーを作成する
●特権を持つ管理ユーザーを識別するために、論理値を取るadmin属性をUserモデルに追加する。
➡︎こうすると、自動的に論理値を返すadmin?メソッドも使えるように成る。
➡︎admin?メソッドを使うことで、管理ユーザーの状態をテストできる。
➡︎マイグレーションを実行して、admin属性を追加する。
→$ rails g migration add_admin_to_users admin:boolean
→コマンドでマイグレーションを実行し、usersテーブルにadmin属性を追加する。データ型はboolean型。
→add_column内のadminカラムに、default: falseという引数を追加し、デフォルトでは管理者になれないという処理を記述してあげる。
➡︎コマンドで、マイグレーションの変更を反映。
→$ rails db:migrate
●最初のユーザーだけを、デフォルトで管理者にするよう処理を行う。
➡︎seeds.rb内のサンプルデータを生成するRailsタスクのユーザー1人に、権限を与える。
→1人のユーザーの引数を、admin: trueとする。
➡︎データベースをリセットして、再度データを生成する。
→$ rails db:migrate:reset
→$ rails db:seed
●Strong Parametersを使うことで、セキュリティ上の問題を解消する。
➡︎上記で、初期化ハッシュにadmin: trueを設定することで、そのユーザーを管理者としている。
➡︎しかし、もし、ユーザーが好きなようにWebリクエストの初期化ハッシュをオブジェクトに渡せるようにしてしまうと、セキュリティ上問題がある。
→例えば、第三者がpatch /users/17?admin=1のようなPATCHリクエストを送信した場合、17番目のユーザーを管理者してしまうことができる。
→そうした危険があるため、編集しても良い安全な属性だけを更新できるようにする必要がある。
➡︎Strong Parametersを使って対策を行う。
→user_paramsというメソッド名でメソッドを定義し、paramsハッシュに対しrequireで必須となる属性、permitで許可する属性を追加する。
→このメソッド内で、許可している属性にadminを含めないことで、管理者権限を与えることを防ぐことができる。
●admin属性の変更が禁止されていることをテストする。
➡︎test/controllers/users_controller_test.rbへ移動。
➡︎admin属性の変更が禁止されているテストを定義する。
→@other_userでログインさせる処理を記述する。
→@other_userが、管理者権限を持っていないことを検証する記述をする。
→@other_user自身が、管理者権限を持てるように、admin属性を持たせてPTACHリクエストを送信する記述をする。
→送信した@other_userの情報がDBに保存されても、管理者権限を得られていないことを検証する。
ターミナル#4-1.マイグレーションを追加。 $ rails generate migration add_admin_to_users admin:boolean
db/migrate/[timestamp]_add_admin_to_users.rbclass AddAdminToUsers < ActiveRecord::Migration[5.1] #4-1.usersテーブルにadmin属性を追加する。デフォルト値をfalseにする。 def change add_column :users, :admin, :boolean, default: false end end
ターミナル#4-1.マイグレーションの変更をDBに反映。 $ rails db:migrate
db/seeds.rbUser.create!(name: "Example User", email: "user@example.com", password: "123456", password_confirmation: "123456", admin: true) #管理者を1人追加する。 99.times do |n| name = Faker::Name.name email = "user-#{n+1}@example.com" password = "123456" User.create!(name: name, email: email, password: password, password_confirmation: password) end
ターミナル#DBを一旦リセットしてから、サンプルデータを再度生成する。 $ rails db:migrate $ rails db:seed
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:Tanaka) end #4-1.第三者が管理者権限を持つことが禁止されていることを確認するテスト。 test "should not allow the admin attribute to be edited via the web" do log_in_as(@other_user) assert_not @other_user.admin? patch user_path(@other_user), params: { user: { password: @other_user.password, password_confirmation: @other_user.password, admin: true } } assert_not @other_user.reload.admin? end end
4-2.destroyアクション
●Usersリソース最後の仕上げとして、destroyアクションへのリンクを追加する。
➡︎ユーザーindexページの各ユーザーに、削除用のリンクを追加する。
➡︎︎次に、管理ユーザーへのアクセスを制限する。
→現在のユーザーが管理者の時に限り、削除リンクが表示されるようにする。
●ユーザー削除用リンクの実装。
➡︎_users.html.erbパーシャルに追加。
➡︎ログイン中のユーザーが管理者の場合だけ、リンクが表示されるようにする。
→「current_user.admin?」と「!current_user?(user)」の条件を同時に満たす時の条件式を記述する。
→URLはuserで、DELETEリクエスト発行のためのリンクを生成。
●destroyアクションの追加。
➡︎上記で記述した削除リンクを動作させるために、destroyアクションを記述を行う。
→該当するユーザーを取得し、ActiveRecordのdestroyメソッドを使って削除する処理を記述する。
→取得と削除は、メソッドチェーンによって1行で削除できる。
→︎削除した後、ユーザー一覧ページへリダイレクトされることを検証する記述をする。。
➡︎削除するにはログインしている必要があるので、destroyアクションを、beforeフィルターのlogged_in_userに適用させる。
●1つセキュリティ上の問題点が存在する。
➡︎コマンドラインでDELETEリクエストを直接発行されると、全ユーザーを削除されてしまう危険がある。
➡︎それを防衛するためのアクセス制限を、destroyアクションへ行う必要がある。
➡︎これを実装することで、ようやく管理者だけがユーザーを削除できるようになる。
➡︎ログイン中のユーザーが、管理者かどうかを確認するメソッドを定義する。
→app/controllers/users_controller.rbファイルへ移動。
→メソッド名をadmin_userとする。
→ログイン中のユーザーに管理者権限がなければ、ルートURLへリダイレクトさせる処理を記述する。
➡︎beforeフィルターにadmin_userメソッドを追加し、destroyアクションを適用させる。
app/views/users/_user.html.erb#4-2.ログイン中のユーザーが管理者権限を持つ場合にリンクを表示させる。 <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> <% if current_user.admin? && !current_user?(user) %> <%= link_to "delete", user, method: :delete, data: { confirm: "You sure?" } %> <% end %> </li>
app/controllers/users_controller.rb#4-2.destroyアクションの追加。 class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] def destroy User.find(params[:id]).destroy flash[:success] = "User deleted" redirect_to users_url end end
#4-2.destroyアクションへのアクセス制限を追加する。 class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] before_action :admin_user, only: :destroy private #4-2.ログイン中のユーザーが管理者でなければルートURLへリダイレクトさせる。 def admin_user redirect_to(root_url) unless current_user.admin? end end
4-3.ユーザー削除のテスト。
●まず、ユーザー用fixtureファイルを修正する。
➡︎今いるサンプルユーザーの内1人を、admin: trueとして管理者にする。
●管理者権限の制御をアクション単位でテストする。
➡︎この時、2つのケースをチェックする。
1つ:ログインしていないユーザーであれば、ログイン画面にリダイレクトさせる。
2つ:ログイン済みであっても、管理者でなければ、ホーム画面へリダイレクトさせる。
➡︎test/controllers/users_controller.rbファイルへ移動する。
➡︎ログインしていないユーザーがdestroyアクションへアクセスした場合のテストを定義する。
→ユーザーリソースへ、特定のユーザーを削除するためのDELETEリクエストが送信されても、ユーザー総数に差異がないことを検証する記述を行う。
→ログインページへリダイレクトされることを検証する記述をする。
➡︎ログインはしているが、管理者権限がない場合にdestroyアクションへアクセスした場合のテストを定義する。
→管理者権限のないユーザーでログインする処理を記述。
→ユーザーリソースへ、ユーザーを削除するためのDELETEリクエストが送信されても、ユーザー総数に差異がないことを検証する記述を行う。
→ルートページへリダイレクトされることを検証する記述をする。
●最後に、管理者や一般ユーザー、削除リンクやユーザー削除に対する統合テストを記述していく。
➡︎test/integration/users_index_test.rbファイルへ移動する。
➡︎setupメソッドを定義する。
→テスト用のユーザーとして、管理者権限を持つユーザーを取得する処理を記述。
→同じくテスト用のユーザーとして、管理者権限を持たないユーザーを取得する処理を記述。
➡︎管理者権限を持ったユーザーの、削除リンクとユーザー削除に対するテストを定義する。
→管理者権限を持つユーザーでログインする記述を行う。
→ユーザー一覧ページへGETリクエストを送信する。
→ユーザー一覧ページが表示されていることを検証する記述をする。
→ページネーションセレクタの存在を検証する記述をする。
→「ユーザー一覧の最初のページ」を表す変数を定義し、ページネーション用のユーザーオブジェクトを作成し、この変数に代入する処理を記述。
→変数をeachメソッドを使い、ユーザーを一人一人取得する処理を記述する。
→取得したユーザーに、ユーザープロフィールページへのリンク、とユーザー自身の名前が存在するかどうかを検証する記述をする。
→取得したユーザーが、管理者権限を持っていない場合の条件式を記述する。
→削除リンクセレクタの存在を検証する記述をする。
→管理者権限のないユーザーに対して、DELETEリクエストを送信した場合は、ユーザー総数に-1の差異が生まれることを検証する記述をする。
➡︎管理者権限のないユーザーのユーザー一覧ページでの振る舞いに対するテストを定義する。
→管理者権限のないユーザーでログインする処理を記述。
→ユーザー一覧ページへGETリクエストを送信する。
→削除リンクのカウントが0であることを検証する記述をする。
➡︎3-4で定義したsetupメソッドと、テストそのものはコメントアウトしておく。
→残しておくと、2つ目に定義したsetupメソッドのテスト用ユーザーが優先して適用されることになり、1つ目に定義したsetupメソッドを使うテストで、ユーザーが未定義(undefined)となってエラーになる。
➡︎テストスイートGREEN。
test/fixtures/users.yml#4-3.サンプルユーザーの内の1人へ、管理者権限を与える。 michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> admin: true Tanaka: name: Taro Tanaka email: tanaka@example.com password_digest: <%= User.digest('password') %> lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> <% end %>
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest #4-3.管理者権限の制御に対するテスト。 def setup @user = users(:michael) @other_user = users(:Tanaka) end test "should redirect destroy when not logged in" do assert_no_difference 'User.count' do delete user_path(@user) end assert_redirected_to login_url end test "should redirect destroy when logged in as a non-admin" do log_in_as(@other_user) assert_no_difference 'User.count' do delete user_path(@user) end assert_redirected_to root_url end end
require 'test_helper' class UsersIndexTest < ActionDispatch::IntegrationTest #4-3.管理者や一般ユーザー、削除リンクやユーザー削除に対する統合テスト。 def setup @admin = users(:michael) @non_admin = users(:Tanaka) end test "index as admin including pagination and delete links" do log_in_as(@admin) get users_path assert_template 'users/index' assert_select 'div.pagination' first_page_of_users = User.paginate(page: 1) first_page_of_users.each do |user| assert_select 'a[href=?]', user_path(user), text: user.name unless user == @admin assert_select 'a[href=?]', user_path(user), text: 'delete' end end assert_difference 'User.count', -1 do delete user_path(@non_admin) end end test "index as non-admin" do log_in_as(@non_admin) get users_path assert_select 'a', text: 'delete', count: 0 end end