LoginSignup
15
10

More than 3 years have passed since last update.

Ruby on Rails チュートリアル 第10章 ユーザー更新 beforeフィルター フレンドリーフォワーディング adminまで

Last updated at Posted at 2019-01-16

近況報告

エンジニア転職成功しました。YouTubeもはじめました。

前回の続き

著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目

第10章 ユーザーの更新・表示・削除 難易度 ★★★ 5時間

挫折しないRailsチュートリアルの進め方を先にお読みください↓↓

Railsチュートリアルで挫折しない3つのポイント

この章ではUsersリソース用のRESTアクションのうち、これまで未実装だった

  • edit
  • update
  • index
  • destroy

アクションを加えてRESTアクションを完成させる。

まずはユーザーが自分のプロフィールを更新できるようにする。
その際、8章で実装した認証用のコードを使う(認可モデル(Authorization Model)についても考える)

次に、全てのユーザーを一覧できるようにする。
ここでページネーションと呼ばれるページ分割機能を導入する。

最後に、ユーザーを削除し、DBから完全に消去する機能を追加する。

ユーザーの削除には、管理ユーザーという特権クラスを作成し、このユーザーにのみ削除を許可する。

10.1 ユーザーを更新する

ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている。

例えば、新規ユーザー用のビューはnewアクションで、createアクションへPOSTリクエストしていたが、
ユーザー情報を編集する場合は、newの代わりにeditアクション、createアクションの代わりにPATCHリクエストに対応するupdateアクションを作成する。

ここでの最大の違いは、ユーザーの登録は誰でも実行できるが、
ユーザー情報を更新できるのはそのユーザー自身に限られるということ。

8章で実装した認証機構を使えば、beforeフィルダーを使ってこのアクセス制御を実現できるとのこと。

git checkout -b updating-users

10.1.1 編集フォーム

編集フォームのモックアップ

image.png

出典:図 10.1: ユーザー編集ページのモックアップ

このようなページを動かすには、Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装する必要がある。

まずはeditアクションの実装から始めるが、ここではDBから適切なユーザーデータを読み込む必要がある。

ここで注意しておきたいのは、Usersリソースが提供するユーザー編集ページの正しいURLガ/users/1/editとなっている点。

ユーザーのidはparams[:id]変数で取り出すことができるので、以下のようにeditアクションを指定することで、ユーザーを受け取ることができる。

users_controller.rb
  def edit
    @user = User.find(params[:id])                                              # URLのユーザーidと同じユーザーをDBから取り出して@userに代入
  end

次にユーザー編集ページに対応するビューを作成する。

edit.html.erb
<% provide(:title, "Ebit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">

    <!--formの送信先を指定-->

    <%= form_for(@user) do |f| %>

      <!--エラーメッセージ-->

     <%= render 'shared/error_messages' %>

      <!--form作成-->

     <%= f.label :name %>
     <%= f.text_field :name, class: 'form-control' %>

     <%= f.label :email %>
     <%= f.text_field :email, class: 'form-control' %>

     <%= f.label :password %>
     <%= f.password_field :password, class: 'form-control' %>

     <%= f.label :password_contfirmation, "Confirmation" %>
     <%= f.password_field :password_confirmation, class: "form-control" %>

     <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>

    <!--編集完了ボタンと画像を表示-->

    <div class="grabatar_edit">
      <%= gravatar_for @user %>
      <a fref="http://gravatar.com/emails" target="_blank">編集</a>
    </div>
  </div>
</div>

上のコードでは、newビューで使ったerror_messagesパーシャルを再利用している。
一度設定したので、エラーメッセージは使い回しでOK。

また、Gravatarへのリンクでtarget="_blank"を使っているが、これを使うと新しいタブが開くようになっているが、セキュリティ上の問題もある。(後の演習で解説する)

@userインスタンス変数を使ったことで、編集ページうまく描画されていることを確認する。

スクリーンショット 2019-01-10 23.47.09.png

ここで、NameやEmailの部分に名前やメールアドレスのフィールドの値が自動的に入力されていることが分かる。
これは、editアクションでDBからidのユーザーを取り出して、fブロック変数で一個一個のフォームに入れてるからである。

ここで、HTMLソースを確認。

<form class="edit_user" id="edit_user_1" action="/users/1" accept-charset="UTF-8" method="post">
<input name="utf8" type="hidden" value="&#x2713;" />
<input type="hidden" name="_method" value="patch" />

さらに、入力フィールドに隠し属性があることに注目。

<input type="hidden" name="_method" value="patch" />

WebブラウザはネイティブではPATCHリクエストを送信できない。
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを偽造している。
(単語集のhiddenを参照)

patchの値がhidden属性によって、_methodという名前と共に、隠して送信される。

ここでもう一つ、form_for(@user)のコードは、newビューと完全に同じ。

しかし、Railsはどうやって新規ユーザー用のPOSTリクエストと
ユーザー編集用のPATCHリクエストを区別するのか。

その答えは、Railsは新規ユーザーか、既存のDBにいるユーザーか、
Active Recordnew_record?論理値メソッドを使って区別できるから。

Rails Consoleで確認してみる。

>> User.new.new_record?
=> true
>> User.first.new_record?
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> false

Railsは、form_for(@user)を使ってフォームを構成すると、@user.new_record?がtrueの時にはPOSTリクエスト、falseのときにはPATCHリクエストを使う。

仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新する。

Usersリソースの名前付きルートである、edit_user_pathと、current_userというヘルパーメソッドを使うと、実装が簡単。

<%= link_to "Settings", edit_user_path(current_user) %>

これを、headerパーシャルに差し込む。

_heaer.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>                                                     <!-- ユーザーがログインしている場合の処理 -->
          <li><%= link_to "Users", '#' %></li>                                  <!-- ユーザー一覧リンク-->
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">         <!-- ドロップダウンメニューを適用-->
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>                   <!-- ログインユーザー用のプロフィールページへのリンク-->
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>   <!-- ユーザーページ編集へのリンク -->
                            <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>          <!-- ログアウトリンク-->
              </li>
            </ul>
          </li>
          <% else %>                                                            <!-- ログインしていない場合-->
            <li><%= link_to "Log in", login_path %></li>                        <!-- ログインURLを自動生成-->
          <% end %>
      </ul>
    </nav>
  </div>
</header>

演習

1:target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題がある。
それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点。

例えばフィッシングサイトのような悪意のあるサイトを導入させられてしまう恐れがあるので、
それを防ぐために、aタグのrel(relationship)属性にrel="noopener"と設定するだけ。

Gravatarの編集ページへのリンクにこの設定をしてみる。

edit.html.erb
    <!--編集完了ボタンと画像を表示-->

    <div class="grabatar_edit">
      <%= gravatar_for @user %>
      <a fref="http://gravatar.com/emails" target="_blank" rel="noopener">編集</a>
    </div>
new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:button_text, "新規登録") %>
<h1>新規登録</h1>
  <%= render 'form' %>

