Two subtle bugs

現在還有兩個小問題要解決。

第一個情況是,使用者同時用兩個瀏覽器登入網站,如果他在其中一個瀏覽器視窗登出(也就是當下就把 current_user 設為 nil),再到另一個瀏覽器視窗登出時,會發生錯誤,因為我們在 log_out 中調用了 forget(current_user)。解決方式就是限制只有在登入狀態的使用者才能登出。

第二個情況是,使用者可能同時登入(而且被記住)兩個瀏覽器,例如 Chrome 和 Firefox,當他在其中一個瀏覽器登出後,另一個瀏覽器並沒有登出就關閉,然後又再度開啟第二個瀏覽器,這樣也會出現錯誤。

假設使用 Firefox 登出,remember digest 被設為 nil(透過 user.forget),這對 Firefox 來說沒問題,因為 log_out 方法刪掉了 user ID:

# Returns the user corresponding to the remember token 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

也就是上述程式碼的這兩行,都會是 false:

if (user_id = session[:user_id])

elsif (user_id = cookies.signed[:user_id])

所以在 current_user 方法中,user 變數的值會回傳 nil。

然後如果我們關閉 Chrome,session[:user_id] 會被設為 nil(因為 session 變數會自動在瀏覽器關閉時失效),但是 user_id 的 cookie 仍然存在(因為你只是關掉瀏覽器,並不會刪除 cookie)。也就是說,相對應的使用者仍然會被持續從資料庫中取出:

elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])

上述的 if 條件句,因為 user 不是 nil,還是會被執行:

user && user.authenticated?(cookies[:remember_token])

因為前面透過 Firefox 登出,已經把使用者的 remember digest 刪除了,所以當使用者繼續透過 Chrome 訪問頁面時,在使用這個 authenticated? 方法時:

def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

remember_digest 的值會是 nil。所以在調用 BCrypt::Password.new(remember_digest) 方法時會拋出例外,為了解決這個問題,我們希望 authenticated? 回傳 false


TDD 這時候就派上用場,所以先寫個測試來捕捉這兩個小錯誤,然後再修正問題。

第一個問題解法

我們先讓整合測試出現錯誤測試(Red):

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, 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
    # Simulate a user clicking logout in a second window.
    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

第二個 delete logout_path 會因為缺少 current_user 而出現錯誤,執行測試(Red):

$ bundle exec rake test

我們要讓 log_out 只有在 logged_in? 為 true 的情況下才會執行:

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

第二個問題解法

第二個問題由於涉及不同的瀏覽器,在整合測試中比較難模擬,但直接透過 User model test 測試很簡單,我們只需要建立一個沒有 remember digest 的使用者就可以了(在 setup 方法中的 @user 的確沒有 remember digest),然後再調用 authenticated? 方法(注意,我們直接使用空的 remember token,因為還沒使用到這個值得時候,就發生錯誤):

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

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

因為 BCrypt::Password.new(nil) 會發生錯誤,所以測試會失敗(Red):

$ bundle exec rake test

為了通過測試,我們要做的就是當 remember digest 為 nil 時,回傳 false:

app/models/user.rb

class User < ActiveRecord::Base
  .
  .
  .
  # Returns true if the given token matches the digest.
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # Forgets a user.
  def forget
    update_attribute(:remember_digest, nil)
  end  
end

這邊使用 return 關鍵字,如果當 remember digest 為 nil,就馬上回傳 false,下面的程式碼就不會被執行,相當於下面的寫法:

if remember_digest.nil?
  false
else
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

執行測試,這下子測試應該會通過了(Green):

$ bundle exec rake test