Ruby on Rails チュートリアル

プロダクト開発の0→1を学ぼう

古い過去アーカイブ版を表示しています。史料として残してありますが、既に更新が止まっておりセキュリティ上の問題もあるため、学習で使うときは最新版をご利用ください。

第3版 目次

第9章 ユーザーの更新・表示・削除

この章では、Usersリソース用のRESTアクション (7.1) のうち、これまで未実装だったeditupdateindexdestroyアクションを追加し、RESTアクションを完成させます。まずはユーザーが自分のプロファイルを自分で更新できるようにします。ここで早速第8章で実装した認証用のコードを使いますが、これは認可モデルについて説明する自然なキッカケになります。次に、すべてのユーザーを一覧できるようにします (もちろん認証を要求します)。これはサンプルデータとページネーション (pagination) を導入する動機にもなります。最後に、ユーザーを削除し、データベースから完全に消去する機能を追加します。ユーザーの削除はどのユーザーにも許可できるものではないので、管理ユーザーという特権クラスを作成し、このユーザーにのみ削除を許可するようにします。

9.1 ユーザーを更新する

ユーザー情報を編集するパターンは、(第7章)の新規ユーザーの作成と極めて似通っています。新規ユーザー用のビューを出力するnewアクションの代わりに、ユーザーを編集するためのeditアクションを作成すればよいのです。 POSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成すればよいのです (コラム3.3)。最大の違いは、ユーザー登録は誰でも実行できますが、ユーザー情報を更新できるのはそのユーザー自身に限られるということです。第8章の認証 (authentication) システムを使えば、before_actionを使用してこれを行えます。

では最初に、いつものようにupdating-usersトピックブランチを作成しましょう。

$ git checkout master
$ git checkout -b updating-users

9.1.1 編集フォーム

まずは編集フォームから始めます。モックアップは9.1のとおりです19.1のモックアップを動くページにするためには、Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装する必要があります。editアクションの実装から始めますが、ここではデータベースから適切なユーザーデータを読み込む必要があります。ここで注意して頂きたいのは、7.1ではユーザー編集ページの正しいURLが/users/1/editとなっていることです (ユーザーのidが1の場合)。ユーザーのidはparams[:id]変数で取り出すことができるのを思い出してください。つまり、リスト9.1のコードを使えばそのユーザーを指定できるということです。

images/figures/edit_user_mockup_bootstrap
図9.1: ユーザー編集ページのモックアップ
リスト9.1: ユーザーのeditアクション app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  private

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

ユーザー編集ページに対応するビュー を、リスト9.2に示します (このファイルは手動で作成する必要があります)。このコードがリスト7.13と極めて似通っていることに注目してください。重複が多いということは、それらのコードの繰り返しをパーシャルにまとめることができるということです。パーシャルにまとめる作業は演習の課題 (9.6) に回します。

リスト9.2: ユーザーのeditビュー app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= f.label :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 "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

上のコードでは、7.3.3で導入したerror_messagesパーシャルを再利用しています。ところで、Gravatarへのリンクでtarget="_blank"が使われていますが、これを使うとリンク先を新しいタブ (またはウィンドウ) で開くようになるので、別のWebサイトへリンクするときなどに便利です。

リスト9.1@userインスタンス変数使うと、編集ページがうまく描画されるようになります (9.2)。9.2の"Name"や"Email"の部分を見ると、Railsによって名前やメールアドレスのフィールドに値が自動的に入力されていることがわかります。これらの値は、@user変数の属性情報から引き出されています。

images/figures/edit_page_3rd_edition
図9.2 名前とメールアドレスが入力済みの、初期状態のユーザー編集ページ

9.2のHTMLソースを見てみると、少しだけ違う箇所もありますが、おおよそformタグは期待どおりに表示されています (リスト9.3)。。

リスト9.3: リスト9.2で定義されたeditフォーム (9.2) のHTML
<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />
  .
  .
  .
</form>

以下の入力フィールドに隠し属性があることに注目してください。

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

WebブラウザはネイティブではPATCHリクエスト (7.1でRESTの慣習として要求されている) を送信できないので、RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを「偽造」しています2

ここでもう1つ微妙な点を指摘しておきたいと思います。リスト9.2form_for(@user)のコードは、リスト7.13のコードと完全に同じです。だとすると、Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのでしょうか。その答えは、Railsは、ユーザーが新規なのか、それともデータベースに存在する既存のユーザーであるかを、Active Recordのnew_record?論理値メソッドを使用して区別できるからです。

$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false

Railsは、form_for(@user)を使用してフォームを構成すると、@user.new_record?trueのときにはPOSTを、falseのときにはPATCHを使用します。

仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新します。7.1で示したedit_user_pathという名前付きルートと、 リスト8.36で定義したcurrent_userというヘルパーメソッドを使うと、実装が簡単です。

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

完全なアプリケーションコードをリスト9.4に示します。

9.1.2 編集の失敗

本項では、7.3のユーザー登録に失敗したときと似た方法で、編集に失敗した場合について扱っていきます。まずはupdateアクションの作成から進めますが、これはリスト9.5にあるように、update_attributes (6.1.5) を使って送信されたparamsハッシュに基いてユーザーを更新します。無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングします。このパターンは以前にも出現したことを覚えているでしょうか。この構造はcreateアクションの最初のバージョン (リスト7.16) と極めて似通っています。

リスト9.5: ユーザーのupdateアクションの初期実装 app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def edit
    @user = User.find(params[:id])
  end

  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      # 更新に成功したときの処理
    else
      render 'edit'
    end
  end

  private

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

update_attributesへの呼び出しでuser_paramsを使用していることに注目してください。7.3.2でも説明したように、ここではStrong Parametersを使用してマスアサインメントの脆弱性を防止しています。

Userモデルのバリデーションとエラーメッセージのパーシャルが既にあるので (リスト9.2)、無効な情報を送信すると役立つエラーメッセージが表示されるようになっています (9.3)。

