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

# 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`アクションに対するテストを追加していきましょう。

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

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

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

```ruby: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章の[（ユーザー登録失敗時の）エラーメッセージ](https://railstutorial.jp/chapters/sign_up?version=5.1#sec-signup_error_messages)という節において既に登場しています。

同節で実装した`app/views/shared/_error_messages.html.erb`の内容を見てみましょう。

```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`でいえば、以下の行が当該機能に関係してきます。

```erb:
<%= render 'shared/error_messages'%>
```

## パスワードの再設定が有効になる場合と無効になる場合を定義し、無効になる場合について、エラーとして表示させる内容を定義する（コントローラー側の実装）

ここまでの実装で、ビュー側でエラーを表示できるようになっていることがわかりました。しかしながら、「何をエラーとして表示させるか」は、コントローラー側で定義しなければなりません。

今回、エラーとして表示させるべき無効な変更は、以下の4つとなります。

* パスワード再設定の有効期限が切れている
* パスワードとパスワード（再入力）が不一致
* パスワードが短すぎる
* パスワードに空文字列が入力された

当然ながら、「パスワードの再設定が正しく反映される状態」についても定義しておく必要があります。

### パスワードに空文字列が入力された場合の実装を追加

まず、「パスワードに空文字列が入力された場合」の実装を追加していきます。

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

```ruby: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`を正しく出力できるようにする必要がありそうです。

### パスワードとパスワード（再入力）が不一致の場合の実装を追加

パスワードとパスワード（再入力）が不一致の場合の実装を追加していきます。

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

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

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

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

```diff:app/models/user.rb
  class User < ApplicationRecord
    ...略
+
+   # パスワード再設定の有効期限が切れている場合はtrueを返す
+   def password_reset_expired?
+     reset_sent_at < 2.hours.ago
+   end

    private

      ...略
  end
```

パスワード再設定トークンの有効期限は2時間とします。

```ruby:
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:ruby: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行目、ということは、「ログイン処理が実装されていない」ということですね。

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

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

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

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

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

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

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

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

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

```diff: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
```

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