Railsチュートリアルの走り方を変えてみた: Railsチュートリアル備忘録 - 9章

現在独学でRailsチュートリアル１周目ですが
２周目に挑戦するのであれば
テスト駆動開発(TDD)を試したいと思い
各章のテーマのなかで実装されるべき要件をリストアップしておくことにした

2週目でイメージしているタスクフローとして
要件定義(1週目でここを残す)　> テストを書く(2週目のここに繋げる)　> 実装 
（現実とかけ離れていたらどなたか早めにご指摘ください）

## 目標: より長期間、かつ安全にログイン状態を維持できるようにする

### この章の気付き

- ログイン情報を維持する仕組みにはブラウザに保存されたcookieが使用されている
- Railsの変数が持つ、有効範囲（スコープを）体感できた（ローカル変数とインスタンス変数）
- `assigns(:user)`とすることで直前のインスタンス変数@userにアクセスできるようになる
- 敢えて、コードに例外を発生させる`raise`を含ませることで テストに内包されているかどうか確認できる
- `assert_equal`は`<expected>, <actual>`で表記する
- Herokuに一時的にアクセスできない場合にメンテナンスページを表示する方法がある`heroku maintenance:on (off)`

本章の内容と関係ないが
学習の質向上のためアウトプットをテンプレート化し
圧倒的に量を増やした（iuput : output = 2:8くらい）
進行速度は大幅に低下したが学びの質は上がった気がする

時間効率と学習効果のバランスが難しい

少しでも時間効率を上げるためにmac用markdownエディタを導入した

# 必要要件

### ログインする際にブラウザを閉じても維持されるcookieを生成する

- ユーザーIDと記憶トークンをcookieに保存する
- 永続化するcookieとする
- ユーザーIDは暗号化して保存する（署名）
- 記憶トークンとしてランダムな文字列を用いる
- トークンはハッシュ値に変換してからデータベースに保存する（ダイジェスト記憶）
- チェックボックスを用いてログインの維持を選択できるようにする

### ブラウザを閉じた（`current_user = nil`）場合にcookieと一致するDB上のユーザでログインを維持する

- `current_user = nil`でない場合以下の処理は不要
- cookieに保存されたユーザIDでDBを検索、一致するユーザー（user）を抽出
- cookieの記憶トークンをハッシュ化したものと、userのダイジェスト記憶(DB)が一致することを確認
- userでログインする（`current_user = nil`でなくなる）

### 正常に完全なログアウトが可能

- ログアウトするとsessionが`nil`かつ、記憶トークン(cookie)およびダイジェスト記憶(DB)が`nil`とする
- ログアウトすると`current_user`が`nil`
- ログアウト後はroot_urlにリダイレクト（実装済み）

# 備忘録: 第9章発展的なログイン機構

## 9.1 Remember me 機能

### 9.1.1 記憶トークンと暗号化

cookieが曝されるリスクと対策すべき内容

>管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す 
>\> 対策: SSL化（対応済み）

>データベースから記憶トークンを取り出す 
>\> 対策:DBに保存されるトークンをハッシュ化する

>ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る
>\> 対策: ログアウトした際にトークンを変更する
>(Railsチュートリアル 第６版)


`remember_digest`属性をUserモデルに追加

```
$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
```

Userモデルで`remember_token`属性を扱えるようにしたいが
この属性はDBには保存しない
\> `attr_accessor`を用いる

