PasswordResetsコントローラーの、ここまでに実装してきた内容に対するテスト
PasswordResetsコントローラーのnew
アクション、create
アクション、およびedit
アクションに対するテストは、Railsチュートリアル本文の内容に合わせていくと、以下のような内容になります。
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:rhakurei)
end
test "password resets" do
get new_password_reset_path
assert_template 'password_resets/new'
# メールアドレスが無効
post password_resets_path, params: { password_reset: { email: "" } }
assert_not flash.empty?
assert_template 'password_resets/new'
get new_password_reset_path
assert flash.empty?
# メールアドレスが有効
post password_resets_path, params: { password_reset: { email: @user.email} }
assert_not_equal @user.reset_digest, @user.reload.reset_digest
assert_equal 1, ActionMailer::Base.deliveries.size
assert_not flash.empty?
assert_redirected_to root_url
end
end
パスワードを更新する際の前提条件
「パスワードの再設定」というユースケースをより具体的に解説すると、以下のようになります。
- Webアプリケーションは、パスワード再設定の有効期限が切れていないことを確認する
- Webアプリケーションは、フォームから送信されるパスワードを受け取る
- Webアプリケーションは、2.で受け取ったパスワードが正当であることを確認する
- Webアプリケーションは、パスワードが正当であれば、当該パスワードでRDBを更新する
Railsチュートリアル本文では、「パスワードの正当性」にかかる実装は、以下のような方針で組み立てていくこととしています。
- 無効なパスワードであれば、再設定を失敗させる(失敗した理由も表示する)
- 空の文字列を新しいパスワードとして与えることはできない
- ユーザー情報の編集では、「パスワードを更新せずに他の属性値だけを更新する」というユースケースを想定し、空の文字列を新しいパスワードとして与えることを可能としていた
パスワードの更新に対するテスト
上記前提を踏まえた上で、PasswordResetsコントローラーのupdate
アクションに対するテストを追加していきましょう。
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:rhakurei)
end
test "password resets" do
get new_password_reset_path
assert_template 'password_resets/new'
# メールアドレスが無効
post password_resets_path, params: { password_reset: { email: "" } }
assert_not flash.empty?
assert_template 'password_resets/new'
get new_password_reset_path
assert flash.empty?
# メールアドレスが有効
post password_resets_path, params: { password_reset: { email: @user.email} }
assert_not_equal @user.reset_digest, @user.reload.reset_digest
assert_equal 1, ActionMailer::Base.deliveries.size
assert_not flash.empty?
assert_redirected_to root_url
+ # パスワード再設定フォームのテスト
+ user = assigns(:user)
+ # メールアドレスが無効
+ get edit_password_reset_path(user.reset_token, email: "")
+ assert_redirected_to root_url
+ # 無効なユーザー
+ user.toggle!(:activated)
+ get edit_password_reset_path(user.reset_token, email: user.email)
+ assert_redirected_to root_url
+ user.toggle!(:activated)
+ # メールアドレスが有効で、トークンが無効
+ get edit_password_reset_path('wrong token', email: user.email)
+ assert_redirected_to root_url
+ # メールアドレスもトークンも有効
+ get edit_password_reset_path(user.reset_token, email: user.email)
+ assert_template 'password_resets/edit'
+ assert_select "input[name=email][type=hidden][value=?]", user.email
+ # パスワードが空
+ patch password_reset_path(user.reset_token),
+ params: { email:user.email,
+ user: { password: "",
+ password_confirmation: "" } }
+ assert_select 'div#error_explanation'
+ # パスワードとパスワード(再入力)が不一致
+ patch password_reset_path(user.reset_token),
+ params: { email:user.email,
+ user: { password: "foobaz",
+ password_confirmation: "barquux" } }
+ assert_select 'div#error_explanation'
+ # 短すぎるパスワード
+ patch password_reset_path(user.reset_token),
+ params: { email:user.email,
+ user: { password: "foo",
+ password_confirmation: "foo" } }
+ assert_select 'div#error_explanation'
+ # パスワード再設定トークンが期限切れ
+ user.reset_sent_at = 48.hours.ago
+ user.update_attribute(:reset_sent_at, user.reset_sent_at)
+ patch password_reset_path(user.reset_token),
+ params: { email:user.email,
+ user: { password: "foobar",
+ password_confirmation: "foobar" } }
+ assert_not is_logged_in?
+ assert_not flash.empty?
+ assert_redirected_to new_password_reset_url
+ # 有効なパスワードとパスワード確認
+ user.reset_sent_at = Time.zone.now
+ user.update_attribute(:reset_sent_at, user.reset_sent_at)
+ patch password_reset_path(user.reset_token),
+ params: { email:user.email,
+ user: { password: "foobar",
+ password_confirmation: "foobar" } }
+ assert is_logged_in?
+ assert_not flash.empty?
+ assert_redirected_to user
end
end
なお、「再設定トークンが期限切れ」という状態は、「テスト用のuser
に対し、再設定トークンの送信時刻を、テスト開始時点の48時間前で上書きする」という形で再現しています。
パスワードの更新に対するテストの実装時点における、同テストの実行結果
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 814
Started with run options --seed 38946
ERROR["test_password_resets", PasswordResetsTest, 3.515056899996125]
test_password_resets#PasswordResetsTest (3.52s)
AbstractController::ActionNotFound: AbstractController::ActionNotFound: The action 'update' could not be found for PasswordResetsController
test/integration/password_resets_test.rb:43:in `block in <class:PasswordResetsTest>'
1/1: [===================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.52484s
1 tests, 13 assertions, 0 failures, 1 errors, 0 skips
以下のエラーによりテストが失敗しています。
AbstractController::ActionNotFound: The action 'update' could not be found for PasswordResetsController
「PasswordResetsコントローラーにupdate
アクションが定義されていない」という趣旨のエラーですね。
私の環境では、test/integration/password_resets_test.rb
の43行目から46行目には以下のコードが記述されています。
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "foobaz",
password_confirmation: "barquux" } }
内容を見るに、PATCH
リクエストを送信した時点でのテストの失敗、ということですね。
では、update
アクションの定義に行ってみましょう。
PasswordResetsコントローラーにupdate
アクションを定義する
まずはPasswordResetsコントローラーにupdate
アクションを定義します。この時点では、まだ中身の実装には着手しません。
class PasswordResetsController < ApplicationController
...略
def new
end
def create
...略
end
def edit
end
+
+ def update
+ end
private
...略
end
end
PasswordResetsコントローラーにupdate
アクションを定義した時点でのテストの実行結果
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 827
Started with run options --seed 50663
ERROR["test_password_resets", PasswordResetsTest, 5.662553800000751]
test_password_resets#PasswordResetsTest (5.66s)
NoMethodError: NoMethodError: undefined method `document' for nil:NilClass
test/integration/password_resets_test.rb:47:in `block in <class:PasswordResetsTest>'
1/1: [===================================] 100% Time: 00:00:05, Time: 00:00:05
Finished in 5.67364s
1 tests, 13 assertions, 0 failures, 1 errors, 0 skips
test/integration/password_resets_test.rb
の47行目でエラーが発生してテストが失敗しています。私の環境では、test/integration/password_resets_test.rb
の42行目から47行目には以下のコードが記述されています。
# パスワードが空
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "",
password_confirmation: "" } }
assert_select 'div#error_explanation'
まずは、パスワードに空文字列が入力された場合についてdiv#error_explanation
を正しく出力できるようにする必要がありそうです。というか、そもそもdiv#error_explanation
とは何か、という疑問もあります。おそらくビューの実装に関係しているのであろう、という推測はつきますが…
無効なパスワードを与えたことにより再設定が失敗した際の動作を実装する
div#error_explanation
内に、再設定が失敗した理由を表示する(ビュー側の実装)
実は、error_explanation
というCSS IDは、Railsチュートリアル 第7章の(ユーザー登録失敗時の)エラーメッセージという節において既に登場しています。
同節で実装したapp/views/shared/_error_messages.html.erb
の内容を見てみましょう。
<% if @user.errors.any? %>
<div id="error_explanation">
<div class="alert alert-danger">
The form contains <%= pluralize(@user.errors.count, "error") %>.
</div>
<ul>
<% @user.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
これから実装する何の機能に関わってくるかといいますと、「無効なパスワードを与えたことにより再設定が失敗した際、その理由をビューに表示するようにHTMLを組み立てる」という機能です。
app/views/password_resets/edit.html.erb
でいえば、以下の行が当該機能に関係してきます。
<%= render 'shared/error_messages'%>
パスワードの再設定が有効になる場合と無効になる場合を定義し、無効になる場合について、エラーとして表示させる内容を定義する(コントローラー側の実装)
ここまでの実装で、ビュー側でエラーを表示できるようになっていることがわかりました。しかしながら、「何をエラーとして表示させるか」は、コントローラー側で定義しなければなりません。
今回、エラーとして表示させるべき無効な変更は、以下の4つとなります。
- パスワード再設定の有効期限が切れている
- パスワードとパスワード(再入力)が不一致
- パスワードが短すぎる
- パスワードに空文字列が入力された
当然ながら、「パスワードの再設定が正しく反映される状態」についても定義しておく必要があります。
パスワードに空文字列が入力された場合の実装を追加
まず、「パスワードに空文字列が入力された場合」の実装を追加していきます。
class PasswordResetsController < ApplicationController
...略
def update
+ if params[:user][:password].empty?
+ @user.errors.add(:password, :blank)
+ render 'edit'
+ end
end
private
...略
end
パスワードに空文字列が追加された場合の実装を追加した時点における、テストの実行結果
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 853
Started with run options --seed 40245
ERROR["test_password_resets", PasswordResetsTest, 5.36249929999758]
test_password_resets#PasswordResetsTest (5.36s)
NoMethodError: NoMethodError: undefined method `document' for nil:NilClass
test/integration/password_resets_test.rb:53:in `block in <class:PasswordResetsTest>'
1/1: [===================================] 100% Time: 00:00:05, Time: 00:00:05
Finished in 5.36864s
1 tests, 14 assertions, 0 failures, 1 errors, 0 skips
test/integration/password_resets_test.rb
の53行目でエラーが発生してテストが失敗しています。私の環境では、test/integration/password_resets_test.rb
の48行目から53行目には以下のコードが記述されています。
# パスワードとパスワード(再入力)が不一致
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "foobaz",
password_confirmation: "barquux" } }
assert_select 'div#error_explanation'
今度は、パスワードとパスワード(再入力)が不一致の場合にdiv#error_explanation
を正しく出力できるようにする必要がありそうです。
パスワードとパスワード(再入力)が不一致の場合の実装を追加
パスワードとパスワード(再入力)が不一致の場合の実装を追加していきます。
class PasswordResetsController < ApplicationController
...略
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
+ elsif @user.update_attributes(user_params)
+ # TODO: ここにパスワード再設定成功時の記述を追加する
+ else
+ render 'edit'
end
end
private
+
+ def user_params
+ params.require(:user).permit(:password, :password_confirmation)
+ end
+
+ # beforeフィルター
...略
end
パスワードとパスワード(再入力)が不一致の場合の実装を追加した時点における、テストの実行結果
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 916
Started with run options --seed 55320
FAIL["test_password_resets", PasswordResetsTest, 4.5419232999993255]
test_password_resets#PasswordResetsTest (4.54s)
Expected true to be nil or false
test/integration/password_resets_test.rb:68:in `block in <class:PasswordResetsTest>'
1/1: [===================================] 100% Time: 00:00:04, Time: 00:00:04
Finished in 4.54489s
1 tests, 18 assertions, 1 failures, 0 errors, 0 skips
test/integration/password_resets_test.rb
の68行目でエラーが発生してテストが失敗しています。私の環境では、test/integration/password_resets_test.rb
の60行目から69行目には以下のコードが記述されています。
# パスワード再設定トークンが期限切れ
user.reset_sent_at = 48.hours.ago
user.update_attribute(:reset_sent_at, user.reset_sent_at)
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
assert_not is_logged_in?
assert_not flash.empty? # <-- 今回失敗したテスト
assert_redirected_to new_password_reset_url
今度は、再設定トークンが期限切れの場合にフラッシュメッセージが定義されていないためにテストが失敗しているようです。
「パスワードが短すぎる場合」に対するテストは成功する
なお、この時点で、以下test/integration/password_resets_test.rb
の54行目から59行目のテストは成功します。
# 短すぎるパスワード
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "foo",
password_confirmation: "foo" } }
assert_select 'div#error_explanation'
再設定トークンが期限切れの場合の実装を追加
再設定トークンが期限切れの場合の実装を追加していきます。
PasswordResetsコントローラーに、新たなbeforeフィルターcheck_expiration
を追加する
まずはPasswordResetsコントローラーへの実装の追加です。
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
+ before_action :check_expiration, only: [:edit, :update]
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new'
end
end
def edit
end
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
# TODO: ここにパスワード再設定成功時の記述を追加する
else
render 'edit'
end
end
private
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
# beforeフィルター
def get_user
@user = User.find_by(email: params[:email])
end
# 正しいユーザーかどうか確認する
def valid_user
unless (@user && @user.activated? &&
@user.authenticated?(:reset, params[:id]))
redirect_to root_url
end
end
+
+ # トークンが有効期限切れかどうか確認する
+ def check_expiration
+ if @user.password_reset_expired?
+ flash[:danger] = "Password reset has expired."
+ redirect_to new_password_reset_url
+ end
end
end
check_expiration
というbeforeフィルターと、check_expiration
の実装を追加しています。check_expiration
フィルターは、PasswordResetsコントローラーのedit
およびupdate
の両アクションに適用されるように実装しています。
Userモデルに、パスワード再設定トークンの有効期限が切れているかを判定するメソッドを追加する
class User < ApplicationRecord
...略
+
+ # パスワード再設定の有効期限が切れている場合はtrueを返す
+ def password_reset_expired?
+ reset_sent_at < 2.hours.ago
+ end
private
...略
end
パスワード再設定トークンの有効期限は2時間とします。
reset_sent_at < 2.hours.ago
上記の式は、「パスワード再設定トークンの生成時刻が、現在時刻より2時間以上早い時刻であれば真」という内容になります。
再設定トークンが期限切れの場合の実装を追加した時点における、テストの実行結果
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 994
Started with run options --seed 55871
FAIL["test_password_resets", PasswordResetsTest, 4.854518399995868]
test_password_resets#PasswordResetsTest (4.85s)
Expected false to be truthy.
test/integration/password_resets_test.rb:77:in `block in <class:PasswordResetsTest>'
1/1: [===================================] 100% Time: 00:00:04, Time: 00:00:04
Finished in 4.85761s
1 tests, 20 assertions, 1 failures, 0 errors, 0 skips
test/integration/password_resets_test.rb
の77行目でエラーが発生してテストが失敗しています。私の環境では、test/integration/password_resets_test.rb
の70行目から79行目には以下のコードが記述されています。
# 有効なパスワードとパスワード確認
user.reset_sent_at = Time.zone.now
user.update_attribute(:reset_sent_at, user.reset_sent_at)
patch password_reset_path(user.reset_token),
params: { email:user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
assert is_logged_in?
assert_not flash.empty?
assert_redirected_to user
77行目、ということは、「ログイン処理が実装されていない」ということですね。
パスワードの再設定が成功した後の処理を実装する
パスワードの再設定が成功する条件
パスワードの再設定が成功するのは以下の条件すべてを満たした場合です。
- パスワード再設定トークンが有効である
- フォームに入力されたパスワードとパスワード(確認)の両方が一致する
- パスワードとして入力された文字列が空でない
- パスワードが短すぎない
パスワードの再設定が成功した場合の処理
また、パスワードの再設定が成功した場合の処理は以下となります。
- パスワード再設定の対象となったユーザーでログインする
- パスワードの再設定が成功した旨のフラッシュメッセージを定義する
- ユーザーのプロフィールページにリダイレクトする
パスワードの再設定が成功した場合の処理の実装
上記を踏まえ、パスワードの再設定が成功した場合の処理の実装は以下のようになります。
class PasswordResetsController < ApplicationController
before_action :get_user, only: [:edit, :update]
before_action :valid_user, only: [:edit, :update]
before_action :check_expiration, only: [:edit, :update]
def new
end
def create
@user = User.find_by(email: params[:password_reset][:email].downcase)
if @user
@user.create_reset_digest
@user.send_password_reset_email
flash[:info] = "Email sent with password reset instructions"
redirect_to root_url
else
flash.now[:danger] = "Email address not found"
render 'new'
end
end
def edit
end
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
- # TODO: ここにパスワード再設定成功時の記述を追加する
+ log_in @user
+ flash[:success] = "Password has been reset."
+ redirect_to @user
else
render 'edit'
end
end
private
def user_params
params.require(:user).permit(:password, :password_confirmation)
end
# beforeフィルター
def get_user
@user = User.find_by(email: params[:email])
end
# 正しいユーザーかどうか確認する
def valid_user
unless (@user && @user.activated? &&
@user.authenticated?(:reset, params[:id]))
redirect_to root_url
end
end
# トークンが有効期限切れかどうか確認する
def check_expiration
if @user.password_reset_expired?
flash[:danger] = "Password reset has expired."
redirect_to new_password_reset_url
end
end
end
パスワードの再設定が成功した場合の処理の実装が完了すると、テストが成功する
test/integration/password_resets_test.rb
に対するテスト
ここまでの実装が完了した時点で、test/integration/password_resets_test.rb
に対してテストを実行していきましょう。
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1020
Started with run options --seed 42135
1/1: [===================================] 100% Time: 00:00:04, Time: 00:00:04
Finished in 4.47597s
1 tests, 23 assertions, 0 failures, 0 errors, 0 skips
テストが成功しました。
テストスイート全体
今度はテストスイート全体に対してテストを実行していきましょう。
# rails test
Running via Spring preloader in process 1033
Started with run options --seed 8141
48/48: [=================================] 100% Time: 00:00:09, Time: 00:00:09
Finished in 9.81715s
48 tests, 228 assertions, 0 failures, 0 errors, 0 skips
こちらのテストも成功しました。