35歳だけどRailsチュートリアルやってみた。[第4版 12章 12.3 パスワードを再設定する まとめ&解答例]

#はじめに
最近、プロジェクト管理業務が業務の大半を占めており、
プログラムを書く機会がなかなかありません。

このままだとプログラムがまったく書けない人になってしまう危機感（迫り来る35歳定年説）と、
新しいことに挑戦したいという思いから、
[Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版](https://railstutorial.jp/)を学習中です。
業務で使うのはもっぱらJavaなのですが、Rails楽しいですね。


これまでEvernoteに記録していましたが、ソースコードの貼付けに限界を感じたため、
Qiitaで自分が学習した結果をアウトプットしていきます。

個人の解答例なので、誤りがあればご指摘ください。

##動作環境
- cloud9
- ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-linux]
- Rails 5.0.0.1

##12.3 パスワードを再設定する
パスワード再設定メール送信後、URLをクリックした場合の処理を作成する。
PasswordResetsコントローラのeditアクションを実装していく。

##12.3.1 editアクションで再設定
###本章での学び
####【view】パスワード再設定のフォームを作成する
自動生成されたフォームの中身を実装する。

```/sample_app/app/views/password_resets/edit.html.erb
<h1>PasswordResets#edit</h1>
<p>Find me in app/views/password_resets/edit.html.erb</p>
```

メールアドレスを下記のとおり取得できるようにする。

- editアクション
    - メールアドレス入りのリンクから取得
- updateアクション
    - hiddenフィールドに保持（`hidden_field_tag`を使用する）
    - params[:email]から取得

```/sample_app/app/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Reset Password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
```

####【view】フォームタグヘルパー
記載方法が似ているが、保存先のハッシュが異なる。

- hidden_field_tag :email, @user.email
    - メールアドレスが、`params[:email]`に保存される
- f.hidden_field :email, @user.email
    - メールアドレスが、`params[:user][:email]`に保存される

####【controller】パスワード再設定のeditアクションを実装する
editアクション内の実装は不要。
beforeアクションで、emailからユーザーを検索し、正しいユーザーかどうかを確認する。

正しいユーザーの定義は以下の通り。

- ユーザーが存在すること
- 有効化されていること
- 認証済みであること

この３つを満たさない場合は、ルートURLにリダイレクトする。

```/sample_app/app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]

  ・・・略・・・

  def edit
  end

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

####動作確認
ブラウザを起動し、パスワード再設定メールを送信する。
パスワード再設定メールは、コンソール上に表示される。

```irb
To reset your password click the link below:

https://xxxxxxxx/password_resets/KEiOUcT0bJfsZhiNlmMG2g/edit?email=example%40railstutorial.org

This link will expire in tow hours.

If you did not request your password to be reset, please ignore this email and your password will stay as it is.
```

メール内のパスワード再設定URLにアクセスすると、
パスワード再設定画面が表示されることを確認。

![image](https://qiita-image-store.s3.amazonaws.com/0/98130/2c6c2774-a331-82f8-ec12-fbdd9bbed4c1.png)

###演習1
*12.2.1.1で示した手順に従って、Railsサーバーのログから送信メールを探し出し、そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて、図 12.11のように表示されるか確かめてみましょう。*

前述の「動作確認」参照。

###演習2
*先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?*

updateアクションが存在しないためエラーとなる。

##12.3.2 パスワードを更新する
###本章での学び
フォームからの送信に対応するupdateアクションを実装する。
以下、4つの手順で実装する。

####手順１．パスワード再設定の有効期限が切れていないか
editアクションと、updateアクションにbeforeフィルターを追加して、
パスワード有効期限切れチェックを行う。

#####【model】パスワード有効期限切れチェックを追加する
Userモデルにインスタンスメソッドを追加する。
「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前（早い）」の場合、期限切れとみなす。

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

####【controller】パスワード有効期限切れチェックを追加する

```/sample_app/app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
・・・略・・・
  before_action :check_expiration, only: [:edit, :update] # (1)への対応

  private
・・・略・・・
    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
