紙一重の積み重ね

35歳のエンジニアがなれる最高の自分を目指して、Ruby on Railsチュートリアルの解答やAmazon Web Servicesについて学んだことをこつこつ情報発信するブログです。

【11章】Ruby on Railsチュートリアル演習まとめ&解答例【11.3 アカウントを有効化する】

はじめに

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)を登録する。

image

送信されたメールの内容を確認。

----==_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をクリックする前にログインすると、警告メッセージが表示されることを確認。

image

メール内の有効化URLにアクセスすると、ユーザーが有効化されることを確認。

image

演習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 を表示した後に、

image.png

https://xxxxxx/users/3 にブラウザのURLを変数すると、アクセスができる。

image.png

コントローラを改良する。 有効なユーザーのみ一覧表示する。

  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

おわりに

アカウントの有効化処理が完成しました。 最後の演習では、ゼロから統合テストを作るのは難しかったですが、 日本語であるべき姿を組み立てると書きやすかったです。

丸写しするのではなく、あるべき姿を常に考えながら、これからもプログラムを書いていきます。