はじめに
最近、プロジェクト管理業務が業務の大半を占めており、
プログラムを書く機会がなかなかありません。
このままだとプログラムがまったく書けない人になってしまう危機感(迫り来る35歳定年説)と、
新しいことに挑戦したいという思いから、
Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版を学習中です。
業務で使うのはもっぱらJavaなのですが、Rails楽しいですね。
これまでEvernoteに記録していましたが、ソースコードの貼付けに限界を感じたため、
Qiitaで自分が学習した結果をアウトプットしていきます。
個人の解答例なので、誤りがあればご指摘ください。
動作環境
- 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
おわりに
アカウントの有効化処理が完成しました。
最後の演習では、ゼロから統合テストを作るのは難しかったですが、
日本語であるべき姿を組み立てると書きやすかったです。
丸写しするのではなく、あるべき姿を常に考えながら、これからもプログラムを書いていきます。