noggy’s blog

自分用の備忘録です。。。

10.4 ユーザーの削除

ユーザーの一覧ページは完了した。残るはdestroyだけ。
これを実装することで、RESTに準拠した正統なアプリとなる。
ここでは、ユーザーを削除するためのリンクを追加する。また、削除を行うのに必要なdestroyアクションも実装する。

まず、削除を実行できる権限をもつ管理(admin)ユーザーのクラスを作成。

10.4.1 管理ユーザー

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

変更後のデータモデルは次の通り

id integer
name string
email string
created_at datetime
updated_at datetime
password_digest string
remember_digest string
admin boolean
アドミン属性をUserモデルに追加

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

 rails g migration add_admin_to_users admin:boolean 

マイグレーションを実行するとadminカラムがusersテーブルに追加される。
下の①ではdefault: falseという引数をadd_columnに追加している。
これは、デフォルトでは管理者になれないと示すため(default: false引数を与えない場合、adminの値はnilになるが、これはfalseと同じ意味になるので、必ずしもこの引数を与える必要はない。ただし、このように明示的に引数を与えておけば、てコードの意図をRailsと開発者に明確に示しことができる)。
db/migrate/[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.admin?
=> false
>> user.toggle!(:admin) ←①
=> true
>> user.admin?
=> true

①:toggle!メソッドでadmin属性をfalseからtrueに反転している。。

仕上げに、最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新する(下の①)。
db/seed.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.3.2で説明した通りStrong Parametersを使って対策をする。具体的には、次のようにparamsハッシュに対してrequirepermitを呼び出す。

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

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

10.4.2 destroyアクション

Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加する。まず、ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。これによって、現在のユーザーが管理者のときに限り[delete]リンクが表示されるようになる。
views/users/_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>

①:current_userが管理者かつ(削除対象の)ユーザがcurrent_userでない。ここでいうユーザーはパーシャルの呼び出し元のindex.html.erbeach文で取り出したuser変数のこと。管理者が自分自身を削除できないようになっている。


必要なDELETEリクエストを発行するリンクの生成は、method: :deleteによって行われている点に注意!また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしている。ブラウザはネイティブではDELETEリクエストを送信できないため、RailsではJavaScriptを使って偽造する。つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になるとういうこと!JavaScriptをサポートしないブラウザをサポートする必要がある場合は、フォームとPOSTリクエストを使ってDELETEリクエストを偽造することもできる。こちらはJavaScriptがなくても動作する。

destroyアクションの実装

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

①で:findメソッドとdestroyメソッドを1行で書くために2つのメソッドを連結している点に注目。結果として、管理者だけがユーザーを削除できるようになる。(より具体的には、削除リンクが見えているユーザーのみ削除できる)。

しかし、実はまだ大きなセキュリティホールがある。ここでコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができてしまう。サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要がある。これを実装してようやく、管理者だけがユーザーを削除できるようにする。

destroyアクションへのアクセス制御

今回はbeforeフィルターを使ってdestroyアクションへのアクセスを制御する。実装するadmin_userフィルターを次に示す。

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

①:現在のユーザーが管理者でなければroot_urlへリダイレクト

10.4.3 ユーザー削除のテスト(重要)

ユーザーを削除するといった重要な操作については、期待された通りに動作するか確かめるテストを書くべき。そこで、まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの1人を管理者にしてみる。
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.com
  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 %>      

次に、Usersコントローラをテストするために、アクション単位でアクセス制御をテストする。
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。
このとき2つのケースをチェックする。
1.ログインしていないユーザーであれば、ログイン画面にリダイレクトされる
2.ログイン済みであっても管理者でなければ、ホーム画面にリダイレクトさせれる
作成したコードが次
test/controllers/users_controller.rb

  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 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メソッドを使って、ユーザー数が、ブロックで渡されたものを呼び出す前と呼び出した後で違いがなければtrue

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

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

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

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

具体的には、DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が1減ったかどうかを確認している。したがって、管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストを全てまとめると、次のようになる。
test/integration/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

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