images/figures/edit_with_invalid_information_3rd_edition
図9.3 更新フォームの送信で発生したエラーメッセージ

9.1.3 編集失敗時のテスト

9.1.2では編集フォームの失敗時を実装しました。次に、コラム3.3で説明したテストのガイドラインに従って、エラーを検知するための統合テストを書いていきましょう。まずはいつものように、統合テストを生成するところから始めます。

$ rails generate integration_test users_edit
      invoke  test_unit
      create    test/integration/users_edit_test.rb

最初は編集失敗時の簡単なテストを追加します (リスト9.6)。リスト9.6のテストでは、まず編集ページにアクセスし、editビューが描画されるかどうかをチェックしています。その後、無効な情報を送信してみて、editビューが再描画されるかどうかをチェックします。ここで、PATCHリクエストを送るためにpatchメソッドを使っていることに注目してください。これはgetpostdeleteメソッドと同じように、HTTPリクエストを送信するためのメソッドです。

リスト9.6: 編集の失敗に対するテスト GREEN test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), user: { name:  "",
                                    email: "foo@invalid",
                                    password:              "foo",
                                    password_confirmation: "bar" }
    assert_template 'users/edit'
  end
end

この時点では、テストは成功しているはずです。

リスト9.7: GREEN
$ bundle exec rake test

9.1.4 TDDで編集を成功させる

今度は編集フォームが動作するようにしましょう。プロファイル画像の編集は、画像のアップロードをGravatarに任せてあるので、既に動作するようになっています。9.2の [change] リンクをクリックすれば、9.4のようにGravatarを編集できます。ではそれ以外の機能の実装にとりかかりましょう。

images/figures/gravatar_cropper
図9.4 Gravatarの画像調整インターフェイス (写真は誰かさん)

そろそろ、より快適にテストをするためには、アプリケーション用のコードを「実装する前に」統合テストを書いた方が便利だと気付いた読者もいるかもしれません。実際、そういったテストのことは「受け入れテスト (Acceptance Tests)」として呼ばれていて、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られています。実際に体験してもらうために、今回はテスト駆動開発を使ってユーザーの編集機能を実装してみましょう。

まずは、リスト9.6のテストを参考にして、ユーザー情報を更新する正しい振る舞いをテストで定義します (今回は有効な情報を送信するように修正します)。次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェックします。また、データベース内のユーザー情報が正しく変更されたかどうかも検証します。変更の結果をリスト9.8に示します。このとき、リスト9.8のパスワードとパスワード確認が空であることに注目してください。ユーザー名やメールアドレスを編集するときに毎回パスワードを入力するのは不便なので、(パスワードを変更する必要が無いときは) パスワードを入力せずに更新できると便利です。また、6.1.5で紹介した@user.reloadを使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している点にも注目してください。(こういった正しい振る舞いというのは一般に忘れがちですが、受け入れテスト (もしくは一般的なテスト駆動開発) では先にテストを書くので、効果的なユーザー体験について考えるようになります。)

リスト9.8: 編集の成功に対するテストRED test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), 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

テストにパスする必要のある、リスト9.8updateアクションは、リスト9.9に示したように、createアクション (リスト8.22) の最終的なフォームとほぼ同じです。

リスト9.9: ユーザーのupdateアクション RED app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
end

リスト9.9のキャプションにも書いておきましたが、このテストはまだREDのままのはずです。というのも、パスワードの長さに対するバリデーション (リスト6.39) があるので、パスワードやパスワード確認の欄を空にしておくとこれに引っかかってしまうからです (リスト9.8)。テストがGREENになるためには、パスワードのバリデーションに対して、空だったときの例外処理を加える必要があります。こういったときに便利なallow_nil: trueというオプションがあるので、これを validatesに追加します (リスト9.10)。

リスト9.10: パスワードが空のままでも更新できるようにする GREEN app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 }
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
  .
  .
  .
end

リスト9.10によって、新規ユーザー登録時に空のパスワードが有効になってしまうのかと心配になるかもしれませんが、安心してください。6.3.3で説明したように、has_secure_passwordでは (追加したバリデーションとは別に) オブジェクト生成時に存在性を検証するようになっているため、空のパスワードが新規ユーザー登録時に有効になることはありません。(それだけでなく、実は空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordのバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグがあったのですが、この問題も解決できてしまいます。)

このコードを追加したことにより、ユーザー編集ページが動くようになります (9.5)。すべてのテストを走らせてみて、成功したかどうか確かめてみてください。

リスト 9.11: GREEN
$ bundle exec rake test
images/figures/edit_form_working
図9.5: 編集に成功した結果

9.2 認可

ウェブアプリケーションの文脈では、認証 (authentication) はサイトのユーザーを識別することであり、認可 (authorization) はそのユーザーが実行可能な操作を管理することです。第8章で認証システムを構築したことで、認可のためのシステムを実装する準備もできました。

9.1のeditアクションとupdateアクションはすでに完全に動作していますが、セキュリティ上の大穴が1つ空いています。 どのユーザーでもあらゆるアクションにアクセスでき、ログインさえしていれば他のユーザーの情報を編集できてしまいます。この節では、ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないようにするセキュリティモデルを構築しましょう。

9.2.1では、ログインしていないユーザーが保護されたページにアクセスしようとした際のケースについて対処していきます。こういったケースはアプリケーションを使っていると普通に起こることなので、ログインページに転送して、そのときに分かりやすいメッセージも表示するようにしましょう。モックアップを9.6に示します。一方で、許可されていないページに対してアクセスするログイン済みのユーザーがいたら (たとえば他人のユーザー編集ページにアクセスしようとしたら)、ルートURLにリダイレクトさせるようにします (9.2.2)。

images/figures/login_page_protected_mockup
図9.6: 保護されたページにアクセスしたときのページのモックアップ

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

9.6のように転送させる仕組みを実装したいときは、Usersコントローラの中でbeforeフィルターを使います。beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組みです3。今回はユーザーにログインを要求するために、リスト9.12のようにlogged_in_userメソッドを定義してbefore_action :logged_in_userという形式で使います

