#第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]
で取り出す。
def edit
@user = User.find(params[:id])
end
続いて、ユーザー編集ページのビューを作る。
$ touch 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 Record
のnew_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
ページのコードがほぼ同じだったので、リファクタリング
<%= 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 %>
<% 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>
<% 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
と似ている。
def update
@user = User.find(params[:id])
if @user.update(user_params)
# 更新に成功した場合を扱う。
else
render 'edit'
end
end
ここでもStrong Parametersのuser_params
を使っている。
Userモデルにはバリデーションとエラーメッセージのパーシャルが既に作成してるので、エラーメッセージがこの時点で出る。
演習
メッセージが出たので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 "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
アクションに、flash
とredirect_to
を追記する。
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つだけになった。
これでテストは成功する。
##認可
WEBアプリケーションの文脈上の**認証(authetication)と認可(authorization)**の違い。
・認証:サイトのユーザーを識別すること
・認可:ユーザーが実行可能な操作を管理する事
edit
アクションとupdate
アクションは動作しているが、セキュリティ上ミスがある。それは、どのユーザー(ログインしてないユーザーでさえも)でもユーザー情報を編集できてしまう。
ユーザーにログインを要求して、かつ自分以外のユーザー情報を変更できないようにすることを、セキュリティモデルと呼ぶ。
・ログインしていなければ、ログインページに転送+メッセージを表示。
・ログイン済みユーザーが別のユーザーにアクセスしようとした場合、ルートURLにリダイレクトさせる。
###ユーザーにログインを要求する
未ログインなら、ログインページに転送という動作をさせるには、before
フィルターを使う。これは、何らかの処理が実行される直前に特定のメソッドを実行するもの。
今回は、ユーザーにログインを要求したい。
なので、logged_in_user
メソッドを定義し、before_action
に組み込む。
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 "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
###正しいユーザーを要求する
ここでは、ユーザーが自分の情報だけを編集できるようにする。
ユーザーの情報が互いに編集できないことを確認したいので、サンプルユーザーを一人追加する。
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
の編集ページや更新を行おうとする時のテストを書いている。
テストを成功させるための、処理を書く。
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ヘルパーの中にメソッドを定義する。
# 渡されたユーザーがカレントユーザーであればtrueを返す
def current_user?(user)
user && user == current_user
end
演習
update
アクションを保護しないとcurlコマンドで直接値を送れるから。
editの方が簡単にテストできる。
###フレンドリーフォワーディング
ここは、親切心で機能を実装することが良いとのことで実装する。
現状の課題
①未ログインユーザーが編集ページにアクセスしようとする。
②当然、ログインページページにリダイレクトされる。
③ログイン後には、問答無用で自分のプロフィールページに移動する。
本来は編集ページにアクセスしたかったのだから、ログイン後は編集ページにリダイレクトされるのが優しさ。
テストとしては
①編集ページにアクセス
②ログインする
③編集ページにリダイレクトされるかチェック
テストを書く
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ヘルパーで定義する。
# 記憶した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
メソッドを使う。
# ログイン済みユーザーかどうか確認
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_or
をcreate
アクションに追加
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 "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 "should redirect index when not logged in" do
get users_path
assert_redirected_to login_url
end
続いて、beforeフィルターのlogged_in_user
にindex
アクション追加し保護する。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update]
def index
@users = User.all
end
@users
に全ユーザーを格納する。
(すべてのユーザーを一気に読みだすと問題が生じるので後で修正する)
全てのユーザーを順々に表示するための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>
each
メソッドを使い、li
タグの中に次々と表示する。表示内容は、各ユーザーのGravatarと名前。
gravatar_for
というものは下記で定義してえある。
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に手を加える。
/* 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タスク
# メインのサンプルユーザーを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
ユーザー沢山いる。
###ページネーション
一つのページに数千ものユーザーが表示されるのは良くない。
そのため、ページ分割機能を実装する。
今回は最もシンプルなwill_paginate
メソッドを使う。
Gemfile
にwill_paginate
gemとbootstrap-will_paginate
gemをいれ、Bootstrapのページネーションスタイルを活用し、will_paginateを構成する。
gem 'will_paginate', '3.1.8'
gem 'bootstrap-will_paginate', '1.0.0'
バンドルインストールし、Webサーバーの再起動をする。
$ bundle install
ページネーションをRailsに指示するコードをindexビューに追加し、User.all
をページネーションを理解できるオブジェクトに変換する必要がある。
<% 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のような感じ。
page
がnil
だったら、paginate
は最初のページを返す。
index
アクション内のall
をpaginate
メソッドにすれば、ページ毎の一塊分のデータを取れる。
:page
パラメータには、params[:page]
が使われる。
def index
@users = User.paginate(page: params[:page])
end
これでユーザー一覧のページネーションができた。
ページネーションオブジェクトのクラスは、ActiveRecord_Relation
クラスになる。
###ユーザー一覧のテスト
ページネーションが実装できたので、これに対するテストを書いておく。
テスト内容
・ログインしindexページにアクセス
・最初のページにユーザーがいることを確認
・ページネーションのリンクがあることを確認
fixtureには埋め込みRubyをサポートしているので、fixtureにユーザーを追加していく。。
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
タグをチェックし、ユーザーがいるか確認している。
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
呼び出しに変える事。
<% 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
に表示していた内容を持ってくる。
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
index
ビューのrender
を@users
にする。
<% 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
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
仕上げに管理者にするサンプルデータを更新する。
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
リンクが表示される
ユーザー削除用リンク
<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
アクションの追加
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
フィルターを作る。
before_action :admin_user, only: :destroy
private
# 管理者かどうか確認
def admin_user
redirect_to(root_url) unless current_user.admin?
end
###ユーザー削除のテスト
まずfixtureファイルを修正し、サンプルユーザーの一人を管理者にする。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
テスト手順
・DELETE
リクエストを発行
・destroy
アクションを直接動作
・2つのケースをチェック。1つは、未ログインユーザーなら、ログイン画面にリダイレクトさせる。2つ目は、ログイン済ユーザーであっても、管理者でなければ、ホーム画面にリダイレクトされる。
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
続いて、管理者ユーザーのふるまいのテストを追加する。
管理者であれば、ユーザー一覧画面に削除用リンクが表示される仕様を利用して、ユーザーが減ることを確認すれば良い。
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章では、メールアドレスを使ったアカウント有効化機能と、パスワード再設定機能をやる。