```


####手順２．無効なパスワードであれば失敗させる (失敗した理由も表示する)

beforeフィルターで保護したupdateアクションを使う。
DBの更新に失敗した時に、editビューが再描画され、パーシャルにエラーメッセージを表示する。

####手順３．新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)

ハッシュからパスワードを取得して、空白ならば、`@user`にエラーメッセージを追加する。

####手順４．新しいパスワードが正しければ、更新する

ストロングパラメータを使ってDB更新が成功した場合、ログイン状態にして成功メッセージを表示させる。

####【controller】手順２〜４の実装まとめ
上記の手順２〜４をupdateアクションに実装する。

```/sample_app/app/controllers/password_resets_controller.rb
  def update
    if params[:user][:password].empty?  # (3)への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)  # (4)への対応
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit' # (2)への対応
    end
  end

・・・略・・・

  private
    
    # Strong parameters
    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end
```

####動作確認
ブラウザを起動し、パスワード再設定を行えることを確認。

![image](https://qiita-image-store.s3.amazonaws.com/0/98130/7355c405-b5ad-4ac7-3a3e-d68296fc1c1c.png)


###演習1
*12.2.1.1で得られたリンク (Railsサーバーのログから取得) をブラウザで表示し、passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?*

パスワード
![image](https://qiita-image-store.s3.amazonaws.com/0/98130/f43c0d19-aac2-3279-c44c-c1ea5946a557.png)


###演習2
*コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう (図 12.13)。パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。ヒント: 新しい値はuser.reloadを通して取得する必要があります。*

コンソールから、パスワード再設定を送信したユーザーオブジェクトを検索し、
password_digestの値を取得する。

```irb
yokoyan:~/workspace/sample_app (password-reset) $ rails console
Running via Spring preloader in process 1646
Loading development environment (Rails 5.0.0.1)
>> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2017-06-20 04:07:12", updated_at: "2017-07-12 21:58:37", password_digest: "$2a$10$A2LTYT9xQH3HYqyzUp1uoutfRsE/qb3I9H5S1M4iBAT...", remember_digest: nil, admin: true, activation_digest: "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/...", activated: true, activated_at: "2017-06-20 04:07:12", reset_digest: "$2a$10$Omi.3o6nMIUUXetPcnvRlOYLV5AwlUW2MVKV6cCJdK0...", reset_sent_at: "2017-07-12 21:58:37">
>> 
?> user.password_digest
=> "$2a$10$A2LTYT9xQH3HYqyzUp1uoutfRsE/qb3I9H5S1M4iBAT.2xnP/xk2O"
```

パスワードの再設定を行う。

![image](https://qiita-image-store.s3.amazonaws.com/0/98130/8be12ea5-6a06-cc6a-64fb-30b0aef20057.png)

コンソールからリロードして、password_digestの値が更新されていることを確認。

```irb
?> user.reload
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2017-06-20 04:07:12", updated_at: "2017-07-15 00:13:29", password_digest: "$2a$10$UZx78WvxKtgCihJNy.674Oj72HHCUTjc0HczrEu5yXu...", remember_digest: nil, admin: true, activation_digest: "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/...", activated: true, activated_at: "2017-06-20 04:07:12", reset_digest: "$2a$10$mpcTDln7FOmzP2UaCv7qieLT5OQiIO8KbxHZ53PQuGz...", reset_sent_at: "2017-07-15 00:13:03">
>> 
?> user.password_digest
=> "$2a$10$UZx78WvxKtgCihJNy.674Oj72HHCUTjc0HczrEu5yXuhI2jao7cYG"
```

##12.3.3 パスワードの再設定をテストする
###本章での学び
送信に成功した場合と、失敗した場合の統合テストを作成する。

####【test】パスワード再設定のテストファイルを作成

```irb
yokoyan:~/workspace/sample_app (password-reset) $ rails generate integration_test password_resets
Running via Spring preloader in process 1685
Expected string default value for '--jbuilder'; got true (boolean)
      invoke  test_unit
      create    test/integration/password_resets_test.rb
```

####【test】パスワード再設定の統合テストの実装

#####事前準備
- 事前準備
    - 配信方法が`:test`の場合、メールを保存する配列をクリアする
        - 参考情報：[Action Mailer の基礎
](https://railsguides.jp/action_mailer_basics.html)
    - michaelの情報を取得し、インスタンス変数`@user`に格納

```/sample_app/test/integration/password_resets_test.rb
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end
```

#####メールアドレスが無効の場合
- テストメソッド
    - GETリクエストを送信する。パスワードリマインダー画面（new_password_resets_path）へ。
    - パスワードリマインダー画面のテンプレートが表示されること
    - POSTリクエストを送信する。（password_resets_path）
        - メールアドレスはブランク
    - フラッシュメッセージが空白ではないこと（エラーメッセージが表示）
    - パスワードリマインダー画面のテンプレートが表示されること

```/sample_app/test/integration/password_resets_test.rb
    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'
