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