0
0

More than 3 years have passed since last update.

Railsチュートリアル 第12章 パスワードの再設定 - PasswordResets#updateの処理をテスト駆動で実装していく

Posted at

PasswordResetsコントローラーの、ここまでに実装してきた内容に対するテスト

PasswordResetsコントローラーのnewアクション、createアクション、およびeditアクションに対するテストは、Railsチュートリアル本文の内容に合わせていくと、以下のような内容になります。

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
  end
end

パスワードを更新する際の前提条件

「パスワードの再設定」というユースケースをより具体的に解説すると、以下のようになります。

  1. Webアプリケーションは、パスワード再設定の有効期限が切れていないことを確認する
  2. Webアプリケーションは、フォームから送信されるパスワードを受け取る
  3. Webアプリケーションは、2.で受け取ったパスワードが正当であることを確認する
  4. Webアプリケーションは、パスワードが正当であれば、当該パスワードでRDBを更新する

Railsチュートリアル本文では、「パスワードの正当性」にかかる実装は、以下のような方針で組み立てていくこととしています。

  • 無効なパスワードであれば、再設定を失敗させる(失敗した理由も表示する)
  • 空の文字列を新しいパスワードとして与えることはできない
    • ユーザー情報の編集では、「パスワードを更新せずに他の属性値だけを更新する」というユースケースを想定し、空の文字列を新しいパスワードとして与えることを可能としていた

パスワードの更新に対するテスト

上記前提を踏まえた上で、PasswordResetsコントローラーのupdateアクションに対するテストを追加していきましょう。

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

なお、「再設定トークンが期限切れ」という状態は、「テスト用の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行目には以下のコードが記述されています。

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アクションを定義します。この時点では、まだ中身の実装には着手しません。

app/controllers/password_resets_controller.rb
  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行目には以下のコードが記述されています。

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の内容を見てみましょう。

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行目には以下のコードが記述されています。

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行目には以下のコードが記述されています。

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行目のテストは成功します。

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コントローラーへの実装の追加です。

app/controllers/password_resets_controller.rb
  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モデルに、パスワード再設定トークンの有効期限が切れているかを判定するメソッドを追加する

app/models/user.rb
  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行目には以下のコードが記述されています。

ruby
# 有効なパスワードとパスワード確認
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行目、ということは、「ログイン処理が実装されていない」ということですね。

パスワードの再設定が成功した後の処理を実装する

パスワードの再設定が成功する条件

パスワードの再設定が成功するのは以下の条件すべてを満たした場合です。

  • パスワード再設定トークンが有効である
  • フォームに入力されたパスワードとパスワード(確認)の両方が一致する
  • パスワードとして入力された文字列が空でない
  • パスワードが短すぎない

パスワードの再設定が成功した場合の処理

また、パスワードの再設定が成功した場合の処理は以下となります。

  • パスワード再設定の対象となったユーザーでログインする
  • パスワードの再設定が成功した旨のフラッシュメッセージを定義する
  • ユーザーのプロフィールページにリダイレクトする

パスワードの再設定が成功した場合の処理の実装

上記を踏まえ、パスワードの再設定が成功した場合の処理の実装は以下のようになります。

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

こちらのテストも成功しました。

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