LoginSignup
2
0

More than 1 year has passed since last update.

Railsチュートリアル(第6版) 第10章 ユーザーの更新・表示・削除

Posted at

第10章

今回の章では、Usersリソース用RESTアクションで未実装だった

・edit
・update
・index
・destroy

これらのアクションを追加する。
また、認可モデル(Authorization Model)についても触れていく。

そして、主にやる事は以下の事
・全てのユーザー一覧と、ページネーションを導入
・ユーザーの削除によって、データベースから完全に削除する機能
・管理ユーザーという特権クラスの作成

ユーザーを更新する

新規登録の時のnewアクションの代わりに、編集はeditアクションを作成し、
POSTリクエストに応答するcreateアクションの代わりに、PATCHリクエストに応答するupdateアクションを作成する。

新規登録の時との最大の違いは、ユーザー登録は誰でもできて、ユーザー情報を更新するのは、ユーザー自身に限られるという点。
また、認証機能に関しては、beforeフィルターを使えばアクセス制御可能。

編集フォーム

流れとしては、Usersコントローラーにeditアクションを追加し、対応するeditビューを作る。
最初にeditアクションの実装から入るが、まずはユーザーデータを読み込む必要があるということ。

例えば、ユーザー1のユーザー編集ページなら、URLは/users/1/editとなる。
このユーザIDは、params[:id]で取り出す。

app/controllers/users_controller.rb
  def edit
    @user = User.find(params[:id])
  end

続いて、ユーザー編集ページのビューを作る。
$ touch app/views/users/edit.html.erb

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_with(model: @user, local: true) 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="https://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

ここでもerror_messagesパーシャルを再利用している。