リスト9.12: beforeフィルターにlogged_in_userを追加する RED app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
  private

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

    # beforeフィルター

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるので、ここでは適切な:onlyオプションハッシュを渡すことによって:edit:updateアクションにのみこのフィルタが適用されるように制限をかけています。

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

images/figures/protected_log_in_3rd_edition
図9.7 保護されたページにアクセスした直後のログインフォーム

リスト9.12のキャプションに記したように、今の段階ではテストは失敗します。

リスト9.13: RED
$ bundle exec rake test

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

このため、editアクションやupdateアクションをテストする前にログインしておく必要があります。解決策は簡単で、 8.4.6で開発したlog_in_asヘルパー (リスト8.50) を使うことです。修正した結果をリスト9.14に示します。

リスト9.14: テストユーザーでログインする GREEN test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful 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
end

(リスト9.14setupメソッド内でログイン処理をまとめてしまうことも可能です。しかし、9.2.3で片方のテストをログインする前に編集ページにアクセスするように変更したいので、ここでまとめてしまっても結局は元に戻すことになってしまいます。)

今度はテストスイートがパスするはずです。

リスト9.15: GREEN
$ bundle exec rake test

これでテストスイートがパスするようになりましたが、実はbeforeフィルターの実装はまだ終わっておりません。セキュリティモデルに関する実装を取り外してもテストがGREENになってしまうかどうか、実際にコメントアウトして確かめてみましょう (リスト9.16)。なんと悪いことに、すべてのテストが成功してしまいました。beforeフィルターをコメントアウトして巨大なセキュリティーホールが作られたら、テストスイートでそれを検出できるべきです。つまり、リスト9.16のコードはREDにならなければいけないのです。テストを書いて、この問題に対処しましょう。

リスト9.16: セキュリティモデルを確認するためにbeforeフィルターをコメントアウトする GREEN app/controllers/users_controller.rb
class UsersController < ApplicationController
  # before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
end

beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書いていきます。具体的には、正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行させてみて、flashにメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認してみましょう。7.1から、適切なリクエストはそれぞれGETPATCHであることがわかります。したがって、テスト内ではgetメソッドとpatchメソッドを使います。修正した結果をリスト9.17に示します。

リスト9.17: editupdateアクションの保護に対するテストする RED test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
    @user = users(:michael)
  end

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

  test "should redirect edit when not logged in" do
    get :edit, id: @user
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch :update, id: @user, user: { name: @user.name, email: @user.email }
    assert_not flash.empty?
    assert_redirected_to login_url
  end
end

ここで、getpatchも次のように

get :edit, id: @user

といった形で引数が渡されていることに注目してください。

patch :update, id: @user, user: { name: @user.name, email: @user.email }

上のコードでは、Railsの慣習によってid: @userという引数が自動的に@user.idに変換されています (これはコントローラでリダイレクトしたときと同様です)。2つ目のケースでは、ルーティングで正しく処理されるようにuserというハッシュも渡しています。(実は第2章のToyアプリケーションのUsersコントローラではテストも生成されていて、中を見ると上と同じコードになっています。)

この時点では、(beforeフィルターが無効のままなので) テストスイートはREDになるはずです。beforeフィルターのコメントアウトを元に戻して、GREENになるかどうか確かめてみましょう (リスト9.18)。

リスト9.18: beforeフィルターを再び有効化する GREEN app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
end

コメントアウトしていた箇所を元に戻すと、テストが成功するようになるはずです。

リスト9.19: GREEN
$ bundle exec rake test

これらのテストを実装したことによって、うっかり誰でも編集できてしまうバグがあっても、すぐに検知できるようになりました。

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

当然のことですが、ログインを要求するだけでは十分ではありません。ユーザーが自分の情報だけを編集できるようにする必要があります。9.2.1では、深刻なセキュリティ上の欠陥を見逃してしまうテストを見てきました。そこで本項では、セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていきます。したがって、Usersコントローラのテスト (リスト9.17) を補完するように、テストを追加するところから始めていきます。

まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加します。ユーザー用のfixtureファイルに2人目のユーザーを追加してみましょう (リスト9.20)。

リスト9.20: fixtureファイルに2人目のユーザーを追加する test/fixtures/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') %>

次に、 リスト8.50で定義したlog_in_asメソッドを使って、editアクションとupdateアクションをテストします (リスト9.21)。このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトしている点に注意してください。

リスト9.21: 間違ったユーザーが編集しようとしたときのテスト RED test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

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

  test "should redirect edit when not logged in" do
    get :edit, id: @user
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch :update, id: @user, user: { name: @user.name, email: @user.email }
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get :edit, id: @user
    assert flash.empty?
    assert_redirected_to root_url
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch :update, id: @user, user: { name: @user.name, email: @user.email }
    assert flash.empty?
    assert_redirected_to root_url
  end
end

別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにします (リスト9.22)。beforeフィルターのcorrect_user@user変数を定義しているため、リスト9.22ではeditupdateの各アクションから、@userへの代入文を削除している点にも注意してください。

リスト9.22: beforeフィルター (correct_user) を使って編集と更新を保護する GREEN app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

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

    # beforeフィルター

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

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

今度はテストスイートが成功するはずです。

リスト9.23: GREEN
$ bundle exec rake test

最後に、リファクタリングではありますが、一般的な慣習に倣ってcurrent_user?という論理値を返すメソッドを実装します。correct_userの中で使えるようにしたいので、Sessionsヘルパーの中にこのメソッドを追加します (リスト9.24)。このメソッドを使うと今までの

unless @user == current_user

といった部分が、次のように (少し) 分かりやすいコードになります。

unless current_user?(@user)
リスト9.24: current_user? メソッド app/helpers/sessions_helper.rb
module SessionsHelper

  # 与えられたユーザーをログイン
  def log_in(user)
    session[:user_id] = user.id
  end

  # 永続セッションとしてユーザーを記憶する
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

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

