静的型付け言語原理主義者によるRailsチュートリアル(第10章)

image

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

  • User リソース用の REST アクションのうち、未実装だった edit update index destroy アクションを加え、REST を完成させる

10.1 ユーザーを更新する

  • ユーザー情報を編集するには、新規ユーザー用のビューを出力する new アクションと同様に、ユーザーを編集するためのアクションを作成する
  • 同様に、POST リクエストに応答する create の代わりに、PATCH リクエストに応答する update アクションを作成する

10.1.1 編集フォーム

  • Users コントローラに edit アクションを追加し、それに対応する edit ビューを実装する
  • ユーザーの id は params[:id] 変数で取り出すことが可能
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
  • ユーザーの edit ビューを作成
<% 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>
  • Rails によって @user 変数の属性情報が引き出され、名前やメールアドレスのフィールドに値が自動入力される
  • Web ブラウザではネイティブで PATCH リクエストを送信できないので、Rails では POST リクエストと隠し input フィールドを利用して PATCH リクエストを偽造している
    • めちゃ微妙。。。
  • Rails は form_for(@user) を使用してフォームを構成すると、@user.new_record?true のときには POST を、false のときには PATCH を使う
    • これまた微妙。。。

10.1.2 編集の失敗

  • ユーザー情報の編集に失敗した場合
  • ユーザーの update アクションの初期実装
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 を使っている
    • Strong Parameters を使ってマスアサインメントの脆弱性を防止している

10.1.3 編集失敗時のテスト

  • いつものように統合テストを書いていく
    • まず編集ページにアクセスし、edit ビューが描画されるかをチェック
    • その後無効な情報を送信し、edit ビューが再描画されるかをチェック
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), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
  end
end

10.1.4 TDD で編集を成功させる

  • 編集フォームが動作するようにするために統合テストも TDD で実装していく
  • ユーザー情報を更新する正しい振る舞い
    • flash メッセージが空でないか
    • プロフィールページにリダイレクトされるか
    • DB 内のユーザー情報が正しく変更されたか
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), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end
  • 上記テストをパスするためにユーザーの update アクションを追加する
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
  • この段階ではまだテストは通らない
    • パスワードのバリデーションに対して、空だった場合の例外処理を加える必要がある
class User < ApplicationRecord
  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
  • 上記修正により新規ユーザー登録時にからのパスワードが有効になってしまうのではという危惧
    • has_secure_password でオブジェクト生成時に存在性を検証するようになっているため、空のパスワードが新規ユーザー登録時に有効になることはない

10.2 認可

  • 認証(authentication)
    • サイトのユーザーを識別すること
  • 認可(ahtuorization)
    • そのユーザーが実行可能な操作を管理すること
  • 本章の edit アクションと update アクションにおけるセキュリティホール
    • どのユーザーでもあらゆるアクションにアクセスできるため、ログインしていないユーザーでもユーザー情報を編集できてしまう
      • ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する
  • ログインしていないユーザーが保護されたページにアクセスしようとしたとき
    • ログインページに転送する
      • よくあるやつ

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

  • Users コントローラの中で before フィルターを使用する
    • before_action メソッドで何らかの処理が実行される直前に特定のメソッドを実行する
    • before フィルターに logged_in_user を追加する
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
  • edit アクションや update アクションでログインを要求するようになっため、テストが失敗する
    • 上記アクションをテストする前にログインしておく必要がある
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
  • テストは通るようになったが、セキュリティモデルに関する実装を覗いてもテストが通ってしまう
    • before フィルターをコメントアウトしてセキュリティホールが作られたときにはテストで検出できるようになっているべき
  • before フィルターは基本的にアクションごとに適用していくので、Users コントローラのテストもアクションごとに書いていく
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
    assert_not flash.empty?
    assert_redirected_to login_url
  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?
    assert_redirected_to login_url
  end
