kuma0319のブログ

webエンジニアになるためのアウトプットブログ

Ruby on Railsチュートリアル第10章

Railsチュートリアルの第10章を進めていきます。

第10章(ユーザーの更新・表示・削除)

ユーザーを更新

RESTアクションのeditアクション、updateアクションを実装する。

editアクション
  • showアクションと一緒で、変数@userにparams[:id]で取り出したidで探したuserを入れる。
  • editアクションのviewは新規ユーザー登録のものとほぼ同一。
  • form_withメソッドは新規ユーザー登録のPOSTアクションと、ユーザー編集のPATCHアクションと同一。Railsはデータベースの既存のユーザーかどうかをnew_record?理論値メソッドで区別し、新規データならPOSTアクション、既存データならPATCHアクションとしている。
  • editの名前付きルーティングはedit_user_path(current_user)で、ログインしているuserを返すcurrent_userメソッドと組み合わせることで表現可能。
target="_barank"の問題

target="_barank"はリンクをクリックした際に新しいタブで開く機能。
リンク先のページがオリジナルなページにアクセス出来る危険性があり、例えばフィッシングサイトのような偽のコンテンツへアクセスする可能性がある(ターゲットブランキング攻撃)。対策として、aタグのrel(relationship)属性に、"noopener"と設定する。

updateアクション

初期のcreateの実装と同様の形態。updateもcreateのときと同様に、意図しない属性値を送り込まれないように、Strong Parametersuser_paramsを使用する。
編集に失敗した場合、既にパーシャルでerror_messageを表示するようにしてあるため、AcrtiveRecordの用意したエラーメッセージが表示される。

[app/controllers/users_controller.rb]
  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      # 更新に成功した場合を扱う
    else
      render 'edit', status: :unprocessable_entity
    end
  end
編集に対するテスト
編集失敗時のテスト

ユーザー情報の更新は名前付きルーティングuser_path(user)でHTTPリクエストのPATCHメソッドでおこなう。

[test/integration/users_edit_test.rb]
  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
    assert_select 'div.alert', "The form contains 4 errors."
  end
編集成功時のテスト

編集成功動作はTDDでおこなう。重要な部分は、@user.reloadでデータベースの最新情報を再読み込みした後の、ユーザー情報がpatchで送信したものと一致していることを確認する動作。

[test/integration/users_edit_test.rb]
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.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

updateアクションに成功時の動作(フラッシュメッセージの表示、users_path(@user)へリダイレクト)を追加する。

allow_nil: trueオプションについて
パスワードのバリデーションにパスワードフィールドが空だった場合の例外処理を加えるallow_nilオプションがある。これを追加することで、editテストのバリデーションエラーを回避出来る。
これとは別にhas_secure_password自体に、オブジェクト生成時のパスワードの存在性の検証があるため、空のパスワードが有効化される訳ではない。
このオプションの追加によって、ユーザー作成時に空のパスワードであった場合に重複するエラーが発生する問題は解消される。

認可

現在の状態だと、editやupdate機能が誰でも使用可能なため、認可されたユーザーのみが操作可能とする必要がある。

beforeフィルター
  • 何らかの処理を実行する前に特定のメソッドを走らせる仕組み。
  • beforeフィルターはコントローラ内のすべてのアクションに定義されるため、特定のアクションに限定する場合はハッシュ形式の:onlyオプションを渡す。
  • before_action :logged_in_user, only: [:edit, :update]のように記述すると、editアクションとupdateアクションのみの直前にloged_in_userメソッドを走らせる。
正しいユーザーを要求する動作

editアクションやupdateアクションはログインしているかつ、自分の情報に対するアクションであるようにする必要がある。
fixtureに2人目のユーザー(other_user)を追加し、other_userがuserの情報を操作しようとした場合にリダイレクトするように実装。ログインはしているため、リダイレクト先はroot_url。
correct_userメソッドをbeforeアクションに加え、editやupdate前に確認させる。

[app/controllers/users_controller.rb]
    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url, status: :see_other) unless @user == current_user
    end

@user == current_userの部分は、理論値を返すcurrent_user?(@user)の形で実装するために、current_user?ヘルパーメソッドを追加する。
user&&userとすることで、存在性の検証(nilだった場合をキャッチ)も兼ねている。

[app/helpers/sessions_helper.rb]
  def current_user?(user)
    user && user == current_user
  end