2:_form.html.erbのパーシャルを使って、new.html.erbビューと、edit.html.erbビューをリファクタリングしてみる。三章で使った、provideメソッドを使って重複を取り除く。

_form.html.erb
    <!--ボタンと画像を表示-->

    <div class="row">
      <div class="col-md-6 col-md-offset-3">
    <!-- formの送信先を指定 -->

    <%= form_for(@user) do |f| %>                                               <!-- 、fブロックに代入-->
      <%= render 'shared/error_messages', object: @user %>                      <!-- エラーメッセージ用のパーシャルを表示 -->

    <!-- form作成-->

      <%= f.label :name %>                                                      <!-- Userモデルのname属性を設定 -->
      <%= f.text_field :name, class: 'form-control' %>                          <!-- ラベル付きのテキストフィールド要素を作成 -->

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

    <!-- 送信ボタン-->

      <%= f.submit yield(:button_text), class: "btn btn-primary" %>
    <% end %>
      </div>
    </div>
new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:button_text, "新規登録") %>
<h1>新規登録</h1>
  <%= render 'form' %>

これで綺麗にまとまった。

10.1.2 編集の失敗

ユーザー登録に失敗した時と似た方法で、編集に失敗した場合について扱っていく。

まずはupdateアクションの作成から進める。

update_attributesを使って送信されたparmasハッシュに基づいて、ユーザーを更新する。

無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。
この構造は、createアクションの最初のバージョンと極めて似通っている。

users_controller.rb
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      # 更新に成功した場合を扱う
    else
      render 'edit'
    end
  end

更新に失敗したらelseに分岐して編集ページをレンダリング。

update_attributesでの呼び出しで、user_paramsを引数に取っている点に注目

7章で扱ったStrong Parametersを使って、マスアサインメントの脆弱性を防止している。

Userモデルのバリデーション、エラーメッセージのパーシャルは既にあるので
無効な情報を送信するとエラーメッセージが表示されるようになった。

スクリーンショット 2019-01-11 8.29.28.png

演習

1:編集フォームから有効でない値を送信して、編集に失敗することを確認。

確認済み。

10.1.3 編集失敗時のテスト

編集失敗のエラーを検知するための統合テストを書いていく。
とりあえずまずは統合テストを生成

$ rails g integration_test users_edit

最初のテストは、まず編集ページにアクセスし、editビューが描画されるかどうかをチェック。
次に、無効な情報を送信してみて、editビューが再描画されるかどうかをチェック。