end

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

  • ログインを要求するだけでは不十分で、ユーザーが自分の情報だけを編集できるようにする必要がある
    • 例によって TDD で
  • まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加する
  • 次に log_in_as メソッドを使って、edit アクションと update アクションをテストする
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get edit_user_path(@user)
    assert flash.empty?
    assert_redirected_to root_url
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end
end
  • 別のユーザーのプロフィールを編集しようとした場合はリダイレクトさせたいので、current_user というメソッドを作成し、 before フィルターからこのメソッドを呼び出せるようにする
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
  • 一般的な慣習に倣って current_user? という論理値を返すメソッドを Session ヘルパーに追加し、しれを呼ぶようにリファクタリングを行う
    • だから慣習って何よ。。。

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

  • 保護されたページにアクセスしようとすると、問答無用で自分のプロフィールページに移動させられてしまう
    • リダイレクト先はユーザーが開こうとしていたページにしてあげるのが親切
  • フレンドリーフォワーディングのテストはシンプルに書くことができる
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_url(@user)
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end
  • ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある
    • store_locationredirect_back_or の2つのメソッドを使って実現してみる
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.original_url if request.get?
  end
end
  • store_location メソッドで before フィルター( logged_in_user )を修正してみる
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 アクションに追加し、サインイン成功後にリダイレクトする
  • session.delete(:forwarding_url) という行を通して転送用の URL を削除している
    • 次回ログイン時に保護されたページに転送されるのを防ぐため
    • 転送用の URL を削除する動作は redirect 文の後に置かれていても実行される
      • 明示的に return 文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しない
        • 思わぬバグを踏みそうな仕様だ。。。
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
  • 統合テストがパスし、基本ユーザー認証機能とページ保護機能の実装が完了
    • 対して実装していないがこの機能が実現できる凄さよ

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

  • すべてのユーザーを一覧表示する index アクションを追加する

10.3.1 ユーザーの一覧ページ

  • ユーザーの show ページについてはすべてのユーザーから見えるようにしておくが、ユーザーの index ページはログインしたユーザーにしか見せないようにし、未登録ユーザーがデフォルトで表示できるページを制限する
  • まずは index アクションのリダイレクトをテストする
    • 例によって TDD
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

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

  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end
  • before フィルターの logged_in_userindex アクションを追加し、このアクションを保護する
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 ビューを実装する
    • User.all を使って DB 上の全ユーザーを取得し、ビューで使えるインスタンス変数 @user に代入する
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.all
  end
  .
  .
  .
end
  • ユーザーのindexビューを作成する
<% 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>
  • SCSS 側も修正しておく
.
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}
  • 最後にサイト内移動用のヘッダーにユーザー一覧表示用のリンクを追加する
<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", users_path %></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>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

10.3.2 サンプルのユーザー

  • Rubyを使ってユーザーを一気に作成してみる
  • Gemfile に Faker gem を追加して bundle install
    • 中二心をくすぐる名前
    • 実際にいそうなユーザー名を作成する gem
  • サンプルユーザーを追加する Ruby スクリプトを追加する
    • Rails タスクとも言う
    • 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
  • DB をリセットし、上記 Rails タスクを実行する
    • 100人分なので少し時間がかかる

10.3.3 ページネーション

  • 大量のユーザー表示に対応するためにページネーションを実装する
    • Gemfile に will_paginate gem と bootstrap-will_paginate gem を追加する
      • gem で対応できるのが Rails っぽい
  • ページネーションが動作するには、ユーザーのページネーションを行うように Rails に指示するコードを index ビューに追加する必要がある
    • まずはビューに will_paginate メソッドを追加する
<% 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 オブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成している
    • どうやってるの。。。
    • @users 変数には User.all の結果が含まれているが、will_paginate では paginate メソッドを使った結果が必要なため上記コードはまだ動かない
  • paginate を使うことで、ユーザーのページネーションを行えるようになる
    • index アクション内の allpaginate メソッドに置き換える
class UsersController < ApplicationController
 before_action :logged_in_user, only: [:index, :edit, :update]
 .
 .
 .
 def index
   @users = User.paginate(page: params[:page])
 end
 .
 .
 .
end

10.3.4 ユーザー一覧のテスト

  • ページネーションに対する簡単なテストを書く
    • ログイン
    • index ページにアクセス
    • 最初のページにユーザーがいることを確認
    • ページネーションのリンクがあることを確認
  • fixture に大量のユーザーを追加する必要があるが、手動は面倒
    • 埋め込み Ruby を利用してさらに30人のユーザーを追加する
  • index ページに対するテストを書く
    • いつものように TDD で
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

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

  • index ビューのリファクタリングとして、ユーザーの lirender 呼び出しに置き換える
<% 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 変数に対して実行している
    • この場合、Rails は自動的に _user.html.erb という名前のパーシャルを探しに行くので、このパーシャルを作成する必要がある
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>
  • さらに render@users に対して直接実行する
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

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