フレンドリーフォワーディング

ログインが必要なユーザーがログイン後にroot_urlではなく、元いたページに再び飛ばされる方が親切。
フレンドリーフォワーディング機能は、保存されたURLがある場合はそこにリダイレクトし、ない場合はデフォルトのURLへ飛ぶように設定。
リクエストがあったURLをsessionハッシュに格納(GETリクエストの場合のみ)し、ユーザー作成のcerateアクション実行時にこれを参照するようにすればよい。

[app/controllers/sessions_controller.rb]
 def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      forwarding_url = session[:forwarding_url]
      reset_session
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      log_in user
      redirect_to forwarding_url || user

reset_sessionforwarding_urlにセッションURLを格納してから実行することで、転送先URLをセッションから削除している。これをしないと、セッションが閉じるまでこのページにアクセスするため。

すべてのユーザーを表示

indexアクションの実装

indexページはログイン済のユーザーのみ表示し、そうでないユーザーへはログインを促す。

サンプルユーザーの追加

gemのFakerを使用すると、一括でサンプルユーザーを生成可能。
下のコードの場合だと、メインユーザーを一人作成し、99人の追加ユーザーを作成する。

[db/seeds.rb]
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")


99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end
ページネーション
  • 大量のユーザーが存在している場合だと、1ページに大量のユーザーが表示されるため、ユーザー体験ととしてはよくない。
  • gemのwill_pagenateと、bootstrap-will_paginateを追加し、Bootstrapの機能で実現可能。
  • paginate機能はpaginateメソッドで得た結果が必要なため、indexアクションにpaginateメソッドを追加する必要がある。
  • pagiateメソッドはkeyが:pageでvalueがページ番号のハッシュを引数として受け取る。
  • paginationのテストは、paginationクラスを持つdivタグを調べることと、1ページ目にuser.nameのテキストを持ったそのユーザーへのリンク(aタグ)があることを調べる。
パーシャルのリファクタリング

ビューのindexページは、liタグ部分をrender呼出しに変えることでリファクタリングが可能。

[app/views/users/index.html.erb]
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
[app/views/users/index.html.erb]
  <% @users.each do |user| %>
    <%= render user %>
  <% end %>

ここのrenderは普通'layouts/header'のようにファイル名の文字列を指定するが、ここは単にuserとしている。これは、@users.each do |user|で指定しているUserクラスのuserオブジェクトに対して実行しており、これでRails_usersパーシャルを探すことが出来る。
しかも更にこの部分はリファクタリングが可能で、do...endブロックごとrender @usersに置き換えると、@usersにはユーザー一覧が格納されているため、RailsはUserオブジェクトのリストとして列挙してくれるらしい。

<ul class="users">
  <%= render @users %>
</ul>

ユーザーの削除

adminユーザー

特有の権限を持つ管理ユーザー(adminユーザー)は、データモデルにboolean(真偽値)型として追加する。adminのデフォルト値はnilであるが、マイグレーションファイルにdefault: falseを明示的に渡しておく方がよい。
このadmin属性は必ず悪意あるユーザーが変更できないように、Strong parametersのpermitで"許可されていない"ことを確認しておく。

カラム名 データ型
id integer
name string
email string
created_at datetime
updated_at datetime
password_digest string
remember_digest string
admin boolean
destroyアクション

destroyアクションは、①current_userがadminかつ、②表示しているユーザーが自分自身ではない場合にのみ表示させる。
if current_user.admin? && !current_user?(user)がこの①、②に該当。②の !current_user?(user)_user.html.erbパーシャル内にあるため少し分かりにくいが、ここの引数の(user)はeachでリスト化されるユーザーであり、リスト化されたユーザーがcurrent_userである場合はそれはadminユーザー自身であるためdeleteは出来ず、それ以外のユーザーはdelete出来るようにするため !current_user?(user)となる。

[app/views/users/_user.html.erb]
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, data: { "turbo-method": :delete,
                                          turbo_confirm: "You sure?" } %>
  <% end %>

コントローラでdestoyアクションを作成するとユーザー削除が可能となる。destoyアクションはログイン済みである必要があるため、beforeフィルタに加えておく。
更に念押しで、adminユーザーだけがdestroy出来るようにadminであるかどうかを確認する動作もbeforeフィルタで加えておくことが重要。