htmlにあるGravatorリンクで`target="_blank"としているが、こうするとリンク先を新しいタブで開くようになる。(セキュリティ上小さな問題があるが、後でみていく。)

コントローラーでインスタンス変数@userを定義したおかげで、編集ページに@userに格納された情報はビューページで引き出されて名前、メールアドレス等の値が自動的に入力され大変便利。

erbから生成されたhtmlを見ると
<input name="_method" type="hidden" value="patch" />
となっていて、通常WEBブラウザはPATCHリクエストを送信できないため、RailsはPOSTリクエストと隠しinputフィールドを利用し、PATCHリクエストを偽造している。
(へぇ~って感じ。詳細がなんとなく気になる)

newページとeditページを見比べると、コードがほぼ同じなのだが、Railsはどのように新規ユーザーか既存ユーザーか区別しているのか。
答えは、Active Recordnew_record?論理値メソッドを使って区別しているから。

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

Railsでゃ、form_with(@user)からフォームを構成した時、@user.new_record?がtrueならPOST、falseならPATCHとしている。

_header.html.erbにナビゲーションバーのeditアクションへのリンクを設定
<%= link_to "Settings", edit_user_path(current_user) %>
current_userヘルパーメソッドを使うと楽。

演習

target="_bkank?"だとフィッシングサイトに引っかかる危険性があるので、rel属性に"noopener"と設定すれば良い。
<a href="https://gravatar.com/emails" target="_blank" rel="noopener">change</a>

先ほどnewページとeditページのコードがほぼ同じだったので、リファクタリング

app/views/users/_form.html.erb
<%= form_with(model: @user, local: true) 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 %>
  <%= f.password_field :password_confirmation, class: 'form-control' %>

  <%= f.submit yield(:button_text), class: "btn btn-primary" %>
<% end %>
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>
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="https://gravatar.com/emails" target="_blank">Change</a>
    </div>
  </div>
</div>

編集の失敗

ユーザー登録の時と同様に、編集に失敗した場合についてを始めに扱う。
無効な情報が送信されたら、更新の結果をfalseとして、編集ページをレンダリングする。
初期のcreateと似ている。

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

ここでもStrong Parametersのuser_paramsを使っている。

Userモデルにはバリデーションとエラーメッセージのパーシャルが既に作成してるので、エラーメッセージがこの時点で出る。

演習

image.png

メッセージが出たのでOK

編集失敗時のテスト

エラー検知の統合テストを生成する。

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

テストの内容
・編集ページにアクセスし、editビューが正しく描画されているか
・その後、無効な情報を送信、editビューが再描画されるか
 ここで、PATCHリクエストを送るのに、patchメソッドを使っていることが分かる。

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

これでテストすると成功する。

演習

これで、正しい数のエラーメッセージが表示されているかのテストはOK
assert_select "div.alert", "The form contains 4 errors."

TDDで編集を成功させる

画像の編集は、Gravatarに任せているので、既に動作する。

次に「受け入れテスト(Acceptance Tests)」を書く。これは、ある機能の実装が完了し、ユーザー情報等のデータを入力できる状態=受け入れ可能状態になったかどうかを決めるテストのこと。

テスト内容
・有効な情報を送信
・フラッシュメッセージが空でないかどうか
・プロフィールページにリダイレクトすること
・データベース内のユーザー情報が正しく変更されたか

test/integration/users_edit_test.rb
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end

テストにて
@user.reloadとあるが、これはデータベースから最新のユーザー情報よ読み直し、正しく更新されたかチェックしていうr。
assert_equalにて読み直した結果と、値が一致しているかテストしている。

updateアクションに、flashredirect_toを追記する。

app/controllers/users_controller.rb
  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end

この時点でテストは失敗する。なぜなら、パスワードの長さのバリデーションに対して、パスワードを空にしているため、引っかかってしまう。
テストが成功するには、パスワードのバリデーションに対し、空だった時の例外処理を加えれば良い。
これを実装するため、allow_nil: trueというオプションをvalidatesに追加する。

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

最初これ見た時に、「あれ?新規ユーザー登録時に空のパスワードが有効になるのでは」と思ったが、どうやらhas_secure_passwordではオブジェクト生成時に存在性を検証するため、空のパスワードが新規ユーザー作成時に有効になることはないとのこと。
前回、空のパスワードのエラーメッセージが2つあるという話があったが、今回の修正でエラーメッセージが1つだけになった。

これでテストは成功する。

演習
アップデートした。
image.png

認可

WEBアプリケーションの文脈上の認証(authetication)認可(authorization)の違い。

・認証:サイトのユーザーを識別すること
・認可:ユーザーが実行可能な操作を管理する事

editアクションとupdateアクションは動作しているが、セキュリティ上ミスがある。それは、どのユーザー(ログインしてないユーザーでさえも)でもユーザー情報を編集できてしまう。

ユーザーにログインを要求して、かつ自分以外のユーザー情報を変更できないようにすることを、セキュリティモデルと呼ぶ。

・ログインしていなければ、ログインページに転送+メッセージを表示。
・ログイン済みユーザーが別のユーザーにアクセスしようとした場合、ルートURLにリダイレクトさせる。

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

未ログインなら、ログインページに転送という動作をさせるには、beforeフィルターを使う。これは、何らかの処理が実行される直前に特定のメソッドを実行するもの。
今回は、ユーザーにログインを要求したい。
なので、logged_in_userメソッドを定義し、before_actionに組み込む。

app/controllers/users_controller.rb
  before_action :logged_in_user, only: [:edit, :update]

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

boforeフィルターはデフォルトだとコントローラ内のすべてのアクションに適用されてしまうため、:onlyオプション(ハッシュ)を使い、:edit:updateだけフィルタを適用している。

現時点ではテストは失敗する。
理由は、editアクションやupdateアクションでログインを要求するようになり、未ログインユーザーだとこれらのテストが失敗するから。

なので、テストする前にログインしておく必要がある。
log_in_asヘルパーを使えばOK

test/integration/users_edit_test.rb
  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

これでテストは成功
しかし、beforeフィルターをコメントしてもテストが通ってしまうため、もしbeforeフィルターをコメントアウトされセキュリティホールが作られたら、非常にまずい。
なので、それを検出できるテストを書く。

  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

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

これで、きちんとlog_in_userが動作してるか分かる。

これでコメントアウトした時にテストが失敗し、コメントアウトを解除すればテストが成功する。

演習

..F

Failure:
SiteLayoutTest#test_layout_links [/home/ubuntu/environment/sample_app/test/integration/site_layout_test.rb:15]:
Expected at least 1 element matching "title", found 0..
Expected 0 to be >= 1.


rails test test/integration/site_layout_test.rb:5

....F

Failure:
UsersControllerTest#test_should_get_new [/home/ubuntu/environment/sample_app/test/controllers/users_controller_test.rb:11]:
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>


rails test test/controllers/users_controller_test.rb:9

....F

Failure:
UsersSignupTest#test_invalid_signup_information [/home/ubuntu/environment/sample_app/test/integration/users_signup_test.rb:13]:
expecting <"users/new"> but rendering with <[]>


rails test test/integration/users_signup_test.rb:5

F

Failure:
UsersSignupTest#test_valid_signup_information [/home/ubuntu/environment/sample_app/test/integration/users_signup_test.rb:20]:
"User.count" didn't change by 1.
Expected: 2
  Actual: 1

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

ここでは、ユーザーが自分の情報だけを編集できるようにする。

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

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

そして、log_in_asメソッドを使い、editアクションとupdateアクションでテストする。

  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

@other_userでログインして、@userの編集ページや更新を行おうとする時のテストを書いている。

テストを成功させるための、処理を書く。

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

correct_userメソッドを作成し、これをbeforeフィルターから呼ぶ。
correct_userメソッドでは、取得したユーザーを@userにいれ、current_userと比較して、一致しなければ、ルートURLにリダイレクトさせる。

ここで、リファクタリングを行う。
current_user?という論理値を返すメソッドにする。

unless @user == current_user

から

unless current_user?(@user)

になる。

このメソッドは、Sessionsヘルパーの中にメソッドを定義する。

app/helpers/sessions_helper.rb
  # 渡されたユーザーがカレントユーザーであればtrueを返す
  def current_user?(user)
    user && user == current_user
  end

演習

updateアクションを保護しないとcurlコマンドで直接値を送れるから。

editの方が簡単にテストできる。

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

ここは、親切心で機能を実装することが良いとのことで実装する。

現状の課題
①未ログインユーザーが編集ページにアクセスしようとする。
②当然、ログインページページにリダイレクトされる。
③ログイン後には、問答無用で自分のプロフィールページに移動する。

本来は編集ページにアクセスしたかったのだから、ログイン後は編集ページにリダイレクトされるのが優しさ。

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

テストを書く

test/integration/users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_url(@user)
    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

ユーザーを希望のページに転送させるには、リクエスト時点のページを保存し、その場所にリダイレクトさせればOK。
この動作をさせるには
store_location
redirect_back_or
2つのメソッドを定義し、使えば良い。なお、これらはSessionsヘルパーで定義する。

app/helpers/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?
  end

store_locationメソッドは、リクエストが送られたURLをsession変数の:forwarding_urlキーに格納するが、GETリクエスト時のみにしたいので、if request.get?としている。
これにより、未ログインユーザーがフォームを送信した時、転送先のURLを保存させないようにできる。
例として、ユーザーがセッション用のcookieを手動で削除後、フォームから送信するケース等。
POSTやPATCH、DELETEリクエストを期待しているURLに対して、GETリクエストが送られてしまって、エラーが発生する。
この現象の回避のために、if request.get?という条件分を使っている。

先ほどのstore_locationメソッドを使う。

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

redirect_back_orメソッドで、リクエストされたURLが存在する場合は、そこへリダイレクトし、ない場合はデフォルトのURLにリダイレクトする。
なお、デフォルトURLはSessionコントローラーのcreateアクションに追加して、ログイン成功時にリダイレクトさせる。

session[:forwarding_url] || default
これについて、or演算子は||で表現され、左側のsession[:forwarding_url]nilでなければ、こちらを評価し、そうでなければデフォルトURLを使う。
また、session.delete(:forwarding_url)で転送用のURLは削除する。
これは、次回ログイン時に保護されたページへ転送され、ブラウザを閉じるまでこれが繰り返されるから。
(sessionはブラウザを閉じるまで値を保持することを以前習った)

redirect_back_orcreateアクションに追加

app/controllers/sessions_controller.rb
  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

redirect文に関してだが、これは明示的にreturn文やメソッド内の最終行が呼び出されない限りは、リダイレクトが発生しない。つまり、リダイレクトはメソッドの最後に行われる。

※railsチュートリアルでは、redirect_back_or userとなっていて、userとしているが、@userとしないとusers_login_testでエラー発生するので、ここでは@userを使おう。

これでテストは成功する。

演習

assert_nil session[:forwarding_url]を追加する。

test/integration/users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_nil session[:forwarding_url] #追加
    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

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

ここでは、ユーザー出力でページネーションを使って、ユーザー一覧ページを作る。

ページネーションとは、よくある[1][2][3]とかページが分割されているやつ。

ユーザーの一覧ページ

showページに関しては、ログインの有無に関わらず捨ててのユーザーが閲覧できるようにする。
indexページは、ログインユーザーのみ閲覧可能とする。

indexページを不正アクセスから守るために、indexアクションが正しくリダイレクトしてくれるかの検証

test/controllers/users_controller_test.rb
  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end

続いて、beforeフィルターのlogged_in_userindexアクション追加し保護する。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]

  def index
  @users = User.all
  end

@usersに全ユーザーを格納する。
(すべてのユーザーを一気に読みだすと問題が生じるので後で修正する)

全てのユーザーを順々に表示するための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>

eachメソッドを使い、liタグの中に次々と表示する。表示内容は、各ユーザーのGravatarと名前。
gravatar_forというものは下記で定義してえある。

app/helpers/users_helper.rb
module UsersHelper

  # 渡されたユーザーのGravatar画像を返す
  def gravatar_for(user, options = { size: 80 })
    size         = options[:size]
    gravatar_id  = Digest::MD5::hexdigest(user.email.downcase)
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

SCSSに手を加える。

app/assets/stylesheets/custom.scss
/* Users index */

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

最後に_header.html.erbパーシャルにユーザー一覧表示用のリンクを追加する。
user_pathを使って、名前付きルートを割り当てる。

<li><%= link_to "Users", users_path %></li>

これでテストして成功した。

サンプルユーザー

複数のユーザーを手作業で一つずつ追加するのはしんどいので、一気にユーザーを登録しよう。

GemfileにFaker gemを追加する。これはありそうなユーザー名を作成してくれるやつ。

gem 'faker', '2.1.2'

次にいつも通りのバンドルインストール。

$ bundle install

データベース上にサンプルユーザーを生成するRialsタスク

db/seeds.rb
# メインのサンプルユーザーを1人作成する
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という名前のユーザーとメールアドレスと、99人のそれっぽいユーザーを作成する。
create!は、ユーザーが無効だったらfalseではなく例外を発生させるもの。
これでエラーを回避できて良い。

データベースのリセットとRailsタスク実行

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

image.png

ユーザー沢山いる。

ページネーション

一つのページに数千ものユーザーが表示されるのは良くない。
そのため、ページ分割機能を実装する。

今回は最もシンプルなwill_paginateメソッドを使う。
Gemfilewill_paginategemとbootstrap-will_paginategemをいれ、Bootstrapのページネーションスタイルを活用し、will_paginateを構成する。

gem 'will_paginate',           '3.1.8'
gem 'bootstrap-will_paginate', '1.0.0'

バンドルインストールし、Webサーバーの再起動をする。

$ bundle install

ページネーションをRailsに指示するコードをindexビューに追加し、User.allをページネーションを理解できるオブジェクトに変換する必要がある。

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変数にはUser.allの結果があるが、will_paginateではpaginateメソッドを使った結果が必要だから。

paginateは、キーが:pageで値がページ番号となる。
User.paginateでは、:pageパラメータを見て、データベースから一塊のデータを取り出す。例えば30だったら、1ページ目は、1から30で2ページ目は31から60のような感じ。
pagenilだったら、paginateは最初のページを返す。

indexアクション内のallpaginateメソッドにすれば、ページ毎の一塊分のデータを取れる。
:pageパラメータには、params[:page]が使われる。

app/controllers/users_controller.rb
  def index
    @users = User.paginate(page: params[:page])
  end

これでユーザー一覧のページネーションができた。

image.png

ページネーションオブジェクトのクラスは、ActiveRecord_Relationクラスになる。

ユーザー一覧のテスト

ページネーションが実装できたので、これに対するテストを書いておく。

テスト内容
・ログインしindexページにアクセス
・最初のページにユーザーがいることを確認
・ページネーションのリンクがあることを確認

fixtureには埋め込みRubyをサポートしているので、fixtureにユーザーを追加していく。。

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

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 generate integration_test users_index

テストとしては、paginationクラスを持ったdivタグをチェックし、ユーザーがいるか確認している。

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

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

最初のリファクタリングは、ユーザーのliタグをrender呼び出しに変える事。

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 %> ←renderにする
  <% end %>
</ul>

<%= will_paginate %>

パーシャルはindexに表示していた内容を持ってくる。

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

indexビューのrender@usersにする。

app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %> ←@usersにする
</ul>

<%= will_paginate %>

@usersにすることで、each doを使わずにリスト表示ができる。
理由としては、Railsは@usersをUserオブジェクトとして認識し、ユーザーコレクションを与えると、ユーザーを列挙する。それぞれのユーザーは_user.html.erbパーシャルで出力する。

ユーザーを削除する

destroyアクションでユーザーを削除できるようにしよう。

内容
・ユーザーを削除するためのリンク追加
・削除を行うdestroyアクション追加
・管理権限(admin)ユーザークラスを作成。(承認においては、このような特権のセットをroleと呼ぶ)

管理ユーザー

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

$ rails generate migration add_admin_to_users admin:boolean

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

default: falseにすることで、デフォルトでは管理者になれないということを示せるが、別にdefault: falseにしなくてもadminの値はデフォルトでnilになる。falseと同じなので、必ず記載しなければならないという訳ではないが、意図的に開発者に示せる。

マイグレートする。
$ rails db:migrate

仕上げに管理者にするサンプルデータを更新する。

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

サンプルデータを再度生成する。

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

再びStrong Parametersの出番
admin: trueとするとユーザーを管理者にできるわけだが、Webリクエストの初期化ハッシュをオブジェクトに渡せると、PATCHリクエストを送信されるかもしれない。

patch /users/17?admin=1

これだと17番目のユーザーに管理者権限を与えてやばいことになる。
なので、Strong Parametersで対策する。

paramsハッシュに対して、requireとpermitを呼び出す。

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

許可された属性リストにadminが含まれないことによって、管理者権限を与えることを防止できる。

destroyアクション

destroyアクションへのリンクを追加する。

流れ
・ユーザーindexページの各ユーザーに削除用リンクを追加
・管理ユーザーへのアクセス制限
・これにより、現在のユーザーが管理者の時に限りdeleteリンクが表示される

ユーザー削除用リンク

app/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>

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

destroyアクションの追加

app/controllers/users_controller.rb
before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

destroyアクションは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、ユーザーindexにリダイレクトする。
ユーザーを削除するためにはログインの必要があるので、logged_in_userフィルターに:destroyアクションを追加している。

しかし、これではセキュリティホールがある。
攻撃者がコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうというもの。
なので、destroyアクションにもアクセス制御を行う必要がある。

beforeフィルターを使い、admin_userフィルターを作る。

app/controllers/users_controller.rb
before_action :admin_user,     only: :destroy

  private
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end

ユーザー削除のテスト

まずfixtureファイルを修正し、サンプルユーザーの一人を管理者にする。

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

テスト手順
DELETEリクエストを発行
destroyアクションを直接動作
・2つのケースをチェック。1つは、未ログインユーザーなら、ログイン画面にリダイレクトさせる。2つ目は、ログイン済ユーザーであっても、管理者でなければ、ホーム画面にリダイレクトされる。

test/controllers/users_controller_test.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

続いて、管理者ユーザーのふるまいのテストを追加する。
管理者であれば、ユーザー一覧画面に削除用リンクが表示される仕様を利用して、ユーザーが減ることを確認すれば良い。

test/integration/users_index_test.rb
  def setup
    @admin     = users(:michael)
    @non_admin = users(:archer)
  end

  test "index as admin including pagination and delete links" do
    log_in_as(@admin)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    first_page_of_users = User.paginate(page: 1)
    first_page_of_users.each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin)
    get users_path
    assert_select 'a', text: 'delete', count: 0
  end

最後に

あとは、いつものようにGithubとHerokuにプッシュでOK

第11章と第12章では、メールアドレスを使ったアカウント有効化機能と、パスワード再設定機能をやる。

2
0
0

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
2
0