ここまでに実装してきたものと、次に何を実装するか
ここまでの実装により、PasswordResetsコントローラーのnew
アクションおよびcreate
アクションに対する実装が完成しました。次に必要となるのは、同じくPasswordResetsコントローラーの、edit
アクションおよびupdate
アクションに対する実装です。
edit
アクションで再設定
長くなりましたので、別記事で解説します。
演習 - edit
アクションで再設定
1. 12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。
まず、有効なユーザーのメールアドレスであるexample-2@railstutorial.org
を対象に、パスワード再設定用メールの送信を求める操作をしてみましょう。
この画面で「Submit」ボタンをクリックすると、パスワード再設定用URLを含むメールを送信する処理がされます。Railsサーバーのログには、以下の記録があります。
Sent mail to example-2@railstutorial.org (8.3ms)
Date: Sun, 15 Dec 2019 07:58:01 +0000
From: noreply@example.com
To: example-2@railstutorial.org
Message-ID: <5df5e7895d0be_1c62ac8ce7605cc87241@705320d4d96d.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
...略
メール本文中に記載されたパスワード再設定用URLは以下のとおりです。
https://localhost:3000/password_resets/D48WcYU6q-K2SHWqjYsrEA/edit?email=example-2%40railstutorial.org
私の環境では、Railsが動作しているのはDocker上であり、Docker環境のTCPポート番号3000はローカル環境のポート番号8080に紐付けされています。また、URLスキームが https:// ではアクセスできず、http:// でアクセスする必要があります。そのため、ローカルからアクセスする場合、アクセス先のURLは以下となります。
http://localhost:8080/password_resets/D48WcYU6q-K2SHWqjYsrEA/edit?email=example-2%40railstutorial.org
実際に上述URLにアクセスすると、Webブラウザには以下のような画面が出力されます。
2. 先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?
上記のように、「Unknown action」というエラー画面が表示されます。
Railsサーバーのログには、以下の記録があります。
Started PATCH "/password_resets/D48WcYU6q-K2SHWqjYsrEA" for 172.17.0.1 at 2019-12-15 08:12:04 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
AbstractController::ActionNotFound (The action 'update' could not be found for PasswordResetsController):
...スタックトレース略
現時点でPasswordResetsコントローラーに対するupdate
アクションが実装されていないためのエラーですね。
パスワードを更新する
長くなりましたので、別記事で解説します。
演習 - パスワードを更新する
1. 12.2.1.1で得られたリンク (Railsサーバーのログから取得) をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?
まずは、パスワード再設定用のフォームを表示させます。
passwordに「foobaz」、confirmationに「barquux」と入力して「Submit」ボタンを押してみます。
「The form contains 1 error. Password confirmation doesn't match Password」というエラーメッセージが表示されています。
サーバーログには以下のように表示されています。
Started PATCH "/password_resets/IlMln7IBh63Pd0zjHzgWmw" ...略
Processing by PasswordResetsController#update as HTML
Parameters: {...略 "email"=>"example-2@railstutorial.org", "user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Update password", "id"=>"IlMln7IBh63Pd0zjHzgWmw"}
User Load (8.2ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "example-2@railstutorial.org"], ["LIMIT", 1]]
(0.1ms) begin transaction
User Exists (2.1ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ? [["email", "example-2@railstutorial.org"], ["id", 3], ["LIMIT", 1]]
(0.1ms) rollback transaction
...略
Completed 200 OK in 660ms (Views: 458.4ms | ActiveRecord: 10.5ms)
「rollback transaction」とありますね。確かにRDBに対する更新が取り消されています。
2.1. コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digest
の値を取得してみましょう。
「example-2@railstutorial.org
というメールアドレスを持つユーザーのパスワード再設定を送信した」という前提です。
# rails console --sandbox
>> user = User.find_by(email: "example-2@railstutorial.org")
>> user.password_digest
=> "$2a$10$lVjDouNtJJObQKOU4uSrtuTeU9/rPu2MkrSyfQr.NETDUXEKyxDJq"
2.2. 次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう (図 12.13)。パスワードの再設定は成功したら、再度password_digest
の値を取得し、先ほど取得した値と異なっていることを確認してみましょう。
ヒント: 新しい値は
user.reload
を通して取得する必要があります。
Railsコンソールをサンドボックスモードで起動した場合、RDBの変更を伴うリクエストを送出する前に一旦Railsコンソールを抜けなければなりません。そうでないと、RDBの変更を伴うリクエストを受け取った時点でエラーが返ってしまいます。サンドボックスモードのRailsコンソールはRDBをロックするためです。
# rails console --sandbox
>> user = User.find_by(email: "example-2@railstutorial.org")
>> user.password_digest
=> "$2a$10$IOSmbIOqlca85aXRmwMqpuc53.KSSnOInMEyRqSAtDmkvNwcxUGU2"
$2a$10$lVjDouNtJJObQKOU4uSrtuTeU9/rPu2MkrSyfQr.NETDUXEKyxDJq
$2a$10$IOSmbIOqlca85aXRmwMqpuc53.KSSnOInMEyRqSAtDmkvNwcxUGU2
確かにpassword_digest
の値は変化しています。
パスワードの再設定をテストする
ここまでの実装が完了すると、test/integration/password_resets_test.rb
の全体像は以下のようになります。
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
このテストが成功することを確認しておきましょう。
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1070
Started with run options --seed 8172
1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.99857s
1 tests, 23 assertions, 0 failures, 0 errors, 0 skips
テストスイート全体が成功することも確認しておきます。
# rails test
Running via Spring preloader in process 1083
Started with run options --seed 64812
48/48: [=================================] 100% Time: 00:00:06, Time: 00:00:06
Finished in 6.42649s
48 tests, 228 assertions, 0 failures, 0 errors, 0 skips
演習 - パスワードの再設定をテストする
演習に臨む前に、上述のテストコードの一部を削除する必要があります。
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
1.1. リスト 12.20に記したテンプレートを使って、update_attribute
の呼び出しを1回のupdate_columns
呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。
リスト 12.6にある
create_reset_digest
メソッドはupdate_attribute
を2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。
app/models/user.rb
を以下のように変更していきます。「前章の演習(リスト 11.39)の解答」というのは、activate
メソッドのことですね。
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token, :reset_token
...略
# アカウントを有効にする
def activate
update_columns(activated: true, activated_at: Time.zone.now)
end
...略
# パスワード再設定の属性を設定する
def create_reset_digest
self.reset_token = User.new_token
- update_attribute(:reset_digest, User.digest(reset_token))
- update_attribute(:reset_sent_at, Time.zone.now)
+ update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now)
end
...略
private
...略
end
1.2. また、変更後にテストを実行し、green
になることも確認してください。
必要最低限のテストを実行します。
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1109
Started with run options --seed 19282
1/1: [===================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.49273s
1 tests, 23 assertions, 0 failures, 0 errors, 0 skips
2. リスト 12.21のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐 (リスト 12.16) を統合テストで網羅してみましょう。
期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます (なお、大文字と小文字は区別されません)。
(リスト 12.21のコードにある
response.body
は、そのページのHTML本文をすべて返すメソッドです)
テストコードそのものは以下のようになります。「有効なパスワードとパスワード確認」とコードの構成は似ていますが、@user
とuser
を取り違えるミスに注意が必要です。
test "expired token" do
get new_password_reset_path
post password_resets_path, params: { password_reset: { email: @user.email } }
@user = assigns(:user)
@user.update_attribute(:reset_sent_at, 3.hours.ago)
patch password_reset_path(@user.reset_token),
params: { email:@user.email,
user: { password: "foobar",
password_confirmation: "foobar" } }
assert_response :redirect
follow_redirect!
assert_match /expired/, response.body
end
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear
@user = users(:rhakurei)
end
test "password resets" do
...略
end
+
+ test "expired token" do
+ get new_password_reset_path
+ post password_resets_path, params: { password_reset: { email: @user.email } }
+ @user = assigns(:user)
+ @user.update_attribute(:reset_sent_at, 3.hours.ago)
+ patch password_reset_path(@user.reset_token),
+ params: { email:@user.email,
+ user: { password: "foobar",
+ password_confirmation: "foobar" } }
+ assert_response :redirect
+ follow_redirect!
+ assert_match /expired/, response.body
+ end
end
2.2. (追加演習)変更後にテストを実行し、green
になることも確認してください。
私の環境では、以下の行がtest/integration/password_resets_test.rb
の72行目となります。
test "expired token" do
上記を踏まえて、対応するテストを実行してみましょう。
# rails test test/integration/password_resets_test.rb:72
Running via Spring preloader in process 1148
Started with run options --seed 28814
2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.95873s
1 tests, 3 assertions, 0 failures, 0 errors, 0 skips
途中でtypo等がなければ、テストは上記のように成功するはずです。
同様のアプローチで、PasswordResetsコントローラーに対するここまでのテストすべてを「1つのテストで1つの機能に対してテストを行うようにする」というリファクタリングもできそうです。
3. 下記問題を解決するために、リスト 12.22のコードを追加し、パスワードの再設定に成功したらダイジェストをnil
になるように変更してみましょう。
2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、もっと良くする方法はまだあります。例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます (しかもそのままログイン機構まで突破されてしまいます!)。
PasswordResetsController#update
の内容を、以下のように変更します。
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
log_in @user
+ @user.update_attribute(:reset_digest, nil)
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit'
end
end
4. リスト 12.18に1行追加し、1つ前の演習課題に対するテストを書いてみましょう。
ヒント: リスト 9.25の
assert_nil
メソッドとリスト 11.33のuser.reload
メソッドを組み合わせて、reset_digest
属性を直接テストしてみましょう。
対応するテストの内容は以下のようになります。
assert_nil user.reload.reset_digest
正しい対象に対してテストが行われていることの確認
PasswordResetsController#update
の内容が以下である場合、テストは失敗します。
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
log_in @user
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit'
end
end
失敗の内容は以下です。
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1161
Started with run options --seed 17444
FAIL["test_password_resets", PasswordResetsTest, 3.2027707000015653]
test_password_resets#PasswordResetsTest (3.20s)
Expected "$2a$04$VSxeyLa4LwjS2fyhIBYe2.flQQgt9j2TrpgleMOexHd/j1Y63b6I2" to be nil.
test/integration/password_resets_test.rb:68:in `block in <class:PasswordResetsTest>'
2/2: [===================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.20832s
2 tests, 21 assertions, 1 failures, 0 errors, 0 skips
私の環境では、test/integration/password_resets_test.rb
の68行目は、現時点で以下の内容となっています。
assert_nil user.reload.reset_digest
テスト失敗時のメッセージの内容は以下です。
Expected "$2a$04$VSxeyLa4LwjS2fyhIBYe2.flQQgt9j2TrpgleMOexHd/j1Y63b6I2" to be nil.
「reset_digest
がnil
でない」ということですね。確かに正しい対象に対してテストが行われているようです。
正しい実装をすればテストが成功することの確認
PasswordResetsController#update
の内容を、以下のように変更します。
def update
if params[:user][:password].empty?
@user.errors.add(:password, :blank)
render 'edit'
elsif @user.update_attributes(user_params)
log_in @user
+ @user.update_attribute(:reset_digest, nil)
flash[:success] = "Password has been reset."
redirect_to @user
else
render 'edit'
end
end
再びテストを実行していきましょう。
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 1188
Started with run options --seed 40656
2/2: [===================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.84093s
2 tests, 24 assertions, 0 failures, 0 errors, 0 skips
今度こそテストが成功しました。
# rails test
Running via Spring preloader in process 1201
Started with run options --seed 49326
49/49: [=================================] 100% Time: 00:00:07, Time: 00:00:07
Finished in 7.45029s
49 tests, 229 assertions, 0 failures, 0 errors, 0 skips
上述のように、全体のテストも成功することを確認しておきましょう。