[app/controllers/users_controller.rb]
    def admin_user
      redirect_to(root_url, status: :see_other) unless current_user.admin?
    end
ユーザー削除のテスト

ログインユーザーでなかったり、adminユーザーでないユーザーに対するdestroyアクションのテストは、assert_not_differenceでユーザー数が変化していないことを確認すればok。
adminユーザーの削除した場合のdestroyアクションとレイアウトに対するテストは少し複雑。

  1. @adminユーザーでログイン。
  2. indexテンプレートやページネーションリンクがあることを確認。
  3. 1ページ目に表示されるユーザーを変数first_page_of_usersに格納。
  4. それらのユーザーに対してdeleteリンクがあることを確認。
  5. ユーザーを一人削除して、ユーザー数が正しく変化(-1)されていることを確認。

ここで、4のdeleteリンクの確認では、unless user == @adminで分岐させて、adminユーザーにはリンクが無く、それ以外のユーザーにのみdeleteリンクがあることを見ている。

[test/integration/users_index_test.rb]
 def setup
    @admin     = users(:michael)
 @non_admin = users(:archer)
  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)
      assert_response :see_other
      assert_redirected_to users_url
    end
  end

第10章ー演習ー

10.1.1

①target="_blank"で新しいページを開くと、古いブラウザでセキュリティ上の小さな問題が生じます。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入されてしまう可能性があります。Gravatarのような著名なサイトではこのような事態はめったに起きないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。リスト 10.2で使ったGravatarの編集ページへのリンクでこの設定を行ってください。
(解答)

<li><%= link_to "Settings", edit_user_path(current_user), rel: "noopener" %></li>

②リスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングし、コードの重複を解消してください。(ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます3 。)
(解答) 解消された。

10.1.2

①編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。
(解答)飛ばします。

10.1.3

①リスト 10.9のテストに1行追加して、エラーメッセージが正しい個数で表示されているかテストしてみましょう。(ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。)
(解答)

[test/integration/users_edit_test.rb]
  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
    assert_select 'div.alert', "The form contains 4 errors."
  end

10.1.4

①実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。
(解答) 飛ばします。

②Gravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更すると、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームでメールアドレスを変更して確認してみましょう。
(解答) 飛ばします。

10.2.1

①デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまい、結果としてテストも失敗するはずです。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。
(解答) コメントアウトしたらテストREDとなった。

10.2.2

①editアクションとupdateアクションを両方とも保護する必要がある理由は何でしょうか?考えてみてください。
(解答) updateアクションは当然第三者からの更新を防ぐためで、editアクションはGETリクエストのため更新はされないが、そもそも第三者がアクセス可能となるページとはすべきでないため。

②上記のアクションのうち、どちらがブラウザで簡単にテストできますか?
(解答) editアクション。別のユーザーでアドレスバーにeditのパスを入力するだけだから。

10.2.3

①フレンドリーフォワーディングで、渡されたURLに転送されるのが初回のみであることを、テストを書いて確認してみましょう。次回以降のログインでは、転送先のURLをデフォルトのプロフィール画面に戻しておく必要があります。(ヒント: リスト 10.30のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。)
(解答) session[:forwarding_url]がreset_sessionによってnilになっていればよい。

[test/integration/users_edit_test.rb]
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_url(@user)
    assert session[:forwarding_url].nil?

②7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして/users/1/editにアクセスしてみてください。デバッガーによって処理が途中で止まるはずです。ここでコンソール画面に移動し、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう。なお、デバッガーを使っている最中に、ターミナルが不意に動かなくなったり挙動がおかしくなったりすることもあります。そのようなときは、熟練開発者の精神を思い出しながら落ち着いて対処しましょう(コラム 1.2)。
(解答) newにdebuggerを挿入して動かすと重くて操作出来ないので飛ばします。。。

10.3.1

①レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。(ヒント: log_in_asヘルパーを使ってリスト 5.31にテストを追加してみましょう。)
(解答)