[attr_accessorが必要な理由と、不要な理由（Railsさんありがとう）: Railsチュートリアル備忘録 - 9章](https://qiita.com/daddy_hacker/items/32bc4f440de965b796d0)

Userモデルに`User.new_token`を定義（記憶トークンを返す）
記憶トークンの生成には`SecureRandom.urlsafe_base64`が適する

```
$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"
```

```ruby
  def User.new_token
    SecureRandom.urlsafe_base64
  end
```

記憶トークンをハッシュ化してDBに保存する
`remember`をUserモデルに定義
ハッシュ化には`User.digest(string)`が使える

```ruby
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
```

`self.`は必要

あとはログイン時にrememberが実行されるようにしたい
それはこの後

###9.1.2 ログイン状態の保持

ユーザーIDをブラウザに保存したい
署名つきcookieを用いる`signed`

```ruby
cookies.signed[:user_id] = user.id
```

`cookies.signed[:user_id]`で暗号解除される

cookieの永続化は`permanent`で可能
メソッドチェーンで

```ruby
cookies.permanent.signed[:user_id] = user.id
```

Userモデルに`authenticated?`を定義
passwordのときと挙動は同じでイメージできるけどBCrypt...の部分理解不十分

```ruby
# 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
```

引数`remember_token`はローカル変数

ログインした際の挙動を変更する
`remember (user)`を追加
これはUserモデルのrememberメソッドと異なる（引数を取っている）

```ruby:app/controllers/sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      remember user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
```

`remember(user)`はヘルパーとして定義
ログインする際に呼び出して、
記憶トークンの生成とcookieへ保存、ダイジェストトークンのDB保存を行っている
（ヘルパーで使い分ける意義、同名のmethodの優先順位が理解不十分）

```ruby:app/helpers/sessions_helper.rb
  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember#Userモデルのメソッド（記憶トークンの生成、ダイジェストトークンのDB保存）
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end
```

`current_user`がsessionだけでなく
cookieによっても維持されるようにする

```ruby:app/helpers/sessions_helper.rb
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
```

`(user_id = session[:user_id])`で繰り返しの省略が可能
`=`は論理演算ではない、代入

この時点で`rails test`は(RED)

```
FAIL["test_login_with_valid_information_followed_by_logout", #<Minitest::Reporters::Suite:0x0000556848d6b040 @name="UsersLoginTest">, 1.7997455329999923]
 test_login_with_valid_information_followed_by_logout#UsersLoginTest (1.80s)
        Expected at least 1 element matching "a[href="/login"]", found 0..
        Expected 0 to be >= 1.
        test/integration/users_login_test.rb:36:in `block in <class:UsersLoginTest>'
```

loginへのリンクが表示されていない
つまりログアウトできていないものと思われる
(cookieによってcurrent_userが維持されていると予想)

###9.1.3 ユーザーを忘れる
ログアウトする際にDBの`remember_digest`と
ブラウザのcookieを削除する（nilで更新）

まずはDBを操作する`forget`を定義して

```ruby:app/models/user.rb
 # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
```

つぎにヘルパーメソッド`forget(user)`を定義する
さっきの`user.forget`でDB上の`:remember_digest`をnil
ブラウザのcookieを`cookies.delete`でnil

```ruby:/sample_app/app/helpers/sessions_helper.rb
　def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end
```

このヘルパーメソッド`forget(user)`を同じヘルパーメソッドの`log_out`で呼び出す

```ruby:/sample_app/app/helpers/sessions_helper.rb
　def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end
```

これで`log_out`はsessionとcookieのいずれも空にできるので
完全なログアウトが可能

`rails test`は(GREEN)

###9.1.4 ２つの目立たないバグ
複数のタブやブラウザを使用した際に生じるバグを解決する

まず、ログインした状態の複数のタブを同時に開いておけば、
Logout(`delete logout_path`)を複数回踏むことができることから生じる問題

1回目の`delete logout_path`で`current_user = nil`となるので
再度`delete logout_path`をリクエストすると、コントローラーはもう一度`log_out`メソッドを呼び出し
`forget(current_user)`を実行しようとしたところで`current_user = nil`なのでエラーとなる

```
NoMethodError: undefined method `forget' for nil:NilClass
app/helpers/sessions_helper.rb:36:in `forget'
app/helpers/sessions_helper.rb:24:in `log_out'
app/controllers/sessions_controller.rb:20:in `destroy'
```

これをテストで検出できるようにするには
再度`delete logout_path`をリクエストすれば良いと考えるとすごくシンプル

```ruby:test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    # ↓ここで再度delete logout_pathをリクエスト
    delete logout_path
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
end
```

テストを走らせると（RED）
うまくバグを再現できることを確認

ログインしていないときは
`delete logout_path`リクエストに対して
`log_out`メソッドが呼び出されないようにすれば良い

ログインの確認は`logged_in`メソッドが使える

```ruby 
  def logged_in?
    !current_user.nil?
  end
```

```ruby:app/controllers/sessions_controller.rb
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
```

となる
`log_out if logged_in?`はおしゃれな書き方で
`if...end`でおきかえることができる

[【Ruby】乱用厳禁！？後置ifで書くとかえって読みづらくなるケース](https://qiita.com/jnchito/items/4e47559a4c821474233a)
この方は可読性の観点からこのような指摘をされていて参考にしたい

この状態で`rails test`は(GREEN)


つぎに、
A、B、２つのブラウザを開いており、片方のブラウザBでログアウトし(DBの`remember_digest`は`nil`)、
続いてブラウザAを閉じると（ブラウザAの`session[:user_id]はnil`）
ブラウザAに保存されたcookieのみが残ることによって生じる問題

この状態でブラウザAを再度開いた場合,cookieが残っているので

```ruby:
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id]) #ここでtrueになる
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])#エラー
      log_in user
      @current_user = user
    end
  end