先ほどのメソッドを使って比較演算していた行を置き換えると、リスト9.25になります。

リスト9.25: 最終的なcorrect_userの実装 GREEN app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

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

    # beforeフィルター

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

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

ここまででWebサイトの認可機能は完成したかのように見えますが、後1つ小さなキズがあります。保護されたページにアクセスしようとすると、問答無用で自分のプロファイルページに移動させられてしまいます。別の言い方をすれば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後にはその編集ページにリダイレクトされるようにするのが望ましい動作です。リダイレクト先は、ユーザーが開こうとしていたページにしてあげるのが親切というものです。

実際のコードは少し複雑ですが、フレンドリーフォワーディングのテストは非常にシンプルに書くことができます。ログインした後に編集ページへのアクセスする、という順序を逆にしてあげるだけです (リスト9.14)。リスト9.26が示すように、実際のテストはまず編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく) 編集ページにリダイレクトされているかどうかをチェックするといったテストです。(なお、リダイレクトによってedit用のテンプレートが描画されなくなったので、リスト9.26では該当するテストを削除しています)

リスト9.26: フレンドリーフォワーディングのテスト RED test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_path(@user)
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), 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

失敗するテストが書けたので、ようやくフレンドリーフォワーディングを実装する準備ができました4。ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要があります。この動作をstore_locationredirect_back_orの2つのメソッドを使用して実現してみましょう。なお、これらのメソッドはSessionsヘルパーで定義しています (リスト9.27)。

リスト9.27: フレンドリーフォワーディングの実装 app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 記憶した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.url if request.get?
  end
end

転送先のURLを保存する仕組みは、8.2.1でユーザーをログインさせたときと同じで、session変数を使います。また、リクエスト先のURLを取得するために、リスト9.27ではrequestオブジェクトも使っています (request.urlでリクエスト先が取得できます)。

リスト9.27store_locationメソッドでは、 リクエストが送られたURLをsession変数の:forwarding_urlキーに格納しています。ただし、GETリクエストが送られたときだけ格納するようにしておきます。これによって、たとえばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにできます。これは稀なケースですが起こり得ます。たとえばユーザがセッション用のcookieを手動で削除してフォームから送信するケースなどです。こういったケースに対処しておかないと、POSTPATCHDELETEリクエストを期待しているURLに対して (リダイレクトを通して) GETリクエストが送られてしまい、場合によってはエラーが発生します。このため、if request.get?という条件文を使ってこのケースの対策しています5

先ほど定義したstore_locationメソッドを使って、早速beforeフィルターのlogged_in_userを修正してみます (リスト9.28)。

リスト9.28: ログインユーザー用beforeフィルターにstore_locationを追加する app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end
  .
  .
  .
  private

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

    # beforeフィルター

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

フォワーディング自体を実装するには、redirect_back_orメソッドを使用します。リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトします。デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトします (リスト9.29)。redirect_back_orメソッドでは、次のようにor演算子||を使用します。

session[:forwarding_url] || default

このコードは、値がnilでなければsession[:forwarding_url]を評価し、nilであれば与えられたデフォルトのURLを使用しますリスト9.27では、session.delete(:forwarding_url) という式を通して転送用のURLを削除している点に注意してください。これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまいます (このコードのテストは9.6の演習とします)。ちなみに、最初にredirect文を実行しても、セッションが削除される点を覚えておくとよいでしょう。実は、明示的にreturn文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しません。したがって、redirect文の後にあるコードでも、そのコードは実行されるのです。

リスト9.29: フレンドリーフォワーディングを備えたcreateアクション app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  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
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

これで、リスト9.26のフレンドリーフォワーディング用統合テストはパスするはずです。成功すれば、基本ユーザー認証機能とページ保護機能の実装は完了です。いつものように、以下を実行してテストスイートが 緑色 (成功) になることを確認してから先に進みましょう。

リスト9.30: GREEN
$ bundle exec rake test

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

この節では、いよいよ最後から2番目のユーザーアクションであるindexアクションを追加しましょう。このアクションは、すべてのユーザーを一覧表示します。その際、データベースにサンプルデータを追加する方法や、将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネート (paginate=ページ分割) の方法を学びます。ユーザーの一覧、ページネーション用リンク、移動用の [Users] リンクのモックアップを9.8に示します6

images/figures/user_index_mockup_bootstrap
図9.8: ユーザー一覧ページのモックアップ

9.3.1 ユーザーインデックス

ユーザーの一覧ページを実装するために、まずはセキュリティモデルについて考えてみましょう。ユーザーのshowページについては、今後も (ログインしているかどうかに関わらず) サイトを訪れたすべてのユーザーから見えるようにしておきますが、ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限します7

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

リスト9.31: indexアクションのリダイレクトをテストする RED test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  test "should redirect index when not logged in" do
    get :index
    assert_redirected_to login_url
  end
  .
  .
  .
end

次に、beforeフィルターのlogged_in_userindexアクションを追加して、このアクションを保護します (リスト9.32)。

リスト9.32: indexアクションにはログインを要求する GREEN app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  before_action :correct_user,   only: [:edit, :update]

  def index
  end

  def show
    @user = User.find(params[:id])
  end
  .
  .
  .
end

今度はすべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装します。Toyアプリケーションにも同じindexアクションがあったことを思い出してください (リスト2.5)。そのときと同様に、User.allを使ってデータベース上の全ユーザーを取得し、ビューで使用可能な@usersというインスタンス変数に代入させます (リスト9.33)。(すべてのユーザーを一気に読み出すとデータ量が多い場合に問題が生じるのではないかと思われた方、そのとおりです。このキズは9.3.3で修正します。)

リスト9.33: ユーザーのindexアクション app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.all
  end
  .
  .
  .
end

実際のインデックスページを作成するには、ユーザーを列挙してユーザーごとにliタグで囲むビューを作成する必要があります。ここではeachメソッドを使用してこれを行います。それぞれの行をリストタグulで囲いながら、各ユーザーのGravatarと名前を表示します (リスト9.34)。