[test/integration/site_layout_test.rb]
  def setup
    @user = users(:michael)
  end

  #ログイン済ユーザーのindexレイアウトのテスト
  test 'index path layput links with logged in user' do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select "a[href=?]", root_path, count: 2
    assert_select "a[href=?]", help_path
    assert_select "a[href=?]", about_path
    assert_select "a[href=?]", contact_path
    assert_select "a[href=?]", edit_user_path(@user)
    assert_select "a[href=?]", logout_path
  end

  #ログインしていないユーザーのindexレイアウトのテスト
  test 'index path layput links with not logged in user' do
    get users_path
    follow_redirect!
    assert_template 'sessions/new'
    assert_select "a[href=?]", root_path, count: 2
    assert_select "a[href=?]", help_path
    assert_select "a[href=?]", about_path
    assert_select "a[href=?]", contact_path
    assert_select "a[href=?]", login_path
  end

10.3.2

①試しに他のユーザーの編集ページにアクセスしてみて、10.2.2で実装した通りにリダイレクトされるかどうかを確かめてみましょう。
(解答) リダイレクトされた。

10.3.3

Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認してみましょう。
(解答) 1ページ目のユーザー30人が取得できた。

User.paginate(page: nil)
  TRANSACTION (0.1ms)  begin transaction
  User Load (4.2ms)  SELECT "users".* FROM "users" LIMIT ? OFFSET ?  [["LIMIT", 30], ["OFFSET", 0]]

②先ほどの演習課題で取得したpaginationオブジェクトは、どのクラスでしょうか? また、User.allのクラスとどこが違うかを比較してみてください。
(解答) 同じ

irb(main):002:0> User.paginate(page: nil).class
=> User::ActiveRecord_Relation
irb(main):003:0> User.all.class
=> User::ActiveRecord_Relation

10.3.4

①試しにリスト 10.46にあるページネーションのリンク(will_paginateの部分)を2つともコメントアウトしてみて、リスト 10.49のテストが red に変わるかどうか確かめてみましょう。
(解答) 2つともコメントアウトするとテストREDとなった。

②先ほどは2つともコメントアウトしましたが、1つだけコメントアウトした場合、テストが green のままであることを確認してみましょう。will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いでしょうか?(ヒント: 表 5.2を参考にして、個数をカウントするテストを追加してみましょう。)
(解答) assert_select 'div.pagination', count: 2とする。

10.3.5

①リスト 10.53にあるrenderの行をコメントアウトし、テストの結果が red に変わることを確認してみましょう。
(解答) REDになる。

10.4.1

①Web経由でadmin属性を変更できないことを確認してみましょう。具体的には、リスト 10.57に示したように、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみてください。テストの振る舞いが正しいことを確信できるように、最初にadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加しておきましょう。最初のテストの結果は red になるはずです。最後の行では、更新済みのユーザー情報をデータベースから読み込めることを確認します(6.1.5)。
(解答) patchリクエストでadmin:trueを送信し、データベースから最新の情報を読み込み(reload)したときにadmin属性が付与されていなければよい。permitにadmin属性を入れるとREDになり、外すとGREENとなった。

[test/controllers/users_controller_test.rb]
  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:              "password",
                                            password_confirmation: "password",
                                            admin: true } }
    assert_not @other_user.reload.admin?
  end

10.4.2

①管理者ユーザーとしてログインし、試しにサンプルユーザを2〜3人削除してみましょう。ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるでしょうか?
(解答) DELETE FROM "users" WHERE "users"."id" = ? "id", 16 とログに表示される。

10.4.3

①試しにリスト 10.60にある管理者ユーザーのbeforeフィルターをコメントアウトしてみて、テストの結果が red に変わることを確認してみましょう。
(解答)REDになる。

第10章ーまとめー

  • リンクを新しいウィンドウで開くtarget="brank"昨日はフィッシングサイトなどにリンクされる可能性を排除するために、rel属性に"noopener"を追加する。
  • パスワードのバリデーションにはallow_nil: trueを追加しても、has_secure_password自体の存在性検証機能があるため問題無い。
  • editアクションやupdateアクションはログインしているユーザーかつ、自分自身の情報の操作である場合に認可する。createアクションと同様に意図しない情報を更新させないように、Strong parametersを適用させる。
  • フレンドリーフォワーディング機能によって、ログイン前の元いたページへ遷移させることが可能。
  • 複数ユーザーが存在する場合はページネーション機能を用いて、特定の人数ごとにページを区切る。
  • destoryアクションはadmin属性にのみ認可し、admin属性はStrong parameterで認可していないことを必ず確認する。
  • ユーザー削除のテストではassert_differenceでユーザー数が削除前後で減少していることを検証すればよい。

第10章終わり🐻