end
```

`if user && user.authenticated?(cookies[:remember_token])`が実行されるが
別のブラウザの挙動によって`remember_digest`が`nil`になってっているので

```ruby:
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)#remember_digest = nil
  end
```

cookieの内容と一致しないことによる例外を返してしまう

```
BCrypt::Errors::InvalidHash: invalid hash
```

これをテストで検出できるようにするために
ブラウザAの状況をUserモデルのオブジェクトで再現すれば良い
つまり`remember_digest = nil`のモデルを用意する

```ruby:test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end
```

setupメソッドで作った`@user`はちょうどそれに該当するのでこれを使用できる
現状`remember_digest = nil`であるので
`@user.authenticated?('')`は例外を返しテストは（RED）

`remember_digest = nil`の場合は
`@user.authenticated?('')`が`false`を返すようにしたいので

```ruby
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
```

とすればいい
ProgateだとIf文の最後に使うことが多かったので気づかないが
`return`はそこでメソッドを終了し値を返す
だからそれ以下は実行されない

これでテストは(GREEN)



## 9.2 ［Remember me］チェックボックス

チェックボックスはヘールパメソッドで挿入可能

```html:/sample_app/app/views/sessions/new.html.erb
      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>
```

CSSを追加

```scss:app/assets/stylesheets/custom.scss
.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}
```

`params[:session][:remember_me]`が
オンなら`'1'`オフなら`'0'`の値を受け取る
`1`か`0`でなく`'1'`か`'0'`

Sessionsコントローラの`create`アクションに以下を加える

```ruby
if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end
```

上記は以下のように書き換えられる（３項演算子）

```ruby
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
```

ここまでで永続的なログイン機構の完成



## 9.3 ［Remember me］のテスト

### 9.3.1 ［Remember me］ボックスをテストする

テスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義する

単体テスト、統合テストそれぞれで使用できるように
class ActiveSupport::TestCase`, `class ActionDispatch::IntegrationTest`のそれぞれで`log_in_asメソッドを定義

統合テストでは`session`を直接扱えないとの理由から
統合テスト用の`log_in_as`メソッドでは`post login_path`を用いる

```ruby:test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end

  # テストユーザーとしてログインする
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionDispatch::IntegrationTest

  # テストユーザーとしてログインする
  def log_in_as(user, password: 'password', remember_me: '1')
    post login_path, params: { session: { email: user.email,
                                          password: password,
                                          remember_me: remember_me } }
  end
end
```

`rails test`(GREEN)

### 9.3.1 演習 :

２つの問題を含んでいるようにおもう
*統合テストではコントローラーで定義したインスタンス変数にアクセスできない*
\>`assigns(:user)`とすることで直前のインスタンス変数@userにアクセスできるようになる

*そもそもSessionsコントローラのcreateメソッドでローカル変数`user`が用いられており*
*インスタンス変数(`@user`)が定義されていない*
\> Sessionsコントローラのcreateメソッドで*インスタンス変数(`@user`)を定義する

前提として統合テストにおけの`setup`メソッドで定義された`@user`は
remember_token属性を含まない

[attr_accessorが必要な理由と、不要な理由（Railsさんありがとう）: Railsチュートリアル備忘録 - 9章](https://qiita.com/daddy_hacker/items/32bc4f440de965b796d0)

```ruby:/sample_app/app/controllers/sessions_controller.rb
  def create
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
      log_in @user
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
      redirect_to @user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
```

```ruby:/sample_app/test/integration/users_login_test.rb
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies[:remember_token], assigns(:user).remember_token
  end
```





### 9.3.2 ［Remember me］をテストする

current_userの挙動についてのテスト

`assert_equal <expected>, <actual>`で表記する

```ruby:/sample_app/test/helpers/sessions_helper_test.rb
require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
    remember(@user)
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end
```



## 9.4 最後に

以下のようにしておくと一時的にアクセスできない間
メンテナンスページを表示することができる

```
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
```