個人的リマインド用
参考
Ruby on Rails チュートリアル プロダクト開発の0→1を学ぼう
ユーザーの更新・表示・削除
ユーザーを更新する
編集の時はeditアクション。同様に、PATCHリクエストに応答するupdateアクションを作成する。気をつけるのはユーザー情報を更新していいのは本人だけという点。これはbeforeフィルターを使ってなんとかする。
編集フォーム
編集用のページを作成するが、例えばユーザー1のページを作成したいときのURLは/users/1/edit。このユーザーIDはparams[:id]変数で取り出せる。
app/controllers/users_controller.rb
def edit
@user = User.find(params[:id])
end
ビューファイルは手動で作成する必用がある。
注目点としては、error_messagesパーシャルを再利用しているところと、Gravatarへのリンクでtarget="_blank"が使われている点。これは開く時に別のタブで開いてくれるようにする。
また@userインスタンス変数を使うと、NameやEmailのフィールド値が入った状態でレンダリングされる。
app/views/users/edit.html.erb
<div class="gravatar_edit">
<%= gravatar_for @user %>
<a href="https://gravatar.com/emails" target="_blank">change</a>
</div>
これから生成されたHTMLファイルはだいたい予想通り。しかし注目点としては以下の通り。
<input name="_method" type="hidden" value="patch" />
入力フィールドの中に隠し属性があるということ。WebブラウザはRESTの慣習として要求されているPATCHリクエストをそのままでは送信できないので、RailsはPOSTリクエストと隠しinputフィールドを利用して、PATCHリクエストを「偽装」している。
またここで気になる点がある。上のerbのform_with(@user)のコードは、第7章のコードと完全に同じ。では、どのようにそれらを判別しているのか。その答えは、Railsはユーザーが新規なのか、それともデータベースに存在する既存のユーザーなのかを、Active Recordのnew_record?論理値メソッドで区別できるから。
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false
trueの時はPOSTを使い、falseの場合はPATCHを使う。
仕上げに_header.html.erb内にある、Settingsへのリンクを更新する。
<li><%= link_to "Settings", edit_user_path(current_user) %></li>
編集の失敗
まずはupdateアクションの作成から進めるが、これはupdateを使って送信されたparamsハッシュに基づいてユーザーを更新する。
app/controllers/users_controller.rb
def update
@user = User.find(params[:id])
if @user.update(user_params) # ←user_paramsを使っている
# 更新に成功した場合を扱う
else
render 'edit', status: :unprocessable_entity
end
end
user_paramsを使っていることに注目。ここではStrong Parametersを使いマスアサインメントの脆弱性を防止している。
既にUserモデルのバリデーションとエラーメッセージのパーシャルがあるので、無効な情報を送信するとわかりやすいエラーメッセージが表示される。
編集失敗時のテスト
まずは統合テストの作成から。
rails g integration_test users_edit
簡単なテストを追加する。まず編集ページにアクセスし、editビューがレンダリングされているかをチェックする。その後、無効な情報を送信し、editビューが再描画されているかをチェック。
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), params: { user: { name: "",
email: "foo@invalid",
password: "foo",
password_confirmation: "bar" } }
assert_template 'users/edit'
end
end
結果はgreen。
TDDで編集を成功させる
今度は編集フォームが動作するようにする。まずは実装前に統合テストを書くことから。ちなみにこれは「受け入れテスト」と呼ばれている。
手順としては、ユーザー情報を更新する正しい振る舞いをテストで定義する。次に、フラッシュメッセージが空ではないかどうか、プロフィールページにリダイレクトされるかどうかをチェック。また、データベース内のユーザー情報が正しく変更されたかどうかも検証する。
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メソッドで最新のユーザー情報を反映させ、更新内容が正しいことを確認している。
上のテストを合格させるためにupdateの更新成功の欄を書き換える
def update
@user = User.find(params[:id])
if @user.update(user_params)
flash[:success] = "Profile updated"
redirect_to @user
else
render 'edit', status: :unprocessable_entity
end
end
しかしまだredのまま。これはパスワードの長さに関するバイデーションを設定しているので、現在の空である状態が引っかかっている。これはvalidatesにallow_nil: trueを追加することで解決できる。
app/models/user.rb
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: true
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
. # ↑この部分
.
.
end
ちなみに空を許したとしても、新規作成の時には存在性を検証するようになっているので、パスワードが空で新規作成されることはない。
これでテストはgreenになる。
認可
Webアプリケーションでは、認証はサイトのユーザーを識別することであり、認可はそのユーザーが実行可能な操作を管理すること。
現在の大きな問題点は、誰でもユーザー情報を編集できること。そこでユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。このようなセキュリティ上の制御機構をセキュリティモデルという。
今回やること
・ログインしていないユーザーが保護されたページにアクセスする時、ログインページに転送し、分かりやすいメッセージを表示する
・ログイン済みのユーザーが、許可されていないページにアクセスしようとしたら、ルートURLにリダイレクトすることにする
ユーザーにログインを要求する
上のような転送の仕組みを使いたい時はbeforeフィルターを使う。これは、なんらかの処理が実行される直前に特定のメソッドを実行する仕組み。
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, status: :see_other
end
end
end
デフォルトでbeforeフィルターは全てのアクションに適用されるので、only:という風に制限する。また、今後どこかの時点で、:destroyアクションを保護するために使われる可能性があるので、logged_in_userの中で、ステータスコード:see_otherを含める。
現段階でテストがredになるが、なぜかというとedit,updateアクションでログインが必須になり、ログインしていないユーザーでこれらのテストが失敗するようになったため。では、テストする前にログインさせるためには、log_in_asヘルパーを使う。
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フィルターのところをコメントアウトしてみると、テストがgreenになる。ここはredにならないといけない。
beforeフィルターはアクションごとに適用されているので、Usersコントローラのテストもアクションごとに書く。具体的には、正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行し、flashメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認する。
test/controllers/users_controller_test.rb
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
これでテストはredになる。そしてコメントアウト部分を戻したらgreenになる。
正しいユーザーを要求する
ログインを要求するだけではなく、ユーザーが自分の情報だけを編集できる必用がある。
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') %>
そして間違ったユーザーファ編集しようとしたときのテストを書く
test/controllers/users_controller_test.rb
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
では正しいユーザーかを判別するcorrect_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出す。
app/controllers/users_controller.rb
before_action :logged_in_user, only: [:edit, :update]
before_action :correct_user, only: [:edit, :update]
private
# 正しいユーザーかどうか確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url, status: :see_other) unless @user == current_user
end
ちなみにこの時点で、correct_userで@user変数を定義したので、edit,updateアクションから@userへの代入文を削除している。
@user == current_userをcurrent_user(@user)にするリファクタリングは本文を参照。
フレンドリーフォワーディング
残っている問題点としては、保護されたページにユーザーがアクセスしようとしたら、問答無用で自分のプロフィールページに移動させられる。リダイレクト先は直前にユーザーが開こうとしていたページにするのが親切。
まずはテスト。これはログインした後に、デフォルトのプロフィールページではなく編集ページにリダイレクトされるかのテスト。
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)
失敗するテストが書けたので、次は実装。ユーザーを適切なページに転送するには、リクエストされたページをどこかに保存しておく。それはSessionヘルパーのstore_location、メソッドにカプセル化しておく。
app/helpers/sessions_helper.rb
module SessionsHelper
.
.
.
# アクセスしようとしたURLを保存する
def store_location
session[:forwarding_url] = request.original_url if request.get?
end
end
request.original_urlでリクエスト先を取得できる。GETリクエストの時だけにすることで、例えばログインしていないユーザーがフォームを送信した場合は、転送先のURLを保存しないようにできる。
app/controllers/users_controller.rb
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
store_location
flash[:danger] = "Please log in."
redirect_to login_url, status: :see_other
end
end
フォワーディング自体を実装する時は、リクエストされたURLが存在する場合はそこにリダイレクトし、存在しない場合は何らかのデフォルトURLにリダイレクトするようにする。ログイン成功後にリダイレクトしたいため、この実装はSessionコントローラのcreateアクションに追加する。またreset_sessionの呼び出しがあるので、以下のようにセットする必用がある。
forwarding_url = session[:forwarding_url]
reset_session
log_in user
転送先URLが存在する場合はそこにリダイレクトし、転送先URLがnilの場合はユーザーのプロフィールにリダイレクトできるようにする
redirect_to forwarding_url || url
フレンドリーフォワーディングを備えたcreateアクションは以下の通り
app/controllers/sessions_controller.rb
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
forwarding_url = session[:forwarding_url]
reset_session
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
log_in user
redirect_to forwarding_url || user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new', status: :unprocessable_entity
end
end
すべてのユーザーを表示する
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_userにindexアクションを追加
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
end
indexアクションにコードを追加。ユーザーを全表示させたい時はUser.allを使う。
app/controllers/users_controller.rb
def index
@users = User.all
end
それをビューに表示
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>
_header.html.erbの名前付きルーティングも忘れずに
サンプルユーザー
見栄え的にユーザーを100人ぐらい増やしたい。そんな時はGemfileにFaker gemを追加する。これはdevelopment環境だけで使うのが普通だが、本文では本番環境でも使っている。
Gemfile
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", "7.0.4"
gem "bcrypt", "3.1.16"
gem "faker", "2.21.0"
gem "bootstrap-sass", "3.4.1"
bundle
サンプルユーザーを生成するスクリプトを追加する。ファイルとしてはdb/seeds.rbを使う。
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
create!は基本的にcreateメソッドと同じだが、ユーザーが無効な場合にfalseを返すのではなく、例外を発生させる。
rails db:migrate:reset
rails db:seed
ページネーション
Gemfileにwill_paginate gemとbootstrap-will_paginate gemを追加し、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する。
Gemfile
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", "7.0.4"
gem "bcrypt", "3.1.16"
gem "faker", "2.21.0"
gem "will_paginate", "3.3.1"
gem "bootstrap-will_paginate", "1.0.0"
bundle
今回は画面の上下にページネーションを配置
app/views/users/index.html.erb
<%= 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オブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成している。
しかし、このままではページネーションは動かない。indexアクション内のUser.allを書き換える必用がある。
app/controllers/users_controller.rb
def index
@users = User.paginate(page: params[:page])
end
ユーザー一覧のテスト
今回のテストは「ログイン」「indexページにアクセス」「最初のページにユーザーがいることを確認」「ページネーションのリンクがあることを確認」という順序で行う。少なくともユーザーが31人必用。fixtureでもありがたいことにERBが使えるので、これを使って対応する。
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 %>
ユーザーができたので統合テストファイルを作る
rails g integration_test users_index
test/integration/users_index_test.rb
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
paginationクラスを持つdivタグをチェックすることで、ユーザー一覧の最初のページが存在することを確認する。
パーシャルページのリファクタリング
リファクタリングの第一歩はliをrender呼び出しに書き換える。
app/views/users/index.html.erb
<%= will_paginate %>
<ul class="users">
<% @users.each do |user| %>
<%= render user %>
<% end %>
</ul>
<%= will_paginate %>
renderをパーシャルに対してではなく、Userクラスのuser変数に対して実行していることに注目。ここでは、自動的に_user.html.erbという名前のパーシャルを探索するので、このパーシャルを作成する必用がある。
app/views/users/_user.html.erb
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
さらに改善して、今度はrenderを@users変数に対して直接呼び出す。
app/views/users/index.html.erb
<ul class="users">
<% @users.each do |user| %>
<%= render @users %>
<% end %>
</ul>
Railsは@usersをUserオブジェクトのリストであると推測する。renderにユーザーのコレクションを渡して呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力する。
ユーザーを削除する
まずは、削除を実行できる権限を持つ管理ユーザーのクラスを作成する。承認においては、このような特権のセットをロールという。
管理ユーザー
論理値型のadmin属性をUserモデルに追加する。
rails g migration add_admin_to_users admin:boolean
db/migrate/[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :admin, :boolean, default: false
end
end
default: falseに関しては、デフォルトでは管理者になれないということを明示している。
rails db:migrate
では仕上げに、最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新する。
db/seeds.rb
# メインのサンプルユーザーを1人作成する
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true)
rails db:migrate:reset
rails db:migrate
Strong Parameters
このadmin属性は非常に慎重に扱うべきもので、もしも一般ユーザーからtrueに変えるよう操作されたらとんでもないことになる。なのでStrong Parametersを使う。
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
上のコードでは、許可された属性リストにadminが含まれていない。これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
destoroyアクション
現在のユーザーが管理者の時に限り[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, data: { "turbo-method": :delete,
turbo_confirm: "You sure?" } %>
<% end %>
</li>
"turbo-method": :deleteは、リンクに必用なDELETEリクエストを発効する準備。
2つ目のturbo_confirm: "You sure?"はJSのconfirmボックスを表示する。
ブラウザはネイティブではDELETEリクエストを送信できないので、RailsではJSを使って偽造する。JSが使えない場合は、フォームとPOSTリクエストを使って偽造できる。
ではdestroyアクションの作成と、beforeフィルターによるログインの要求を行う。
app/controllers/users_controller.rb
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, status: :see_other
end
これで管理者だけが削除できるようになったが、まだ大きな穴がある。それは、コマンドラインでDELETEリクエストを直接発効するという方法でサイトの全ユーザーを削除できること。サイトを適切に防衛するには、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, status: :see_other) unless current_user.admin?
end
end
ユーザー削除のテスト
サンプルユーザーの1人を管理者に変える。
test/fixtures/users.yml
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
テストでは2つのケースをチェックする。1つは、ユーザーがログインしていない場合は、ログイン画面にリダイレクトされること。もう1つは、ログイン済みであっても管理者でなければ、ホーム画面にリダイレクトされること。
test/controllers/users_controller_test.rb
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_response :see_other
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_response :see_other
assert_redirected_to root_url
end
end
destroyアクションに直接DELETEリクエストを発効するためにdeleteメソッドを使う。また、assert_no_differenceを使って、ユーザー数に変化がないことを確認している。
では管理者の振る舞いに関してもテストをする。
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)
assert_response :see_other
assert_redirected_to users_url
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
各ユーザーの削除リンクをテストする時、削除対象のユーザーが管理者の場合はテストをスキップしている。これにより管理者自身には削除リンクが表示されないようになっている。