訳注: 7.7の1つ目の演習 (リスト7.31) でgravatarメソッドを拡張していない場合、以下のリストがうまく動きません。まだの方は当該リストのコードを先に反映させておいてください。

リスト9.34: ユーザーのindexビュー app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

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

リスト9.34では、7.7の演習のリスト7.31の結果を利用しています。これは、Gravatarヘルパーにデフォルト以外のサイズを指定するオプションを渡します。この演習をまだやっていない場合は、リスト7.31に従ってUsersヘルパーファイルを更新してから先に進んでください。

CSS (正確にはSCSSですが) にもちょっぴり手を加えておきましょう (リスト9.35)。

リスト9.35: ユーザーのindexページ用のCSS app/assets/stylesheets/custom.css.scss
.
.
.
/* Users index */

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

最後に、サイト内移動用のヘッダーにユーザー一覧表示用のリンクを追加します。これにはusers_pathを使用し、7.1に残っている最後の名前付きルートを割り当てます。変更の結果をリスト9.36に示します。

これでユーザーのインデックスは完全に動くようになり、テストも全てパスするようになります。

リスト9.37: GREEN
$ bundle exec rake test

ところで、9.9のようにユーザーが1人のままではいささか寂しすぎます。もう少し何とかしてみましょう。

images/figures/user_index_only_one_3rd_edition
図9.9 ユーザー一覧ページにユーザーが1人しか表示されていない

9.3.2 サンプルのユーザー

この節では、一人ぼっちのユーザーに仲間を加えてあげることにします。複数のユーザーが表示されたユーザーインデックスページにするためには、ブラウザでサインアップページを表示してユーザーを手作業で1人ずつ追加するという方法もありますが、せっかくなのでRubyとRakeを使用してユーザーを一気に作成しましょう。

まず、GemfileFaker gemを追加します (リスト9.38)。これは、実際にありそうなユーザー名とメールアドレスを持つサンプルユーザーを自動的に作成するものです。

リスト9.38: GemfileにFakerを追加する
source 'https://rubygems.org'

gem 'rails',                '4.2.2'
gem 'bcrypt',               '3.1.7'
gem 'faker',                '1.4.2'
.
.
.

次に、いつものように以下を実行します。

$ bundle install

では、サンプルユーザーを生成するRakeタスクを追加してみましょう。Railsではdb/seeds.rbというファイルを標準として使います。結果をリスト9.39に示します。(リスト9.39のコードは少し応用的です。詳細が完全に理解できなくても問題ありません)

リスト9.39: データベース上にサンプルユーザーを生成するRakeタスク 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

リスト9.39のコードでは、Example Userという名前とメールアドレスを持つ1人のユーザと、それらしい名前とメールアドレスを持つ99人のユーザーを作成します。create!は基本的にcreateメソッドと同じものですが、ユーザーが無効な場合にfalseを返すのではなく例外を発生させる (6.1.4) 点が異なります。こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になります。

それでは、データベースをリセットして、リスト9.39のRakeタスクを実行 (db:seed) してみましょう8

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed

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

db:seedでRakeタスクを実行し終わると、サンプルアプリケーションのユーザーが100人になっています。9.10が示すように、 最初のいくつかのメールアドレスについては、デフォルトのGravatar画像以外の写真を関連付けてみました。(システム環境によっては、ここでRailsを再起動させる必要があるかもしれません。)

images/figures/user_index_all_3rd_edition
図9.9 ユーザー一覧ページに100人のサンプルユーザーが表示されている

9.3.3 ページネーション

これで、最初のユーザーにも仲間ができました。しかし今度は逆に、1つのページに大量のユーザーが表示されてしまっています。100人でもかなり大きい数であると思いますし、今後は数千ユーザーに増える可能性もあります。これを解決するのがページネーション (pagination) というもので、この場合は、たとえば1つのページに一度に30人だけユーザーを表示するというものです。

Railsには豊富なページネーションメソッドがあります。今回はその中で最もシンプルかつ堅牢なwill_paginateメソッドを使用してみましょう。これを使用するには、Gemfileにwill_paginate gem とbootstrap-will_paginate gemを両方インクルードし、Bootstrapのページネーションスタイルを使用してwill_paginateを構成します。更新したGemfileリスト9.40に示します。

リスト9.40: Gemfilewill_paginateを追加する
source 'https://rubygems.org'

gem 'rails',                   '4.2.2'
gem 'bcrypt',                  '3.1.7'
gem 'faker',                   '1.4.2'
gem 'will_paginate',           '3.0.7'
gem 'bootstrap-will_paginate', '0.0.10'
.
.
.

次にbundle installを実行します。

$ bundle install

新しいgemが正しく読み込まれるように、Webサーバーを再起動してください。

ページネーションが動作するには、ユーザーのページネーションを行うようにRailsに指示するコードをindexビューに追加する必要があります。また、indexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もあります。まずは、ビューに特殊なwill_paginateメソッドを追加しましょう (リスト9.41)。同じコードがリストの上と下に2つありますが、その理由はこの後で説明します。

リスト9.41: ユーザー一覧ページでpaginationを使う app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</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オブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成しています。ただし、リスト9.41のビューはこのままでは動きません。というのも、現在の@users変数にはUser.allの結果が含まれていますが (リスト9.33)、will_paginateではpaginateメソッドを使った結果が必要だからです。必要となるデータの例は次のとおりです。

