はじめに
Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版の 11章 11.3 アカウントを有効化するの演習まとめ&解答例です。
個人の解答例なので、誤りがあればご指摘ください。
動作環境
- cloud9
- ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-linux]
- Rails 5.0.0.1
11.3.1 authenticated?メソッドの抽象化
本章での学び
sendメソッドによるメタプログラミング
sendメソッドを使うことで、プログラム内で呼び出すメソッドを動的に決めることができる。 「黒魔術」と言われているらしいw
rubyリファレンスは以下の通り。 instance method Object#send
オブジェクトのメソッド name を args を引数に して呼び出し、メソッドの実行結果を返します。
以下の呼び出し方は、どれもa.length
を呼び出している。
メソッド名を、シンボル:length
や文字列"length"
で指定することで、呼び出すメソッドを動的に指定している。
yokoyan:~/workspace/sample_app (account-activation) $ rails console Running via Spring preloader in process 1744 Loading development environment (Rails 5.0.0.1) >> a = [1, 2, 3] => [1, 2, 3] >> a.length => 3 >> a.send(:length) => 3 >> a.send("length") => 3
シンボルや文字列ではなく、メソッド名を直接指定すると、エラーになる。
>> a.send(length) NameError: undefined local variable or method `length' for main:Object
同様に、ユーザー情報の有効化トークンも、sendメソッドで取得できる。
シンボル:activation_digest
や、文字列"activation_digest"
を指定することで、user.activation_digest
を呼び出している。
yokoyan:~/workspace/sample_app (account-activation) $ rails console Running via Spring preloader in process 1763 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-06-20 04:07:12", password_digest: "$2a$10$No5Z7APSsWojYME0Cm1POerj89GopLI23E2dN/9gyvt...", remember_digest: nil, admin: true, activation_digest: "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/...", activated: true, activated_at: "2017-06-20 04:07:12"> >> user.activation_digest => "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/QOr0USsdKy" >> user.send(:activation_digest) => "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/QOr0USsdKy" >> user.send("activation_digest") => "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/QOr0USsdKy"
シンボルを指定して、式展開でも取得できる。 Railsではシンボルを使うことが一般的。
?> attribute = :activation => :activation >> user.send("#{attribute}_digest") => "$2a$10$ufdL0hGRXuEm9biMP3H0deF99cC.BpPrrobGbyL4FQ/QOr0USsdKy"
【model】authenticated?メソッドの抽象化
Userモデルのauthenticated?メソッド内のdigest
を、sendメソッドを使って動的に変化するようにリファクタリングする。
- sendメソッドに渡すシンボルは、
atribute
として引数に追加する。 - もう1つの引数である
token
は他の認証でも使えるように、名称を一般化している。 - sendメソッドは、
self.send
でも呼べるが、Userモデル内であるため省略している。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end
動作確認
現時点でテストコードはredになる。
authenticated?メソッドの引数が1つから2つに増えているため、
テストコードでArgumentError
が発生している。
yokoyan:~/workspace/sample_app (account-activation) $ rails test Running via Spring preloader in process 2906 Started with run options --seed 40151 ERROR["test_current_user_returns_nil_when_remember_digest_is_wrong", SessionsHelperTest, 0.13959716499084607] test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (0.14s) ArgumentError: ArgumentError: wrong number of arguments (given 1, expected 2) app/models/user.rb:42:in `authenticated?' app/helpers/sessions_helper.rb:29:in `current_user' test/helpers/sessions_helper_test.rb:17:in `block in <class:SessionsHelperTest>' ERROR["test_current_user_returns_right_user_when_session_is_nil", SessionsHelperTest, 0.14938249200349674] test_current_user_returns_right_user_when_session_is_nil#SessionsHelperTest (0.15s) ArgumentError: ArgumentError: wrong number of arguments (given 1, expected 2) app/models/user.rb:42:in `authenticated?' app/helpers/sessions_helper.rb:29:in `current_user' test/helpers/sessions_helper_test.rb:11:in `block in <class:SessionsHelperTest>' ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.17042131099151447] test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.17s) ArgumentError: ArgumentError: wrong number of arguments (given 1, expected 2) app/models/user.rb:42:in `authenticated?' test/models/user_test.rb:75:in `block in <class:UserTest>' 43/43: [======================================================================================================================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.42911s 43 tests, 181 assertions, 0 failures, 3 errors, 0 skips
【helper】authenticated?を使用するコードの修正
ヘルパー内で、authenticated?メソッドの呼び出し方法を修正する。
# 現在のログイン中のユーザーを返す(いる場合) def current_user ・・・省略・・・ # if user && user.authenticated?(cookies[:remember_token]) if user && user.authenticated?(:remember, cookies[:remember_token])
【test】authenticated?を使用するコードの修正
テストコード内で、authenticated?メソッドの呼び出し方法を修正する。
test "authenticated? should return false for a user with nil digest" do # assert_not @user.authenticated?('') assert_not @user.authenticated?(:remember,'') end
動作確認
テストがgreenに変わったことを確認。
yokoyan:~/workspace/sample_app (account-activation) $ rails test Running via Spring preloader in process 3415 Started with run options --seed 1233 43/43: [======================================================================================================================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.13016s 43 tests, 185 assertions, 0 failures, 0 errors, 0 skips
演習1
コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?
新規ユーザーを作成する。 コンソール内でユーザーを作成すると、記憶トークンはnilになる。
yokoyan:~/workspace/sample_app (account-activation) $ rails console --sandbox Running via Spring preloader in process 3624 Loading development environment in sandbox (Rails 5.0.0.1) Any modifications you make will be rolled back on exit >> user = User.create(name: "test3", email: "test3@example.com", password: "password", password_confirmation: "password") (0.1ms) SAVEPOINT active_record_1 User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ? [["email", "test3@example.com"], ["LIMIT", 1]] SQL (0.4ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest", "activation_digest") VALUES (?, ?, ?, ?, ?, ?) [["name", "test3"], ["email", "test3@example.com"], ["created_at", 2017-06-25 01:14:20 UTC], ["updated_at", 2017-06-25 01:14:20 UTC], ["password_digest", "$2a$10$9q.TlsyWqdVeeqJce12.1eeunUufSNvkh/LoZo4/6ruphM/Suwxse"], ["activation_digest", "$2a$10$LB2zw3e8q/KYc5eeQxpsMO15pW8yOQXZp.stDZ6aE9ziAttt9Klui"]] (0.1ms) RELEASE SAVEPOINT active_record_1 => #<User id: 102, name: "test3", email: "test3@example.com", created_at: "2017-06-25 01:14:20", updated_at: "2017-06-25 01:14:20", password_digest: "$2a$10$9q.TlsyWqdVeeqJce12.1eeunUufSNvkh/LoZo4/6ru...", remember_digest: nil, admin: false, activation_digest: "$2a$10$LB2zw3e8q/KYc5eeQxpsMO15pW8yOQXZp.stDZ6aE9z...", activated: nil, activated_at: nil> >>
そのため、ユーザー作成後に記憶トークンと記憶ダイジェストを更新する。
?> user.remember_token = User.new_token => "g65Xv0B5Sh6HEnxRxGn3Cg" >> user.update_attribute(:remember_digest, User.digest(user.remember_token)) (0.1ms) SAVEPOINT active_record_1 SQL (0.2ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", 2017-06-25 01:20:32 UTC], ["remember_digest", "$2a$10$AVQ.XYFSP6eapVywUwO/7.gaE/OHfHqiBJ8q/Rx80MiiWB3A.T2.S"], ["id", 102]] (0.1ms) RELEASE SAVEPOINT active_record_1 => true
記憶トークン、記憶ダイジェスト、有効化トークン、有効化ダイジェストの値を確認。
?> user.remember_token => "g65Xv0B5Sh6HEnxRxGn3Cg" >> user.remember_digest => "$2a$10$AVQ.XYFSP6eapVywUwO/7.gaE/OHfHqiBJ8q/Rx80MiiWB3A.T2.S" >> user.activation_token => "Z8FAIp08GJ6NfxM2CwIznA" >> user.activation_digest => "$2a$10$LB2zw3e8q/KYc5eeQxpsMO15pW8yOQXZp.stDZ6aE9ziAttt9Klui"
演習2
リスト 11.26で抽象化したauthenticated?メソッドを使って、先ほどの各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。
先ほど生成したユーザーの記憶トークンを使って、認証結果がtrueになることを確認。
?> user.authenticated?(:remember, user.remember_token) => true
11.3.2 editアクションで有効化
本章での学び
AccountActivationsコントローラ内で、 前章で作成したauthenticated?メソッドを呼び出すeditアクションを作成する。 これにより、有効化リンクをクリックした後の処理が完成する。
【controller】editアクションの実装
AccountActivationsController内のeditアクションを実装する。
- ユーザーをemailで検索する
- ユーザーが存在する、かつ、有効化されていないユーザー、かつ、有効化トークンによる認証ができる
- すべての条件を満たす場合
- 有効化ステータスをtrueに更新する
- 有効化時刻を現在時刻で更新する
- ユーザーをログイン状態にする
- フラッシュメッセージにsuccessをセットする
- ユーザー情報ページへリダイレクトする
- すべての条件を満たさない場合
- フラッシュメッセージにdangerをセットする
- ルートURLにリダイレクトする
- すべての条件を満たす場合
def edit user = User.find_by(email: params[:email]) if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now) log_in(user) flash[:success] = "Account Activated!" redirect_to user else flash[:danger] = "Invalid activation link" redirect_to root_url end end
【controller】sessionsコントローラの改良
ユーザー認証を行うcreateアクションを修正する。 有効化に成功しているユーザーのみログインを行い、有効ではないユーザーがログインできないように修正を加える。
- 有効化されたユーザーか判定する
- 有効化されている
- ログインする
- セッションパラメータのremember_meの値が1なら、ユーザ情報を記憶する(1以外なら忘れる)
- ログイン前にユーザーがいた場所にリダイレクトする
- 有効化されていない
- アカウントが認証されていない旨のメッセージを代入
- emailの有効化リンクをチェックする旨のメッセージを代入
- フラッシュメッセージにwarningをセットする
- ルートURLにリダイレクトする
- 有効化されている
if @user && @user.authenticate(params[:session][:password]) if @user.activated? #ユーザログイン後にユーザ情報のページヘリダイレクトする log_in(@user) #チェックされていたらユーザを記憶する params[:session][:remember_me] == '1' ? remember(@user) : forget(@user) # redirect_to @user redirect_back_or @user else message = "Account note activated." message += "Check your email for the activation link." flash[:warning] = message redirect_to root_url end else
動作確認
ブラウザを起動して、新規ユーザー(メールアドレスがtest3@example.com)を登録する。
送信されたメールの内容を確認。
----==_mimepart_595027e48692f_6bd2244f0865b4 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit Hi test3 Welcome to the Sample App! Click on the link below to active your account: https://rails-tutorial-yokoyan.c9users.io/account_activations/aIUAKDQ_Dy-k1ihCxVCs1A/edit?email=test3%40example.com ----==_mimepart_595027e48692f_6bd2244f0865b4 Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <style> /* Email styles need to be inline */ </style> </head> <body> <h1>Sample App</h1> <p>Hi test3</p>, <p> Welcome to the Sample App! Click on the link below to activate your account: </p> <a href="https://rails-tutorial-yokoyan.c9users.io/account_activations/aIUAKDQ_Dy-k1ihCxVCs1A/edit?email=test3%40example.com">Activate</a> </body> </html>
有効化URLをクリックする前にログインすると、警告メッセージが表示されることを確認。
メール内の有効化URLにアクセスすると、ユーザーが有効化されることを確認。
演習1
コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?
有効化トークンが含まれているのは、account_activations/
の後ろの部分。
https://rails-tutorial-yokoyan.c9users.io/account_activations/aIUAKDQ_Dy-k1ihCxVCs1A/edit?email=test3%40example.com
演習2
先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。
上記の動作確認を参照。 また、コンソールから有効化ステータスがtrueになっていることを確認。
>> user = User.find_by(email: "test3@example.com") User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "test3@example.com"], ["LIMIT", 1]] => #<User id: 102, name: "test3", email: "test3@example.com", created_at: "2017-06-25 21:15:15", updated_at: "2017-06-25 21:21:49", password_digest: "$2a$10$NubRAD6eWQO6NtC2CPKfb.BvH2lchppXa80YEAUalzI...", remember_digest: nil, admin: false, activation_digest: "$2a$10$3S210A5wksNnbfTcFtPEduxCfEhvPiOpB95OHwZ3bM9...", activated: true, activated_at: "2017-06-25 21:21:49"> >> ?> user.activated? => true
11.3.3 有効化のテストとリファクタリング
本章での学び
アカウント有効化の統合テストを追加する。
【test】統合テストの修正
- setupで配列deliveriesを初期化する
def setup ActionMailer::Base.deliveries.clear end
- GETでsignup_pathにアクセスする
- User.countが1増えていること
- post users_path , パラメータ
- 配信されたメッセージが1と等しいか確認
- createアクション内のインスタンス変数@userにアクセスしてユーザー情報を取得する
- ユーザーが有効化されていないことを確認
- (有効化されていない状態で)ログインする
- ログインできないことを確認
- GETでedit_account_activation_pathにアクセスする
- 引数1:無効なトークン
- 引数2:ユーザーのemail
- ログインできないことを確認
- GETでedit_account_activation_pathにアクセスする
- 引数1:ユーザーの有効化トークン
- 引数2:不正なemail
- ログインできないことを確認
- GETでedit_account_activation_pathにアクセスする
- 引数1:ユーザーの有効化トークン
- 引数2:ユーザーのemail
- ユーザーをDBから再読み込みして、有効化されていることを確認
- リダイレクトする
- ユーザープロフィール画面のテンプレートが表示されること
- ログインできることを確認
test "valid signup information with account activation" do get signup_path assert_difference 'User.count', 1 do post users_path, params: { user: { name: "Example User", email: "user@example.com", password: "password", password_confirmation: "password" } } end assert_equal 1, ActionMailer::Base.deliveries.size user = assigns(:user) assert_not user.activated? log_in_as(user) get edit_account_activation_path("invalid token", email: user.email) assert_not is_logged_in? get edit_account_activation_path(user.activation_token, email: 'wrong') assert_not is_logged_in? get edit_account_activation_path(user.activation_token, email: user.email) assert user.reload.activated? follow_redirect! assert_template 'users/show' assert_not flash.empty? assert is_logged_in? end
動作確認
テストがgreenになることを確認。
yokoyan:~/workspace/sample_app (account-activation) $ rails test Running via Spring preloader in process 2050 Started with run options --seed 1929 43/43: [===============================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.90977s 43 tests, 193 assertions, 0 failures, 0 errors, 0 skips
【controller】コントローラのリファクタリング1
ユーザー操作の一部を、コントローラからモデルに移す。 コントローラ内で、以下の有効化ステータスを更新する箇所を、モデルに移す。
def edit ・・・略・・・ user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now)
Userモデルにアカウントを有効にするactivate
メソッドを作成する。
Userモデル内であるため、user.
や、self.
は省略できる。
# アカウントを有効にする def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end
作成したメソッドを呼び出す。
if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.activate
【controller】コントローラのリファクタリング2
ユーザー操作の一部を、コントローラからモデルに移す。 コントローラ内で、以下の有効化メールを送信する箇所を、モデルに移す。
Userモデルにアカウントを有効にするactivate
メソッドと、
def create ・・・略・・・ UserMailer.account_activation(@user).deliver_now
Userモデルに有効化メールを送信するsend_activation_email
メソッドを作成する。
Userモデル自身が引数となるため、@user
は、self
となる。
# 有効化用のメールを送信する def send_activation_email UserMailer.account_activation(self).deliver_now end
作成したメソッドを呼び出す。
def create @user = User.new(user_params) if @user.save @user.send_activation_email
リファクタリングの結果確認
テストがgreenになることを確認。
yokoyan:~/workspace/sample_app (account-activation) $ rails test Running via Spring preloader in process 2304 Started with run options --seed 49015 43/43: [===============================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.34037s 43 tests, 193 assertions, 0 failures, 0 errors, 0 skips
演習1
リスト 11.35にあるactivateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 11.39に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。
activateメソッドを改良する。
# アカウントを有効にする def activate # update_attribute(:activated, true) # update_attribute(:activated_at, Time.zone.now) update_columns(activated: true, activated_at: Time.zone.now) end
テスト結果がgreenになることを確認。
yokoyan:~/workspace/sample_app (account-activation) $ rails test Running via Spring preloader in process 2670 Started with run options --seed 12072 43/43: [===============================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.13286s 43 tests, 193 assertions, 0 failures, 0 errors, 0 skips
演習2
現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト 11.40のテンプレートを使って、この動作を変更してみましょう8。なお、ここで使っているActive Recordのwhereメソッドについては、13.3.3でもう少し詳しく説明します。
https://xxxxxx/users/2 を表示した後に、
https://xxxxxx/users/3 にブラウザのURLを変数すると、アクセスができる。
コントローラを改良する。 有効なユーザーのみ一覧表示する。
def index # @users = User.all # @users = User.paginate(page: params[:page]) @users = User.where(activated: true).paginate(page: params[:page]) end def show @user = User.find(params[:id]) redirect_to root_url and return unless @user.activated? #debugger end
演習3
ここまでの演習課題で変更したコードをテストするために、/users と /users/:id の両方に対する統合テストを作成してみましょう。
- 有効化されていないテストユーザーを作成する
non_activated: name: Non Activated email: non_activated@example.gov password_digest: <%= User.digest('password') %> activated: false activated_at: <%= Time.zone.now %>
- setup時にテストユーザーを読み込む
@non_activated_user
def setup @user = users(:michael) @other_user = users(:archer) @non_activated_user = users(:non_activated) end
- テストケース名を生成
- 有効化されていない属性を許可しない
@non_activated_user
でログインする@non_activated_user
の有効化属性がfalseであること- getで/usersにアクセスする
- 有効化されていないユーザーが表示されていないこと
@non_activated_user
のプロフィールページへのリンクがユーザー一覧に表示されていないこと
- getで/users/:id(有効化されていないユーザー)にアクセスする
- ルートURLにリダイレクトされること
test "should not allow the not activated attribute" do log_in_as(@non_activated_user) assert_not @non_activated_user.activated? get users_path assert_select "a[href=?]", user_path(@non_activated_user), count: 0 get user_path(@non_activated_user) assert_redirected_to root_url end
おわりに
アカウントの有効化処理が完成しました。 最後の演習では、ゼロから統合テストを作るのは難しかったですが、 日本語であるべき姿を組み立てると書きやすかったです。
丸写しするのではなく、あるべき姿を常に考えながら、これからもプログラムを書いていきます。