require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)                                                   # userIDを取得(michael)
    assert_template 'users/edit'                                                # editビューが描画できてたらtrue
    patch user_path(@user), params: { user: { name: "",                         # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }
    assert_template 'users/edit'                                                # editビューが描画できてたらtrue
  end
end

ここで、重要なのは、PATCHリクエストを送るためにpatchメソッドを使っている所。
これはgetやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。

テストを調べる。

 FAIL["test_invalid_signup_information", UsersSignupTest, 0.4198383340044529]
 test_invalid_signup_information#UsersSignupTest (0.42s)
        Expected at least 1 element matching "form[action="/signup"]", found 0..
        Expected 0 to be >= 1.
        test/integration/users_signup_test.rb:19:in `block in <class:UsersSignupTest>'

  28/28: [================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.46134s
28 tests, 67 assertions, 1 failures, 0 errors, 0 skips

エラーやんけ!

実際にnewビューで送信してみると、URLが/usersになる件。
そーいえば以前にもこんなのあったな。

ん、待てよ

new.html.erb
<% provide(:url, user_path) %>
edit.html.erb
<% provide(:url, signup_path) %>

こうやってそれぞれにurlを指定し、

_form.html.erb
    <%= form_for(@user, url: yield(:url)) do |f| %>                                               <!-- 、fブロックに代入-->

ってやればいいんじゃね。

Finished in 0.54764s
8 tests, 31 assertions, 0 failures, 0 errors, 0 skips

よしきた。どうやら前回の演習でミスをしたようだw

演習

1:assert_selectを使ってalertクラスのdivタグを探しだし、The form contains 4 errors.というテキストを精査してみる。

 assert_select "div.alert", "The form contains 4 errors."

10.1.4

今度は編集フォームが動作するようにする。

プロフィール画像の編集は画像アップロードをGravatarに任せてあるので、既に動作するようになっている。

changeボタンをクリックすれば、Gravatarを編集できる。

ここで、より快適にテストするためには、アプリ用のコードを実装する前に統合テストを書いた方が便利ということで、そういった受け入れテストを行う。

受け入れテストは、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている。

今回はテスト駆動開発を使ってユーザーの編集機能を実装してみる。

まずは、ユーザー情報を更新する正しい振る舞いをテストで定義する。

次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェックする。
また、DB内のユーザー情報が正しく変更されたかどうかも検証する。

users_edit_test.rb
  test "successful edit" do
    get edit_user_path(@user)                                                   # userIDを取得(michael)
    assert_template 'users/edit'                                                # editビューが描画できてたらtrue
    name  = "Foo Bar"                                                           # フォーム欄に値を入力する
    email = "foo@bar.com"                                                       
    patch user_path(@user), params: { user: { name: name,                       # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?                                                     # エラー文が空じゃなければtrue
    assert_redirected_to @user                                                  # michaelのユーザーidページへ移動できたらtrue
    @user.reload
    assert_equal name,  @user.name                                              # DB内の名前と@userの名前が一致していていたらtrue
    assert_equal email, @user.email                                             # DB内のEmailと@userの名前が一致
  end

パスワードとパスワード確認が空であることに注目。

ユーザー名やメルアドを編集する時に毎回パスワードを入力するのは不便なので、
わざとパスワードを入力せずに更新している。

また、user.reloadを使ってDBから最新のユーザー情報を読み込み直して、正しく更新されたかどうか確認している点にも注目。

受け入れテストでは先にテストを書くので、効果的なユーザー体験について考えるようになる。

テストにパスする必要のあるupdateアクションは、createアクションの最終的なフォームとほぼ同じである。

users_controller.rb
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      flash[:success] = "プロフィール更新完了"
      redirect_to @user
    else
      render 'edit'
    end
  end

このテストはまだ失敗する。
理由として、パスワードの長さに対するバリデーションがあるので、パスワードやパスワード確認の欄を空にしておくと引っかかってしまう恐れがあるから。

テストをパスさせるためには、空だった時の例外処理を加える必要がある。

そのために、Userモデルのバリデーションにallow_nil :trueというオプションを使う。

user.rb
class User < ApplicationRecord
  # インスタンス変数の定義
  attr_accessor :remember_token


  before_save { email.downcase! }                                               #DB保存前にemailの値を小文字に変換する
  validates :name, presence: true, length: { maximum: 50 }                      #nameの文字列が空でなく、50文字以下ならtrue
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i                      #正規表現でemailのフォーマットを策定し、定数に代入
  validates :email, presence: true, length: { maximum: 255 },                   #emailの文字列が空でなく、255文字以下ならtrue
                    format: { with: VALID_EMAIL_REGEX },                        #emailのフォーマットを引数に取ってフォーマット通りか検証する。
                    uniqueness: { case_sensitive: false }                       #大文字小文字を区別しない(false)に設定する このオプションでは通常のuniquenessはtrueと判断する。

  has_secure_password                                                           #passwordとpassword_confirmation属性に存在性と値が一致するかどうかの検証が追加される
  validates :password, presence: true,length: { minimum: 6 }, allow_nil: true   #passwordの文字列が空でなく、6文字以上ならtrue。例外処理に空(nil)の場合のみバリデーションを通す(true)

これでpasswordが空だったとしたらvalidationをスルー(true)する例外処理を加えたが、
has_secure_passwordではDBにレコード(オブジェクト)が生成された時だけ存在性(nilかどうか)のvalidationを行う
性質があるので、実際にpasswordを作成する際は、nilかどうかの検証を行ってくれる。

つまり、実行環境でpasswordが空だった場合のvalidation機能を保持したまま、テストで空だった場合にvalidationを通すことができるという訳。(今回のテストではuserオブジェクトを生成していないので)

また、allow_nilのおかげでhas_secure_passwordによるバリデーションがそれぞれ実行され、二つのエラーメッセージが表示されるバグも解決できた。(オブジェクト生成時と、実際に空かどうかの二つの検証を行っていた為)

スクリーンショット 2019-01-11 19.35.31.png

ほんとだ。

これでテストはパスする。
スクリーンショット 2019-01-11 20.08.13.png

$ rails t

スクリーンショット 2019-01-11 20.06.01.png

実際に編集もできた。

演習

1:実際に編集が成功するかどうか、有効な情報を送信して確かめる

確認済み

2:Gravatarと紐づいていない適当なメールアドレスに変更した場合、画像がどう変化するか確認

スクリーンショット 2019-01-11 20.08.13.png

Gravatarとの連動も確認

10.2 認可

第8章ではauthentication(認証)システムを構築することで、サイトのユーザーを識別する機能を加えたが、今回はそのユーザーをauthorization(認可)することで、ログインユーザーのみが実行可能な操作を実現する。

editアクションとupdateアクションにはセキュリティ上の大穴が一つある。
それは、どのユーザーでもあらゆるアクションにアクセスできる為、誰でもユーザー情報を編集出来てしまうところ。

今回はユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御してみる。

さらに、ログインしていないユーザーが保護されたページにアクセスしようとした際に、ログインページに転送して、分かりやすいメッセージを表示させる仕組みも作る。

また、許可されていないページに対してアクセスするログイン済みのユーザーがいたら、ルートURLにリダイレクトさせるようにする。

モックアップはこちら

image.png

出典:図 10.6: 保護されたページにアクセスしたときのページのモックアップ

10.2.1 ユーザーにログインを要求する

モックアップのように転送させる仕組みを実装したい時は、Usersコントローラの中でbeforeフィルターを使う。

beforeフィルターは、before_actionメソッドを使って、何らかの処理が実行される直前に特定のメソッドを実行する仕組み。

ユーザーにログインを要求するために、logged_in_userメソッドを定義して、before_action:logged_in_userという形式で使う。

users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]                         # editとupdateアクションにlogged_in_userメソッドを適用

    def logged_in_user                                                          # ログイン済みユーザーかどうか確認
      unless logged_in?                                                         # ユーザーがログインしていなければ(false)処理を行う
        flash[:danger] = "Please log in."                                       # エラーメッセージを書く
        redirect_to login_url                                                   # ログインユーザーのidを引数に取ったURLのページへ飛ぶ
      end
    end

beforeフィルターはデフォルトで、コントローラ内の全てのアクションに適用されるので、:onlyオプションを渡すことで、editとupdateというアクションのみフィルタを適用されるよう制限を掛けた。

beforeフィルターを使って実装した結果、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスしてみることで確認できる。

スクリーンショット 2019-01-12 0.15.42.png

怒られてloginページに飛ばされた。

また、テストはこの時点で失敗。
理由は、editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストで失敗してしまう。

このため、テストではeditアクションやupdateアクションを実行する前にログインしておく必要がある。

解決策は簡単。test_helperのlog_in_asヘルパーを使うだけ。

users_edit_test.rb
  test "unsuccessful edit" do
    log_in_as(@user)                                                            # ログインする
    get edit_user_path(@user)

  test "successful edit" do
    log_in_as(@user)                                                            # ログインする

これでテストはパスする。

setupメソッド内でログイン処理をまとめてしまうことも可能だが、後のテストでログインする前に編集ページにアクセスするように変更したいので、あえてこうしている。

2 tests, 8 assertions, 0 failures, 0 errors, 0 skips

しかし、実はbeforeフィルターの実装はまだ終わっておらず、セキュリティモデルに関する実装を取り外してもテストが失敗するかどうか、実際にコメントアウトして確かめる必要がある。

users_controller.rb
  #before_action :logged_in_user, only: [:edit, :update]                         # editとupdateアクションにlogged_in_userメソッドを適用

何とテストが通ってしまった。

$ rails t

これでは問題なので、対処をする。

beforeフィルターは基本的にアクションごとに適用していくので、
Usersコントローラのテストもアクションごとに書いていく。

具体的には

①正しい種類のHTTPリクエストを使う
②editアクションとupdateアクションをそれぞれ実行させてみる
③flashにメッセージが代入されるかどうか検証
④ログイン画面にリダイレクトされたかどうか

を確認する。

HTTPリクエストは、 editにはget、updateにはpatchを割り当てる。

users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "should get new" do
    get signup_path
    assert_response :success
  end

  test "should redirect edit when not logged in" do
    get edit_user_path(@user)                                                   # ログインユーザーの編集ページを取得
    assert_not flash.empty?                                                     # flashが空でないならtrue
    assert_redirected_to login_url                                              # ログインユーザーのidのURLへ飛べたらtrue
  end

  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,                 # ログインユーザーへ、保存ユーザーの名前とメルアドを引数に取り送信(更新)
                                              email: @user.email } }
    assert_not flash.empty?                                                     # flashが空でないならtrue
    assert_redirected_to login_url                                              # ログインユーザーのidのURLへ飛べたらtrue
  end

end

これで先ほどのbefore_actionをコメントアウトするとテストが失敗する。

$ rails t
test/controllers/users_controller_test.rb:24:in `block in <class:UsersControllerTest>'
test/controllers/users_controller_test.rb:16:in `block in <class:UsersControllerTest>'
11 tests, 41 assertions, 2 failures, 0 errors, 0 skips

どうやらflashメッセージが表示されないのと、ログインページへリダイレクトできないのが原因見たい。

これでコメントアウトを解除すればtrue

$ rails t
11 tests, 41 assertions, 0 failures, 0 errors, 0 skips

演習

1:beforeフィルターを全てに適用して見てテストがそのエラーを検知できるかどうか確認。

$ rails t
 FAIL["test_valid_signup_information", UsersSignupTest, 0.35811705499872915]
 test_valid_signup_information#UsersSignupTest (0.36s)
        "User.count" didn't change by 1.
        Expected: 2
          Actual: 1
        test/integration/users_signup_test.rb:25:in `block in <class:UsersSignupTest>'

 FAIL["test_invalid_signup_information", UsersSignupTest, 0.37334744699910516]
 test_invalid_signup_information#UsersSignupTest (0.37s)
        expecting <"users/new"> but rendering with <[]>
        test/integration/users_signup_test.rb:16:in `block in <class:UsersSignupTest>'

 FAIL["test_should_get_new", UsersControllerTest, 0.475691736999579]
 test_should_get_new#UsersControllerTest (0.48s)
        Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/login>
        Response body: <html><body>You are being <a href="http://www.example.com/login">redirected</a>.</body></html>
        test/controllers/users_controller_test.rb:11:in `block in <class:UsersControllerTest>'

OK

10.2.2 正しいユーザーを要求する

ユーザーが自分の情報だけを編集できるようにする必要がある。
ここではセキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。

したがって、Usersコントローラのテストを補完するように、テストを追加していく。

まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加する。

ユーザー用のfixtureファイルに2人目のユーザーを追加してみる。

users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

次に、log_in_asメソッド(ログインユーザー)を使ってeditアクションとupdateアクションをテストする。

このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトしている点に注意。

users_controller_test.rb
  test "should redirect edit when logged in as wrong user" do                   # @other_userで編集できるか確認
    log_in_as(@other_user)                                                      # @other_userでログインする
    get edit_user_path(@user)                                                   # @userの編集ページを取得
    assert flash.empty?                                                         # flashが空ならtrue
    assert_redirected_to root_url                                               # root_urlへ移動できればtrue
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)                                                      # @other_userでログインする
    patch user_path(@user), params: { user: { name:  @user.name,                # @userのユーザーページへ、フォームに入力したname値・email値を送信(更新)
                                              email: @user.email } }
    assert flash.empty?                                                         # flashが空ならtrue
    assert_redirected_to root_url                                               # root_urlへ移動できればtrue
  end

テストは失敗する。

ログイン済みの@other_userで、@userのページを取得・送信した際にルートURLへリダイレクトさせたいので、
correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。

beforeフィルターのcorrectuserで@user変数を定義し、editupdateの各アクションから、@userの代入文を削除している点にも注意。

users_controller.rb
  before_action :correct_user,   only: [:edit, :update]                         # editとupdateアクションにcorret_userメソッドを適用

    def correct_user                                                            # 正しいユーザーかどうか確認
      @user = User.find(params[:id])                                            # URLのidの値と同じユーザーを@userに代入
      redirect_to(root_url) unless @user == current_user                        # @userと記憶トークンcookieに対応するユーザー(current_user)を比較して、失敗したらroot_urlへリダイレクト
    end

ちょっと難しいが、@userと記憶トークンcookieに対応するユーザーとを比較し、値が異なっていればroot_urlへリダイレクトしている。
要は、ログインユーザーとURLに入力されたユーザーが異なっていれば、root_urlに飛ばしますよ〜という処理。

root_urlに飛ばすことで、flash(エラーメッセージ)を無くしている。

これでテストはパスする。

最後にリファクタリングとして、current_user?という論理値を返すメソッドを実装する。
correct_userの中で使えるようにしたいのでSessionsヘルパーの中にこのメソッドを追加する。

unless @user == current_user

これを

unless current_user?(@user)

とする。

sessions_helper.rb
  # 渡されたユーザーがログイン済みユーザーであればtrueを返す  
  def current_user?(user)
    user == current_user
  end

先ほどのメソッドを使って比較演算していた行を置き換えると

users_controller.rb
    def correct_user                                                            # 正しいユーザーかどうか確認
      @user = User.find(params[:id])                                            # URLのidの値と同じユーザーを@userに代入
      redirect_to(root_url) unless current_user?(@user)                         # @userと記憶トークンcookieに対応するユーザー(current_user)を比較して、失敗したらroot_urlへリダイレクト
    end

これでテストはパスする。

演習

1:何故editアクションとupdateアクションを両方とも保護する必要があるのか?

updateアクションにpatchリクエストを送って不正に更新しようとする行為を防止する為

2:上記アクションのうち、どちらがブラウザで簡単にテストできるか?

editやろ

10.2.3 フレンドリーフォワーディング

Webサイトの認可機能は完成したかのようん見えるが、後1つ小さなキズがあります。

保護されたページにアクセスしようとすると、問題無用で自分のプロフィールページに移動させられてしまう。

例えば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後には、その編集ページにリダイレクトさせてあげるのが望ましい。

リダイレクト先は、ユーザーが開こうとしていたページにしてあげる。

フレンドリーフォワーディングのテストコードは、シンプルに書くことができて、例えばログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけ。

具体的には、

①編集ページにアクセス
②ログイン
③編集ページにリダイレクトされているかどうかチェックする

これを、users_edit_text.rbで実際に行う

なお、リダイレクトによってedit用のテンプレが描画されなくなったので、edit_testにあるassert_template 'users/edit'を削除する。

users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)                                                   # @userのユーザー編集ページを取得
    log_in_as(@user)                                                            # @userでログイン
    assert_redirected_to edit_user_path(@user)                                  # @userのユーザー編集ページへ移動する
    log_in_as(@user)                                                            # ログインする
    get edit_user_path(@user)                                                   # userIDを取得(michael)
    name  = "Foo Bar"                                                           # フォーム欄に値を入力する
    email = "foo@bar.com"                                                       
    patch user_path(@user), params: { user: { name: name,                       # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?                                                     # エラー文が空じゃなければtrue
    assert_redirected_to @user                                                  # michaelのユーザーidページへ移動できたらtrue
    @user.reload
    assert_equal name,  @user.name                                              # DB内の名前と@userの名前が一致していていたらtrue
    assert_equal email, @user.email                                             # DB内のEmailと@userの名前が一致
  end

これでテストは失敗。

これでフレンドリーフォワーディングを実装する準備が整った。

ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
この動作をstore_locationredirect_back_orの2つのメソッドを使って実現してみる。

これらのメソッドはSessionsヘルパーで定義している。

sessions_helper.rb
  # 記憶したURL(もしくはデフォルト値)にリダイレクト

  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    session[:forwarding_url] = request.original_url if request.get?              # :forwarding_urlキーにリクエスト先のURLを、GETリクエストが送られた時だけ代入
  end

:forwarding_urlキーへの値の格納で、GETリクエストが送られたときだけ格納するようにする。
こうすることで、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにする。

これは稀なケースだが起こりえる。

例えば、ユーザがセッション用のcookieを手動で削除して、フォームから送信するケース。

こういったケースに対処しておかないと、POSTやPATCH、DELETEリクエストを期待しているURLに対して、GETリクエストが送られてしまい、場合によってはエラーが発生する。

これらの問題を
if request.get?という条件文を使って対応している。

先ほど定義したstore_locationメソッドを使って、早速beforeフィルターlogged_in_userを修正してみる。

users_controller.rb
    # ログイン済みユーザーかどうか確認
    def logged_in_user                                                          # ログイン済みユーザーかどうか確認
      unless logged_in?                                                         # ユーザーがログインしていなければ(false)処理を行う
        store_location                                                          # アクセスしようとしたURLを覚えておく
        flash[:danger] = "ログインしないとダメですよ"                                               # エラーメッセージを書く
        redirect_to login_url                                                   # ログインユーザーのidを引数に取ったURLのページへ飛ぶ
      end
    end

これで、ログインできなかった場合、アクセスしようとしたURLを記憶する。

フォワーディング自体を実装するには、redirect_back_orメソッドを使う。
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトする。

デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトする。
redirect_back_orメソッドでは、次のようにor演算子||を使う。

session[:forwarding_url] || default

このコードは、値がnilでなければsession[:forwarding_url]を評価し、そうでなければデフォルトのURLを使っている。

また、redirect_back_or(default)メソッドでは、session.delete(:forwarding_url)という行を通して転送用のURLを削除している点にも注意。

これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまう。
ちなみに、最初にredirect文を実行しても、セッションが削除される。
実は、明示的にreturn文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しない。

したがって、redirect文の後にあるコードでも、そのコードは実行される。

sessions_controller.rb
  def create
    @user = User.find_by(email: params[:session][:email].downcase)              # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if @user && @user.authenticate(params[:session][:password])                 # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
      log_in @user                                                              # sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)   # ログイン時、sessionのremember_me属性が1(チェックボックスがオン)ならセッションを永続的に、それ以外なら永続的セッションを破棄する
      redirect_back_or @user                                                    # userの前のページもしくはdefaultにリダイレクト
    else
      flash.now[:danger] = 'Invalid email/password combination'                 # flashメッセージを表示し、新しいリクエストが発生した時に消す
      render 'new'                                                              # newビューの出力
    end
  end

*Tutorialでは@userではなくuserが使われているが、使うとusers_login_testでエラーが発生するので@userを使う。

これで、フレンドリーフォワーディング用統合テストもパスする。

$ rails t
5 tests, 20 assertions, 0 failures, 0 errors, 0 skips

演習

1:フロンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを確認するテストを書く

users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)                                                   # @userのユーザー編集ページを取得
    assert_equal session[:forwarding_url], edit_user_url(@user)                 # 渡されたURLに転送されているか確認
    log_in_as(@user)                                                            # @userでログイン
    assert_nil session[:forwarding_url]                                         # forwarding_urlの値がnilならtrue(deleteが効いてる)
    name  = "Foo Bar"                                                           # フォーム欄に値を入力する
    email = "foo@bar.com"                                                       
    patch user_path(@user), params: { user: { name: name,                       # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?                                                     # エラー文が空じゃなければtrue
    assert_redirected_to @user                                                  # michaelのユーザーidページへ移動できたらtrue
    @user.reload
    assert_equal name,  @user.name                                              # DB内の名前と@userの名前が一致していていたらtrue
    assert_equal email, @user.email                                             # DB内のEmailと@userの名前が一致
  end

2:debuggerメソッドを使ってSessionsコントローラのnewアクションに置いてみる。

その後、ログアウトして/users/1/editにアクセス。すると、デバッガーが途中で処理をやめる。
ここでコンソールでsession[:forwarding_url]の値が正しいかどうか、newアクションにアクセスした時のrezuest.get?の値も確認してみる。

(byebug) "https://eac437457e484fe491559aaa135f7f93.vfs.cloud9.us-east-2.amazonaws.com/users/1/edit"
(byebug) true

OK。

10.3 すべてのユーザーを表示する

全てのユーザーを表示するためのindexアクション(/users users_path)を追加する。

その際、DBにサンプルデータを追加する方法や、将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネーション(ページ分割)の方法を学ぶ。

ユーザーの一覧、ページネーション用リンク、移動用の[Users]リンクのモックアップはこちら

image.png

出典:図 10.8: ユーザー一覧ページのモックアップ

10.3.1 ユーザーの一覧ページ

実装前にセキュリティモデルについて考えてみる。

ユーザーのshowページはサイトを訪れた全てのユーザーから見えるようにしておくが、
ユーザーのindexページはログインしたユーザーにしか見えないようにし、未登録のユーザーがデフォルトで表示できるページを制限する。

indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするかを検証するテストを書いてみる。

users_controller_test.rb
  test "should redirect index when not logged in" do
    get users_path                                                              # index(/users)を取得
    assert_redirected_to login_url                                              # ログインページへリダイレクトできたらtrue
  end

次に、users_controllerのbeforeフィルターのlogged_in_userにindexアクションを追加して、ログインしていなければログインページを飛ばす

users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]                         # editとupdateアクションにlogged_in_userメソッドを適用
  before_action :correct_user,   only: [:edit, :update]                         # editとupdateアクションにcorret_userメソッドを適用

  def index
  end

今度は、usersページで全てのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装する。
Toyアプリケーションにも同じindexアクションがあったことを思い出して、User.allを使って全DB上のユーザーを取得し、ビューで使えるインスタンス変数@users`に代入させる。

users_controller.rb
  def index
    @users = User.all
  end

実際のindexページを作成するには、ユーザーを列挙してユーザーごとにliタグで囲むビューを作成する必要がある。
ここではeachメソッドを使って作成する。

それぞれの行をulで囲いながら、各ユーザーのGravatarと名前を表示する。

index.html.erb
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>

<ul class="users">
  <% @users.each do |user| %>
  <li>
    <%= gravatar_for user, size: 50 %>
    <%= link_to user.name, user %>
  </li>
  <% end %>
</ul>

ここでgravatar_for で画像を表示し、デフォルト以外のサイズを指定するオプションを渡している点に注目。

7章の演習でgravatar_forメソッドの引数にオプションでsizeを渡していたので、それを今回は変更している。

Gravatar側の準備が整ったら、SCSSにも手を加える。

custom.scss
/* ユーザー一覧 */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}

最後に、ヘッダーにユーザー一覧用のリンクを追加。
リンクにはusers_path(名前付きルート)を割り当てる。

_header.html.erb
          <li><%= link_to "Users", users_path %></li>                         <!-- ユーザー一覧リンク-->

これでテストは成功。

$ rails t

ユーザー一覧も表示できた。

スクリーンショット 2019-01-15 0.13.15.png

演習

1:レイアウトにあるすべてのリンクに対して統合テストを書いてみる。
ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてみる。

site_layout_test.rb
require 'test_helper'

class SiteLayoutTest < ActionDispatch::IntegrationTest
  # test "the truth" do
  #   assert true
  # end

  test "layout links" do
    get root_path
    assert_template 'static_pages/home'
    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
    get contact_path
    assert_select "title", full_title("Contact")
    get signup_path
    assert_select "title", full_title("Sign up")
  end

  def setup
    @user = users(:michael)
  end

  test "layout links when logged in" do                                         # ホームページ用の統合テスト
    log_in_as(@user)
    get root_path
    assert_template 'static_pages/home'
    assert_select "a[href=?]", users_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)
    assert_select "a[href=?]", logout_path
  end
end

全部描画されているのでtrue

$ rails t
2 tests, 13 assertions, 0 failures, 0 errors, 0 skips

10.3.2 サンプルユーザー

indexページに複数のユーザーを表示させるために、Rubyを使ってユーザーを一気に作成してみる。

その為にGemfileにFaker gemを追加する。

これは実際にいそうなユーザー名を作成するgem。faker gemは開発環境以外では普通使わないが、今回は例外的に本番環境でも適用させる予定なので、次のようにすべての環境で使えるようにする。

gem 'bootstrap-sass', '3.3.7'
gem 'bcrypt',       '3.1.12'
gem 'faker',        '1.7.3'

$ bundle update
# bundle install

次に、サンプルユーザーを生成するRubyスクリプト(Railsタスク)を追加してみる。
Railsではdb/seed.rbというファイルを標準として使う。

作成したコードを次に示す。

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

このコードでは、Example Userという名前とメールアドレスを持つ1人のユーザと、
それらしい名前とメールアドレスを持つ99人のユーザーを作成する。

create!は基本的にcreateメソッドと同じものだが、ユーザーが無効な場合にfalseを返すのではなく、例外を発生させる点が異なる。

こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になる。

ここえDBをリセットして、Railsタスクを実行(db:seed)してみる。

$ rails db:migrate:reset
$ rails db:seed

データベース上にデータを追加するのは遅くなりがちで、システムによっては数分掛かることもあり得る。

また、Railsサーバーを動かしている状態だとrails db:migrate:resetコマンドがうまく動かない時もあるので注意

ここで、ユーザー一覧を確認してみる。

スクリーンショット 2019-01-15 4.11.23.png

db:seedでRailsタスクを実行し終わると、サンプルアプリケーションのユーザーが100人になっている。

最初のいくつかのメールアドレスはデフォルトのGravatar画像以外の写真を関連付けている。

演習

1:試しに他人の編集ページにアクセスしてみて、実装したようにリダイレクトされるか確認。

確認済み。

10.3.3 ページネーション

大量のユーザーを1つのページに表示させているが、これを分割する為にページネーションを行う。

今回は、1ページに30人だけユーザーを表示してみる。

Railsには豊富なページネーションメソッドがあり、今回はwill paginateメソッドを使って見る。
これを使うために、Gemfilewill_paginategemとbootstrap-will_paginategemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する必要がある。

まずは各gemをGemfileに追加してみる。

Gemfile
gem 'faker',        '1.7.3'
gem 'will_paginate','3.1.6'
gem 'bootstrap-will_paginate','1.0.0'

$ bundle install

実行後、新しいgemが正しく読み込まれるように、Webサーバーを再起動する。

ページネーションが動作するには、ユーザーのページネーションを行うようにRailsに指示するコードをindexビューに追加する必要がある。
また、indexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もある。

まずはビューにwill_paginateメソッドを追加する。

index.html.erb
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
  <li>
    <%= gravatar_for user, size: 50 %>
    <%= link_to user.name, user %>
  </li>
  <% end %>
</ul>

<%= will_paginate %>

このwill_paginateメソッドはusersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成している。

ただし、このままでは上記のビューは動かない。
理由は現在の@users変数にはUser.allが含まれているが、will_paginateではpaginateメソッドを使った結果が必要だから。

必要となるデータは

$ rails c
>> User.paginate(page: 1)
  User Load (0.9ms)  SELECT  "users".* FROM "users" LIMIT ? OFFSET ?  [["LIMIT", 11], ["OFFSET", 0]]
   (0.1ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest

paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る。

User.paginateでは、:pageパラメーターに基づいて、DBからひとかたまりのデータ(デフォルトでは30)を取り出す。

したがって、1ページ目は1から30のユーザー、2ページ目は31~60のユーザーと行った具合にデータが取り出される。

ちなみに、pageがnilの場合、paginateは単に最初のページを返す。

paginateを使うことで、サンプルアプリケーションのユーザーのページネーションを行えるようになる。
具体的には、indexアクション内のallをpaginateメソッドに置き換える。

ここで、:pageパラメーターにはparams[:page]が使われるが、これはwill_paginateによって自動的に生成される。

users_controller.rb
  def index
    @users = User.paginate(page: params[:page])                                 # Userを取り出して分割した値を@usersに代入
  end

以上で、ユーザー一覧ページを確認してみる。

スクリーンショット 2019-01-15 4.37.15.png

スクリーンショット 2019-01-15 4.37.20.png

次ページへの遷移も可能。

演習

1:Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認する。

>> User.paginate(page: nil)
  User Load (0.2ms)  SELECT  "users".* FROM "users" LIMIT ? OFFSET ?  [["LIMIT", 11], ["OFFSET", 0]]
   (0.1ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest:

OK

2:paginationオブジェクトは何クラスか確認。また、User.allのクラスとどこが違うか?

>> user = User.all
>> page = User.paginate(page: 1)
>> user.class
=> User::ActiveRecord_Relation
>> page.class
=> User::ActiveRecord_Relation

ActiveRecord_RelationクラスでUser.allと同じ。

10.3.4 ユーザー一覧のテスト

これでユーザー一覧ページが動くようになったので、ページネーションに対する簡単なテストを書いておく。

今回のテストでは、

①ログイン
②indexページにアクセス
③最初のユーザーのページにユーザーがいることを確認
④ページネーションのリンクがあることを確認

という順にテストしていく。

最後の2つのステップでは、テスト用のDBに31人以上のユーザーがいる必要がある。

そこで、今回はfixtureに埋め込みRubyを使って31人以上のテストユーザーを作成する。

users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  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 %>

テスト用ユーザーを追加したので、indexページに対するテストを書いていく。
まずは統合テストから作成

$ rails g integration_test users_index

今回のテストでは、paginationクラスを持ったdivタグをチェックして、最初のページにユーザーがいることを確認する。

users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  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

これでテストはパス

$ rails t
36 tests, 121 assertions, 0 failures, 0 errors, 0 skips

演習

1:ページネーション(will_paginate)をコメントアウトしてテストが失敗するかどうか確認。

確認済み

2:1つだけコメントアウトした場合にテストがパスすることを確認。
また、will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いか?

users_index_test.rb
  test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select   'div.pagination', count: 2
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end

10.3.5 パーシャルのリファクタリング

ユーザー一覧ページにページネーションを実装できたが、ここではパーシャルを使ってリファクタリングしていく。

今回は一覧ページのリファクタリング(動作を変えずにコードを整理)を行うことにする。

サンプルアプリケーションのテストは既に完了しているので、Webサイトの機能を損なうことなく安心してリファクタリングに取りかかれる。

リファクタリングの第一歩は、ユーザーのliをrender呼び出しに置き換えること。

index.html.erb
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <%= render user %>
  <% end %>
</ul>

<%= will_paginate %>

パーシャル用のファイルを作成し、そこにliタグを置く。

_user.html.erb
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

さらに、今度はrenderを@users変数に対して直接実行する。

index.html.erb
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>

<%= will_paginate %>

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

<%= will_paginate %>

@usersをrenderで読み出すと、Railsは自動的に@usersをUserオブジェクトのリストであると推測。
さらに、ユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力する。

つまり、each doメソッドを使わずに、@usersのユーザーを順に取り出して表示することが可能。

$ rails t
15 tests, 79 assertions, 0 failures, 0 errors, 0 skips

テストがパスしたので、ユーザーを取り出せてることを確認した。

演習

1: renderの行をコメントアウトし、テストが失敗することを確認。

確認済み。

10.4 ユーザーを削除する

ユーザー一覧ページが完了したので、残るはdestroyのみ。
これを実装することで、RESTに準拠した正統なアプリケーションとなる。

まずはユーザーを削除するためのリンクを追加する。
次に、削除を行うのに必要なdestroyアクションも実装する。

モックアップはこちら

image.png

出典:図 10.13: 削除リンクを追加したユーザー一覧のモックアップ

10.4.1 管理ユーザー

特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。
追加したら、自動的にadmin?という論理値を返すメソッドも使えるようになる。
これを使って、管理ユーザーの状態をテストできる。

変更後のデータモデル

image.png

出典:図 10.14: 論理値をとるadmin属性が追加されたUserモデル

いつものように、マイグレーションを実行してadmin属性を追加する。
属性の型はbooleanとする。

$ rails g migration add_admin_to_users admin:boolean

マイグレーション実行後、adminカラムがusersテーブルに追加される。
default: falseという引数をadd_columnに追加している。

これは、デフォルトでは管理者になれないと明示している。
なお、デフォルトではadminの値はnilなのでfalseを指定せずともいいのだが、あえてfalseを渡すことでコードの意図をRailsと開発者に明確に示せる。

[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

あとは、マイグレーションを実行して変更を反映

$ rails db:migrate

Railsコンソールで動作を確認すると、期待どおりadmin属性が追加されて論理値をとり、さらに疑問符の付いたadmin?メソッドも利用できるようになっている。

>> user = User.first
  User Load (0.7ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest: "$2a$10$dljD1bDoK.bH4l8WOY0CIueP7hTv.84hOJW76oNEf2w...", remember_digest: nil, admin: false>
>> user.admin?
=> false
>> user.toggle!(:admin)
   (0.1ms)  SAVEPOINT active_record_1
  SQL (3.6ms)  UPDATE "users" SET "updated_at" = ?, "admin" = ? WHERE "users"."id" = ?  [["updated_at", "2019-01-15 00:50:25.017787"], ["admin", "t"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

これでuserがデフォルトでは管理者ではないことがわかった。

また、toggle!メソッドでadmin属性の状態をfalseからtrueに反転している。

仕上げに、最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新する。

seeds.rb
User.create!(name:                    "Example User",
             email:                   "example@railstutorial.org",
             password:                "foobar",
             password_confirmation:   "foobar",
             admin: true)

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

次に、DBをリセットして、サンプルデータを再度生成する。

rails db:migrate:reset
rails db:seed

Strong Parameters 再び

seeds.rbでは初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしている。
ここでは、荒れ狂うWeb世界にオブジェクトを晒すことの危険性を改めて強調している。

もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくるかもしれない。

patch /users/17?admin=1

このリクエストは、17番目のユーザーを管理者に変えてしまう。
ユーザーのこの行為は少なくとも重大なセキュリティ違反となる可能性があるし、それだけでは済まされない。

このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になる。

これを、7章で使ったStrong Parametersを使って対策をする。

次のように、paramsハッシュに対してrequirepermitを呼び出す。

 def user_params
    params.require(:user).permit(:name, :email, :password,
                                 :password_confirmation)

上のコードでは、許可された属性リストにadminが含まれていないことに注目。

これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
この問題は重大であるため、編集可能になってはならない属性に対するテストを作成してみる。

演習

1:Web経由でadmin属性を変更できないことを確認してみる。
具体的には、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみる。
テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加するところから始めてみる。

users_controller_test.rb
    test "should not allow the admin attribute to be edited via the web" do
      log_in_as(@other_user)                                                    # @other_userでログインする
      assert_not @other_user.admin?                                             # @toher_userが管理権限あれば(adminがtrueなら)falseを返す
      patch user_path(@other_user), params: {                                   # /users/@other_user へparamsハッシュの中身を送る
                                    user: { password:               'password',
                                            password_confirmation:  'password',
                                            admin: true } }
      assert_not @other_user.reload.admin?                                      # @other_userを再読み込みし、admin論理値が変更されてないか検証(falseやnilならtrue)
    end

10.4.2 destroyアクション

Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加する。
まず、ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。

これによって、現在のユーザーが管理者のときに限り[delete]リンクが表示されるようになる。

_user.html.erb
<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>

必要なDELETEリクエストを発行するリンクの生成は、method: :deleteによって行われている点に注意。
また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしている。

ブラウザはネイティブではDELETEリクエストを送信できないため、RailsではJavaScriptを使って偽造する。
つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になる。

JavaScriptをサポートしないブラウザをサポートする必要がある場合は、フォームとPOSTリクエストを使ってDELETEリクエストを偽造することもできる。(これはJavaScriptがなくても動作する)

この削除リンクが動作するためには、destroyアクションを追加する必要がある。
該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーのindexに移動する。
ユーザーを削除するためにはログインしていなければならないので、destroyアクションもlogged_in_userフィルターに追加する。

users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]       # logged_in_userメソッドを適用
  before_action :correct_user,   only: [:edit, :update]                         # editとupdateアクションにcorret_userメソッドを適用
  before_action :admin_user,     only: :destroy

  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "削除完了"
    redirect_to users_url
  end

destroyアクションでは、findメソッドとdestroyメソッドを1行で書くために2つのメソッドを連結している。

結果として、管理者だけがユーザーを削除できるようになる。
(つまり、削除リンクが見えているユーザーのみ削除できる)

しかし、ここでコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができる。
サイトを正しく防衛するためには、destroyアクションにもアクセス制御を行う必要がある。

これを実装してようやく、管理者だけがユーザーを削除できるようにする。

またbeforeアクションを使う。今回は:admin_userをdestoryアクションのみに適用する。

users_controller.rb
  before_action :admin_user,     only: :destroy

    def admin_user                                                              # 管理者のみに適用
      redirect_to(root_url) unless current_user.admin?                          # 現在のユーザーが管理者でなければroot_urlへリダイレクト
    end

演習

1:管理者ユーザーとしてログインし、試しにサンプルユーザを2~3人削除してみる。
ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるか?

スクリーンショット 2019-01-15 13.09.59.png

   (0.1ms)  begin transaction
  SQL (2.6ms)  DELETE FROM "users" WHERE "users"."id" = ?  [["id", 3]]
   (8.5ms)  commit transaction

transaction処理でid=3のユーザーを削除していることが分かる。

10.4.3 ユーザー削除のテスト

ユーザーを削除するといった重要な操作については、期待された通りに動作するか確かめるテストを書くべき。

まずは、fixtureファイルのmichaelに管理者権限を与える。

users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true

次に、Usersコントローラをテストするために、アクション単位でアクセス制御をテストする。
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。

この時、2つのケースをチェック

①ログインしていないユーザーであれば、ログイン画面にリダイレクトさせる
②ログイン済みであっても管理者でなければ、ホーム画面にリダイレクトさせる。

users_controller_test.rb
    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

assert_no_differenceメソッドを使って、ユーザー数が変化しないことを確認している点に注目。

管理者ではないユーザーの振る舞いについて検証するが、管理者ユーザーの振る舞いと一緒に確認できるといい。
そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、今回のテストを追加していくことにする。

これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けそう。

今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、
ユーザーが削除されたことを確認する部分。

その部分のテストがこれ。

 assert_difference 'User.count', -1 do
   delete user_path(@other_user)
 end

7章ではassert_differenceメソッドを使ってユーザーが作成されたことを確認していたが、
今回は同じメソッドを使ってユーザーが削除されたことを確認。

具体的には、DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が1減ったかどうかを確認している。

したがって、管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストを全てまとめると、以下のようになる。

users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

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

各ユーザーの削除リンクをテストする時に、ユーザーが管理者であればスキップしている点にも注目。
これは、_user.html.erbで、ログイン時、現在のユーザーがログインしていて、なおかつ特定のユーザーがログイン成功したユーザーでない場合のみ削除リンクを表示しているから。

結果として、管理者権限のないユーザーに削除リンクが表示されてるため、このテストが通る。

$ rails t

演習

1:users_controllerの管理者ユーザーのbeforeフィルターをコメントアウトしてテストが失敗するか確認

確認済み。

10.5 最後に

ログイン/ログアウト、ユーザー一覧画面まで作り、管理者権限を持ったユーザーはユーザーを削除することができる仕組みも実装した。

この時点で、サンプルアプリケーションはWebサイトとしての十分な基盤が整ったと言える。
今後はアカウント有効化機能、パスワード再設定、投稿機能などを実装する。

いつも通りブランチ切ってマージ、Herokuにデプロイ

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push
$ rails test
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
$ heroku restart

seedを走らせることで、ユーザー一覧をローカル環境の標準順序と合わせている。

第11章へ

単語集

  • target="_blank"

新しい窓(ウィンドウ)、又はタブでリンク先を開く

  • type="hidden"

hidden属性の中で指定し、name属性で名前をつけ、value属性で送信される値を指定する。
そうすることで、非表示データを送信することができる。

  • allow_nil: true

validatesのオプションで、validatesに追加すると、trueならばnilの検証はスキップする。
なお、初期値はfalseである。

使い方

validates :password, presence: true, length: { minimum: 6 }, allow_nil: true

上記のように明示的にtrueを指定することで、passwordのバリデーションに引っ掛かったならば、nil(空かどうか)
の検証を行い、trueを返す(バリデーションを通す)

  • セキュリティモデル

セキュリティ上の制御機構のこと。
エンドユーザと管理者との間の権限を管理する。

  • before_action

何らかの処理を実行させる前に、特定のメソッドを実行させる。

  • フレンドリーフォワーディング

ログインしていないユーザーが何らかのページへアクセスした際に、ログイン画面に遷移させて、ユーザーがログイン後にそのページへリダイレクトさせてあげる施策のこと。

  • redirect_back_or(default)

引数に渡したデフォルト値を使い、一個前(直前)のページにリダイレクトするメソッド

  • default

デフォルト値を設定

  • forwarding_url

一個前のURLを記憶するsessionのキー。

使い方

session[:forwarding_url]
  • request.original_url

HTTPリクエストでクライアントがサーバーに渡した値を、_urlでURLを取得

  • get?

getしたかどうか聞いている。

  • permit

引数に取った属性のみ受け取りを許可し、オブジェクトに渡すメソッド。

15
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10