$ rails console
>> User.paginate(page: 1)
  User Load (1.5ms)  SELECT "users".* FROM "users" LIMIT 30 OFFSET 0
   (1.7ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1,...

paginateでは、キーが:pageで値がページ番号のハッシュを引数に取ります。User.paginateは、:pageパラメーターに基いて、データベースからひとかたまりのデータ (デフォルトでは30) を取り出します。従って、1ページ目は1から30のユーザー、2ページ目は31から60のユーザーといった具合にデータが取り出されます。ちなみにpagenilの場合、 paginateは単に最初のページを返します。

paginateを使用することで、サンプルアプリケーションのユーザーのページネーションを行えるようになります。具体的には、indexアクション内のallpaginateメソッドに置き換えます (リスト9.42)。ここで:pageパラメーターにはparams[:page]が使用されていますが、これはwill_paginateによって自動的に生成されます。

リスト9.42: indexアクションでUsersをページネートする app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.paginate(page: params[:page])
  end
  .
  .
  .
end

以上で、ユーザー一覧ページは9.11のように動作するはずです (システム環境によっては、ここでRailsを再起動する必要があるかもしれません)。will_paginateをユーザーリストの上と下の両方に配置してあるので、ページネーションのリンクもページの上と下の両方に表示されています。

images/figures/user_index_pagination_3rd_edition
図9.11: ページネーションされたユーザー一覧ページ

[2] リンクまたは [Next] リンクをクリックすると、9.12のように次のページに移動します。

images/figures/user_index_page_two_3rd_edition
図9.12 ユーザー一覧の2ページ目

9.3.4 ユーザーインデックスのテスト

これでユーザー一覧ページが動くようになったので、9.3.3のページネーションに対する簡単なテストも書いておきましょう。今回のテストでは、ログイン、indexページにアクセス、最初のページにユーザーがいることを確認、ページネーションのリンクがあることを確認、といった順でテストしていきます。最後の2つのステップでは、テスト用のデータベースに31人以上のユーザーがいる必要があります。

リスト9.20で2人目のユーザーをfixtureに追加しましたが、今回はもっと多くのユーザーを作成する必要があります。手動で追加するのは面倒そうですね。幸運には、ユーザー用fixtureファイルのpassword_digest属性で使ったように、fixtureでは埋め込みRubyをサポートしています。これを利用してさらに30人のユーザーを追加してみましょう (リスト9.43)。なお、今後必要になるので、リスト9.43では2人の名前付きユーザーも一緒に追加しています。

リスト9.43: fixtureにさらに30人のユーザーを追加する test/fixtures/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') %>

mallory:
  name: Mallory 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 %>

リスト9.43のfixtureファイルができたので、indexページに対するテストを書いてみます。まずは、いつものように統合テストを生成します。

$ rails generate integration_test users_index
      invoke  test_unit
      create    test/integration/users_index_test.rb

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

リスト9.44: ユーザー一覧とページネーションに対するテスト GREEN test/integration/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

このテストは成功するはずです。

リスト9.45: GREEN
$ bundle exec rake test

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

ユーザー一覧ページにページネーションを実装することができましたが、私はここで1つの改良を加えてみたいのです。実はRailsにはコンパクトなビューを作成するための素晴らしいツールがいくつもあります。この節ではそれらのツールを使用して一覧ページのリファクタリング (動作を変えずにコードを整理すること) を行うことにします。サンプルアプリケーションのテストは既に完了しているので、Webサイトの機能を損なうことなく安心してリファクタリングに取りかかれます。

リファクタリングの第一歩は、リスト9.41のユーザーのlirender呼び出しに置き換えることです (リスト9.46)。

リスト9.46: indexビューに対する最初のリファクタリング app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

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

<%= will_paginate %>

ここでは、renderをパーシャルの名前の文字列に対してではなく、Userクラスのuser変数に対して実行していることに注意してください9。この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探します。このパーシャルを作成する必要があります (リスト9.47)。

リスト9.47: 各ユーザーを表示するパーシャル app/views/users/_user.html.erb
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

これは間違いなく大きな進歩です。しかしここで終わらせず、さらに改良してみましょう。今度はrender@users変数に対して直接実行します (リスト9.48)。

リスト9.48: ユーザー一覧ページの完全なリファクタリング GREEN app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

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

<%= will_paginate %>

Railsは@usersUserオブジェクトのリストであると推測します。さらに、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力します (訳注: each doとendで囲む必要がなくなります)。これにより、リスト9.48のコードは極めてコンパクトになります。

これに限らず、リファクタリングを行う場合には、アプリケーションのコードを変更する前と後で必ずテストを実行し、いずれも成功になることを確認するようにしてください。

リスト9.49: GREEN
$ bundle exec rake test

9.4 ユーザーを削除する

ユーザーの一覧ページはついに完了しました。残るはdestroyだけです。これを実装することで、RESTに準拠した正統なアプリケーションとなります。この節では、ユーザーを削除するためのリンクを追加します。モックアップを9.13に示します。また、削除を行うのに必要なdestroyアクションも実装します。しかしその前に、削除を実行できる権限を持つ管理 (admin) ユーザーのクラスを作成しましょう。

9.4.1 管理ユーザー

特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加します。この後で説明しますが、こうすると自動的にadmin?メソッド (論理値を返す) も使えるようになりますので、これを使用して管理ユーザーの状態をテストできます。変更後のデータモデルは9.14のようになります。

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

ここでいつものように、マイグレーションを実行してadmin属性を追加しましょう。コマンドラインで、この属性の型をbooleanと指定します。

$ rails generate migration add_admin_to_users admin:boolean

マイグレーションを実行するとadminカラムがusersテーブル (リスト9.50) に追加されます。リスト9.50では、default: falseという引数をadd_columnに追加しています。これは、デフォルトでは管理者になれないということを示すためです (default: false引数を与えない場合、 adminの値はデフォルトでnilになりますが、これはfalseと同じ意味ですので、必ずしもこの引数を与える必要はありません。ただし、このように明示的に引数を与えておけば、コードの意図をRailsと開発者に明確に示すことができます)。

リスト9.50: boolean型のadmin属性をUserに追加するマイグレーション db/migrate/[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

後はいつものようにマイグレーションを実行します。

$ bundle exec rake db:migrate

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

$ rails console --sandbox
>> user = User.first
>> user.admin?
=> false
>> user.toggle!(:admin)
=> true
>> user.admin?
=> true

ここではtoggle!メソッドを使用して admin属性の状態をfalseからtrueに反転しています。

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

リスト9.51: サンプルデータ生成タスクに管理者を1人追加する db/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

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

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed

Strong Parameters、再び

リスト9.51では、初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしていることにお気付きになりましたでしょうか。ここでは、荒れ狂うWeb世界にオブジェクトをさらすことの危険性を改めて強調しています。もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は以下のようなPATCHリクエストを送信してくるかもしれません10

patch /users/17?admin=1

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

このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になります。7.3.2で説明したとおり、Strong Parametersを使用してこれを行います。具体的には、 以下のようにparamsハッシュに対してrequirepermitを呼び出します。

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

上のコードでは、許可された属性リストにadminが含まれていないことに注目してください。これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できます。この問題は重大であるため、編集可能になってはならない属性に対するテストを作成することをぜひともお勧めします。admin属性のテストについては演習に回します (9.6)。

9.4.2 destroyアクション

Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加しましょう。まず、ユーザーインデックスページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限します。これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示されるようになります (リスト9.52)。

ここで、必要なDELETEリクエストを発行するリンクの生成はmethod: :delete引数によって行われている点に注目してください。 また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしています。管理者から見えるページを9.15に示します。

ブラウザはネイティブではDELETEリクエストを送信できないため、RailsではJavaScriptを使用してこれを偽造します。つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になるということです。JavaScriptをサポートしないブラウザをサポートする必要がある場合は、フォームとPOSTリクエストを使用してDELETEリクエストを偽造することもできます。こちらはJavaScriptがなくても動作します11

この削除リンクが動作するためには、destroyアクション (7.1) を追加する必要があります。このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使用して削除し、最後にユーザーインデックスに移動します (リスト9.53)。ユーザーを削除するためにはログインしていなくてはならないので、リスト9.53では:destroyアクションもlogged_in_userフィルターに追加しています。

リスト9.53: 実際に動作するdestroyアクションを追加する app/controllers/users_controller.rb
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

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

User.find(params[:id]).destroy

結果として、管理者だけがユーザーを削除できるようになります (より具体的には、削除リンクが見えているユーザーのみ削除できる)。しかし、実はまだ大きなセキュリティホールがあります。ある程度の腕前を持つ攻撃者なら、コマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができるでしょう。サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要があります。これを実装してようやく、管理者だけがユーザーを削除できるようにします。

9.2.19.2.2と同じように、今回はbeforeフィルターを使ってdestroyアクションへのアクセスを制御します。実装するadmin_userフィルターをリスト9.54に示します。

リスト9.54: beforeフィルターでdestroyアクションを管理者だけに限定する app/controllers/users_controller.rb
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
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

9.4.3 ユーザー削除のテスト

ユーザー削除と同じくらい重要なことは、その振る舞いが期待されたかどうかを確かめる良いテストを書くことです。そこで、まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にしてみます (リスト9.55)。

リスト9.55: fixture内の最初のユーザーを管理者にする test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true

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

mallory:
  name: Mallory 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 %>

9.2.1で経験してきたように、Usersコントローラをテストするために、アクション単位でアクセス制御をテストします。リスト8.28のログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させます。このとき2つのケースをチェックします。1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされることです。もう1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされることです。変更の結果をリスト9.56に示します。

リスト9.56: 管理者権限の制御をアクションレベルでテストする GREEN test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect destroy when not logged in" do
    assert_no_difference 'User.count' do
      delete :destroy, id: @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 :destroy, id: @user
    end
    assert_redirected_to root_url
  end
end

このとき、リスト9.56ではassert_no_differenceメソッド (リスト7.21) を使って、ユーザー数が変化しないことを確認している点に注目してください。

リスト9.56のテストでは、管理者ではないユーザーの振る舞いについて検証していますが、管理者ユーザーの振る舞いと一緒に確認できるとよさそうです。そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、リスト9.44のテストに今回のテストを追加していくことにします。これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けそうです。さて、今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、ユーザーが削除されたことを確認する部分です。今回は次のようなテストでこれを実現しました。

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

リスト7.26ではassert_differenceメソッドを使ってユーザーが作成されたことを確認しましたが、今回は同じメソッドを使ってユーザーが削除されたことを確認しています。具体的には、DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が\( -1 \)減ったかどうかを確認しています。

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

リスト9.57では各ユーザーの削除リンクをテストするときに、ユーザーが管理者であればスキップしている点にも注目してください (これはリスト9.52により、管理者であれば削除リンクが表示されないからです)。

これで、削除に関するコードに対して、よくテストできている状態になりました。テストスイートを走らせると成功するはずです。

リスト9.58: GREEN
$ bundle exec rake test

9.5 最後に

5.4でUsersコントローラをご紹介して以来、長い道のりをたどってきました。あの頃はユーザー登録すらありませんでしたが、今は登録もログインもログアウトもできます。プロフィールの表示も、設定の編集も、すべてのユーザーの一覧画面もあります。さらに、一部のユーザーは他のユーザーを削除することすらできるようになりました。

この時点で、サンプルアプリケーションはWebサイトとしての十分な基盤 (ユーザーを認証したり認可したり) が整ったといえるでしょう。第10章では、さらに2つの改善を加えます。メールアドレスを使ってアカウントを有効化する機能と (すなわち本当に有効なメールアドレスか検証するプロセスと)、ユーザーがパスワードを忘れてしまったときのためのパスワードリセット機能です。

次の章に進む前に、すべての変更をmasterブランチにマージしておきましょう。

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push

アプリケーションを本番展開したり、サンプルデータを本番データとして作成することもできます (本番データベースをリセットするにはpg:resetタスクを使用します)。

$ bundle exec rake test
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rake db:migrate
$ heroku run rake db:seed
$ heroku restart

もちろん、実際のWebサイトではサンプルデータを生成したくないという人もいるかと思いますが、これには理由があります (9.16)。それは、9.16が示すように、サンプルユーザーの表示順序が変化してしまい、9.11にあるようなローカル環境での表示順序と異なってしまうことです。これは現時点ではまだデフォルトの表示順序が指定されていないことが原因です。 結果として、データベースの内容に応じて表示順序が異なってしまいます。それだけのことかと思われるかもしれませんが、これは今後マイクロポストを実装するときに問題となります。なお、この問題については11.1.4で解決していきます。

images/figures/heroku_sample_users
図9.16: 本番環境のユーザー一覧ページ

9.5.1 本章のまとめ

  • ユーザーは、編集フォームからPATCHリクエストをupdateアクションに対して送信し、情報を更新する
  • Strong Parametersを使うことで、安全にWeb上から更新させることができる
  • beforeフィルターを使うと、特定のアクションが実行される直前にメソッドを呼び出すことができる
  • beforeフィルターを使って、認可 (アクセス制御) を実現した
  • 認可に対するテストでは、特定のHTTPリクエストを直接送信する低級なテストと、ブラウザの操作をシミュレーションする高級なテスト (統合テスト) の2つを利用した
  • フレンドリーフォワーディングとは、ログイン成功時に元々行きたかったページに転送させる機能である
  • ユーザー一覧ページでは、すべてのユーザーをページ毎に分割して表示する
  • rake db:seedコマンドは、db/seeds.rbにあるサンプルデータをデータベースに流し込む
  • render @usersを実行すると、自動的に_user.html.erbパーシャルを参照し、各ユーザーをコレクションとして表示する
  • 論理属性adminを追加すると、自動的にuser.admin?メソッドが使えるようになる
  • 管理者が削除リンクをクリックすると、DELETEリクエストがdestroyアクションに向けて送信され、該当するユーザーが削除される
  • fixtureファイル内で埋め込みRubyを使うと、多量のテストユーザーを作成することができる

9.6 演習

: 『演習の解答マニュアル (英語)』にはRuby on Railsチュートリアルのすべての演習の解答が掲載されており、www.railstutorial.orgで原著を購入いただいた方には無料で配布しています (訳注: 解答は英語です)。

なお、演習とチュートリアル本編の食い違いを避ける方法については、演習用のトピックブランチに追加したメモ (3.6) を参考にしてください。

  1. フレンドリーフォワーディングで、最初に与えられたURLにのみ確実に転送されていることを確認するテストを作成してください。続けてログインを行った後、転送先のURLはデフォルト (プロフィール画面) に戻る必要もありますので、これもテストで確認してください。ヒント: リスト9.26session[:forwarding_url]の正しい値を確認するテストを追加してください。
  2. レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。ヒント: log_in_asヘルパーを使ってリスト5.25にテストを追加してみましょう。
  3. Web経由でadmin属性を変更できないことを確認してください。リスト9.59に示したように、PATCHリクエストを updateメソッドに直接発行するテストを作成してください。テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminuser_paramsメソッド内の許可されたパラメータ一覧に追加するところから始めてみましょう。最初のテストはREDになるはずです。
  4. リスト9.60のパーシャルを使用して、new.html.erbビューとedit.html.erbビューをリファクタリングし、コードの重複を取り除いてください。このとき、リスト9.61のようにフォーム変数fを明示的にローカル変数として渡す必要があることに注意してください。また、provide関数を使うと、パーシャル化したnewフォームやeditフォームの重複をさらに取り除くことも可能です (Jose Carlos Montero Gómezの指摘に感謝します)。
リスト9.59: admin属性の変更が禁止されていることをテストする test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionController::TestCase

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch :update, id: @user, user: { name: @user.name, email: @user.email }
    assert_redirected_to root_url
  end

  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 :update, id: @other_user, user: { password:              FILL_IN,
                                            password_confirmation: FILL_IN,
                                            admin: FILL_IN }
    assert_not @other_user.FILL_IN.admin?
  end
  .
  .
  .
end
リスト9.60: newフォームとeditフォームをパーシャル化する app/views/users/_form.html.erb
<%= form_for(@user) do |f| %>

  <%= render 'shared/error_messages', object: @user %>

  <%= f.label :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 %>
リスト9.61: newビューをパーシャル化する app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:button_text, 'Create my account') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
      <%= render 'form' %>
  </div>
</div>

リスト9.62: editビューをパーシャル化する app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<% provide(:button_text, "Save changes") %>
<h1>Update your profile</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render "form" %>
    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">Change</a>
    </div>
  </div>
</div>
  1. 画像はhttp://www.flickr.com/photos/sashawolff/4598355045/からの引用です。
  2. この動作の詳細を気にする必要はありません (悪いことをしているわけでもありません)。この詳細に関心を抱くのはRailsフレームワークそのものの開発者ぐらいであり、Railsでアプリケーションを開発する人にとっては重要ではありません。
  3. beforeフィルター (before filters) はこれまでbefore_filterと呼ばれていましたが、「アクションの直前で実行される」という点を強調するためにRailsコアチームによって名前が変更されました。
  4. このセクションのコードでは、thoughtbot社が提供するClearance gemを適用しています。
  5. Yoel Adlerの指摘によって、この問題と解決策が見つかりました。感謝いたします。
  6. 子供の写真はhttp://www.flickr.com/photos/glasgows/338937124/からの引用です。
  7. ちなみにこれはTwitterの認可モデルと同じです。
  8. 原理的には、rake db:resetコマンド1つでこれら2つのタスクを実行することがもできますが、最新のRailsだとうまく動かないのでこのようにしています。
  9. このuserという名前そのものはまったく重要ではないことに注意してください。たとえばuserをfoobarに置き換え、@users.each do |foobar|と書いてからrender foobarと呼び出しても問題なく動作します。重要なのは、そのオブジェクトそのものではなく、そのオブジェクトが属しているクラス (この場合はUserクラス) の方です。
  10. curlなどのコマンドラインツールを使用すると、PATCHリクエストをこの形式で送信することができます。
  11. 詳しくはRailsCastの “JavaScriptを使わない削除” (英語) を観てください。
前の章
第9章 ユーザーの更新・表示・削除 Rails 4.2 (第3版)
次の章