```

#####メールアドレスが有効の場合

- テストメソッド
    - POSTリクエストを送信する。（password_reset_path）
        - メールアドレス：`@user`のメールアドレス
    - `@user`のパスワードダイジェストと、`@user`をリロードしたパスワードダイジェストが一致しないこと（変更されていること）
    - メイラーの配列に保存された件数が１件であること
    - フラッシュメッセージが空白ではないこと（成功メッセージが表示）
    - root_urlにリダイレクトされること

```/sample_app/test/integration/password_resets_test.rb
    # メールアドレスが有効
    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`の代わりに、`user`を今後使用
    - 参考：[テスト(test)](http://railsdoc.com/test)

```/sample_app/test/integration/password_resets_test.rb
    # パスワード再設定フォームのテスト
    user = assigns(:user)
```

#####メールアドレスが無効の場合

- GETリクエストを送信する（edit_password_reset_path）
    - リセットトークン：`user.token`
    - メールアドレス：ブランク
- root_urlにリダイレクトする

```/sample_app/test/integration/password_resets_test.rb
    # メールアドレスが無効の場合
    get edit_password_reset_path(user.reset_token, email:"")
    assert_redirected_to root_url
```

#####無効なユーザー

- `user`の有効化フラグを逆転
    - toggleでbooleanを逆転できる
    - 参考：[Railsのいろんなメソッド](http://qiita.com/ysk_1031/items/5d1fa1321abf2f432674)
- GETリクエストを送信する。（edit_password_reset_path）
    - リセットトークン：`user.token`
    - メールアドレス：`user.email`
- root_urlにリダイレクトすること
- `user`の有効化フラグを逆転（元に戻す）

```/sample_app/test/integration/password_resets_test.rb
    # 無効なユーザー
    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
    - メールアドレス：`user.email`
- root_urlにリダイレクトすること

```/sample_app/test/integration/password_resets_test.rb
    # メールアドレスが有効、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
```

#####メールアドレスもトークンも有効
- GETリクエストを送信する。（edit_password_reset_path）
    - リセットトークン：`user.reset_token`
    - メールアドレス：`user.email`
- パスワード再設定画面のテンプレートが表示されること
- 画面のHTMLに、以下が表示されていること
    - input属性
    - name:email
    - type:hidden
    - value:user.email

```/sample_app/test/integration/password_resets_test.rb
    # メールアドレスもトークンも有効
    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）
    - email:`user.email`
    - user
        - password：foobaz
        - password_confirmation：barquux
- 画面のHTML、以下が表示されていること(エラーになること）
    - div#error_explanation

```/sample_app/test/integration/password_resets_test.rb
    # 無効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "barquux"
            }
        }
    assert_select 'div#error_explanation'
```

#####パスワードが空

- PATCHリクエストを送信する。（password_reset_path）
    - email:`user.email`
    - user
        - password：ブランク
        - password_confirmation：ブランク
- 画面のHTML、以下が表示されていること(エラーになること）
    - div#error_explanation

```/sample_app/test/integration/password_resets_test.rb
    # パスワードが空
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "",
                password_confirmation: ""
            }
        }
    assert_select 'div#error_explanation'
```

#####有効なパスワードとパスワード確認

- PATCHリクエストを送信する。（password_reset_path）
    - email:`user.email`
    - user
        - password：foobaz
        - password_confirmation：foobaz
- ログイン状態であることを確認
- フラッシュメッセージが空ではないこと（成功メッセージが表示）
- ユーザープロフィール画面へリダイレクトする

```/sample_app/test/integration/password_resets_test.rb
    # 有効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "foobaz"
            }
        }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