<%= will_paginate %>
  • Rails は @usersUser オブジェクトのリストであると推測する
  • ユーザーのコレクションを与えて呼び出すと、Rails は自動的にユーザーのコレクションを列挙し、それぞれのユーザーを _user.html.erb パーシャルで出力する
    • いつもの黒魔術

10.4 ユーザーを削除する

  • ユーザー一覧ページは完成したので、残るは destroy だけ
  • まずは削除を実行できる admin ユーザ−のクラスを作成する

10.4.1 管理ユーザー

  • 特権を持つ管理ユーザーを識別するために、論理値をとる admin 属性を User モデルに追加する
    • 自動的に admin? メソッドも使えるようになる
      • なぜ?
  • いつものようにマイグレーションを実行して admin 属性を追加する
$ rails generate migration add_admin_to_users admin:booleanadmin:boolean
  • マイグレーションを実行すると、admin カラムが users テーブルに追加される
    • default: false という引数を add_column に追加することにより、デフォルトでは管理者になれないということを示す
class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end
  • いつものようにマイグレーションを実行する
    • admin? メソッドも利用できるようになる
      • うーん黒魔術
  • 最初のユーザーだけをデフォルトで管理者にするようにサンプルデータを更新する
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 をリセットし、サンプルデータを再度生成する
  • 上記コードでは初期化ハッシュに admin: true を設定することでユーザーを管理者にしている
    • 攻撃者が PATCH リクエストにより任意のユーザーの権限を書き換える可能性がある
    • Stroing Parameters で対策
    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
  • 許可された属性リストに admin が含まれていないため、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる
  • 演習問題で少しハマる
    • 失敗すべきテストが通ってしまう
    • has_secure_password によって守られているため、テストでは @other_user 経由で passowrd と password_confirmation を呼び出せないのが原因(ググると同じ問題にハマっている人がいた)
    • password と password_confirmation をベタ書きにすることで対応できた
      • しかし微妙になっとくできないのでもう少し調べてみよう。。。

10.4.2 destroy アクション

  • destroy アクションへのリンクを追加する
    • ユーザー index ページの各ユーザーに削除用のリンクを追加し、管理ユーザーへのアクセスを制限する
      • 現在のユーザーが管理者のときに限り destroy リンクが表示されるようになる
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
  • やはり erb 内に登場する if 文が気になる。。。
  • ブラウザではネイティブで DLETE リクエストを送信できないため、Railsでは JavaScript を使って偽造する
  • 削除リンクを動作させるために destroy アクションを追加する
    • 該当するユーザーを見つけて Acticve Record の destroy メソッドを使って削除し、最後にユーザー index に移動する
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

  private
  .
  .
  .
end
  • コマンドラインで DELETE リクエストを直接発行するという方法でサイトの全ユーザーを削除するというセキュリティホールがまだある
    • destroy アクションにもアクセス制限を行うことにより、管理者だけがユーザーを削除できるようになる
  • before フィルターで destroy アクションへのアクセスを制御する
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

10.4.3 ユーザー削除のテスト

  • ユーザー削除のテストを書く
    • fixture ファイルを修正してサンプルユーザーの一人を管理者にする
  • Users コントローラをテストするために、アクション単位でアクセス制御をテストする
    • 削除をテストするために DELETE リクエストを発行して destroy アクションを直接動作させる
      • ログインしていないユーザーであればログイン画面へリダイレクト
      • ログイン済みユーザーであっても管理者でなければ、ホーム画面へリダイレクト
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  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
end
  • 管理者ユーザーの振る舞いも一緒に確認できると良い
    • 管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用してテストを追加する
    • DELETE リクエストを適切な URL に向けて発行し、User.count でユーザー数が1減ったかどうかを確認する
    • 管理者や一般ユーザーのテスト、ページネーションや削除リンクのテストをすべてまとめる
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

10.5 最後に

  • Web サイトとしての十分な基盤(ユーザーの認証認可)を整えたサンプルアプリケーションが出来上がった

所感

認可の仕組みやページネーションを実装し、よりWebアプリケーションらしさが増してきた一方、本来このチュートリアルから得たかったことからは徐々に離れつつある気もしてきている。Rails の特定な便利機能の動きを学べるのはプラスにはなるのだが、正直言ってあまり興味は持てない。とはいえ残り4章なので、最後まで頑張って走りきってみる。


6868 Words

2020-03-12 02:03 +0000