```

###演習1
*リスト 12.6にあるcreate_reset_digestメソッドはupdate_attributeを２回呼び出していますが、これは各行で１回ずつデータベースへ問い合わせしていることになります。リスト 12.20に記したテンプレートを使って、update_attributeの呼び出しを１回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが１回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。ちなみにリスト 12.20にあるコードには、前章の演習 (リスト 11.39) の解答も含まれています。*

2回DBに更新している箇所を、1回にまとめる。

```/sample_app/app/models/user.rb
  # パスワード再設定の属性を追加する
  def create_reest_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
```

テストがgreenになることを確認。

```irb
yokoyan:~/workspace/sample_app (password-reset) $ rails test                                                                                     
Running via Spring preloader in process 6011                                                                                                     
Started with run options --seed 39983                                                                                                            
                                                                                                                                                 
  46/46: [==================================================================================================] 100% Time: 00:00:02, Time: 00:00:02
                                                                                                                                                 
Finished in 2.42747s                                                                                                                             
46 tests, 221 assertions, 0 failures, 0 errors, 0 skips     
```

###演習2
*リスト 12.16のテンプレートを埋めて、期限切れのパスワード再設定で発生する分岐 (リスト 12.21) を統合テストで網羅してみましょう (12.21 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)。 期限切れをテストする方法はいくつかありますが、リスト 12.21でオススメした手法を使えば、レスポンスの本文に「expired」という語があるかどうかでチェックできます (なお、大文字と小文字は区別されません)。*

- getリクエストを送信する。（new_password_reset_path）
- postリクエストを送信する。（password_reset_path）
    - params
        - password_reset
            - メールアドレス；@user.email
- パスワード再設定フォームのテストのために、アクションを実行結果、インスタンス変数に代入されたオブジェクトを取得
    - @userにassigns(:user)の結果を代入
- パスワード再設定メール送信時間を３時間前に更新する
- patchリクエストを送信する。（password_reset_path(@user.reset_token)）
    - params
        - password_reset
            - メールアドレス：@user.email
        - user
            - パスワード：foobar
            - 確認用パスワード：foobar
- レスポンスが302であること
- 単一のリダイレクトのレスポンスに従う
- レスポンスで返されたHTMLの中身に、expired（期限切れ）の文字列が存在すること

```/sample_app/test/integration/password_resets_test.rb
  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/i, response.body
  end
```

追加した統合テストがgreenになることを確認。

```irb
yokoyan:~/workspace/sample_app (password-reset) $ rails test
Running via Spring preloader in process 7793
Started with run options --seed 25242

  47/47: [=====================================================================================================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.45340s
47 tests, 224 assertions, 0 failures, 0 errors, 0 skips
```

###演習3
*２時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。しかし、もっと良くする方法はまだあります。例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。仮にログアウトして離席したとしても、２時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを表示させ、パスワードを更新してしまうことができてしまいます (しかもそのままログイン機構まで突破されてしまいます!)。この問題を解決するために、リスト 12.22のコードを追加し、パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょう4。*

パスワードの再設定に成功したら、ダイジェストをnilに変更する。

```/sample_app/app/controllers/password_resets_controller.rb
  def update
    if params[:user][:password].empty?  # (3)への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)  # (4)への対応
      log_in @user
      @user.update_attribute(:reset_digest, nil)
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit' # (2)への対応
    end
  end
```

####演習4
*リスト 12.18に１行追加し、１つ前の演習課題に対するテストを書いてみましょう。ヒント: リスト 9.25のassert_nilメソッドとリスト 11.33のuser.reloadメソッドを組み合わせて、reset_digest属性を直接テストしてみましょう。*

パスワードの再設定に成功後、user.reload後のreset_digest属性がnilになっていることを確認する。

```/sample_app/test/integration/password_resets_test.rb
    # 有効なパスワードとパスワード確認
    patch password_reset_path,
        params: { email: user.email,
            user: {
                password: "foobaz",
                password_confirmation: "foobaz"
            }
        }
    assert_nil user.reload.reset_digest # 追加
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
```

#おわりに
本章のテストコードは読み応えがあり、読み解いてテストコードを書くのに時間がかかりました。
また、作らなくてもいいテストを作ってしまい、テストが動かないという問題にハマってしまいました・・・。（こちらに投稿しました。[Railsチュートリアルの12章で統合テスト実行時に302: Foundが出る場合の対処法](http://qiita.com/yokoyan/items/5ecbb6ed5bfb157783dc)）

こういう失敗を糧に、熟練を目指していきます！