はじめに
Railsチュートリアル(Rails 7.0.4.3 / Ruby 3.2.2 / WSL Ubuntu)の11章「アカウントの有効化」を進めた記録です。
send、super、トークンとダイジェストの関係など、「なんとなく動いているけど中身が分からない」ものを調べた内容と、テストでつまずいた箇所をまとめます。
11.4(本番環境でのメール送信)は、デプロイ先とメール送信サービスのアカウントが必要になるため、今回は読むだけにして実施していません。
11章の内容
ユーザー登録後、メールに届いたリンクをクリックして初めてアカウントが有効になる流れを作りました。
-
11.1 AccountActivationsリソース。
有効化用のトークンとダイジェストを、before_createコールバックで作る。 -
11.2 アカウント有効化メール。
UserMailerでメールを作り、send_activation_emailで送る。 -
11.3 アカウントを有効化する。
editアクションでトークンを照合し、activateで有効にする。authenticated?はrememberとactivationの両方で使えるように一般化した。 - 11.4 本番環境でのメール送信。 今回は読むだけで、未実施。
つまずいたところ
1. sendは何をしているのか
authenticated?を一般化する箇所で、send("#{attribute}_digest")が出てきました。
「勝手に判断して良しなにしてくれるってこと?本当に意図通りに動作するの?」というのが最初の疑問でした。
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
sendは判断も推測もしない。
渡されたメソッド名の文字列(またはシンボル)を、そのまま呼び出すだけです。Pythonのgetattr(obj, name)()に近いみたいです。
| 呼び出し | attribute |
実際に呼ばれるもの |
|---|---|---|
authenticated?(:remember, token) |
:remember |
remember_digest |
authenticated?(:activation, token) |
:activation |
activation_digest |
どのメソッドが呼ばれるかは、文字列の組み立てで決まる。
attributeはRailsの特別な機能ではなく、ただの引数名です。「:attributeってなんだ?」と思ったけれど、名前をkindなどに変えても同じように動きます。
名前が間違っていると実行時にしか分からない。
typoがあっても、書いた時点では気付けません。外部から来た文字列をそのままsendに渡すのも危ないらしいです。今回は自分のコードから:rememberと:activationを渡すだけなので問題ありません。
2. sendはprivateも呼べる
調べていて「privateも呼べるのか!反則的やなぁ」と思いました。
class Foo
private
def secret = "hi"
end
Foo.new.secret # => NoMethodError (private method called)
Foo.new.send(:secret) # => "hi"
公式リファレンスには、呼び出し制限に関係なく任意のメソッドを呼べるとあります。(こわい)
public限定にしたいときはpublic_sendを使うのが安全です。
3. authenticated?の一般化でArgumentError
authenticated?を2引数(attribute, token)に変えたあと、rails tで3件のエラーが出ました。
ArgumentError: wrong number of arguments (given 1, expected 2)
app/models/user.rb:41:in `authenticated?'
app/helpers/sessions_helper.rb:25:in `current_user'
test/helpers/sessions_helper_test.rb:11:in `block in <class:SessionsHelperTest>'
原因は呼び出し側の修正漏れ。
定義を2引数にしたのに、sessions_helper.rbのcurrent_userとuser_test.rbの呼び出しが、旧来の1引数のままでした。
スタックトレースのapp/helpers/sessions_helper.rb:25とtest/models/user_test.rb:70が、そのまま修正箇所を指しています。
直し方は、第1引数に:rememberを足すだけです。環境の問題ではなく、変更の影響範囲の確認漏れでした。
4. トークンとダイジェストの関係(復習)
コンソールの演習(新規ユーザーを作って、各トークンとダイジェストを確認する)を通して整理しました。
トークンは仮想属性で、DBには保存されない。
attr_accessor :remember_token, :activation_tokenで定義した、メモリ上だけの値です。同じuser変数を持っている間しか残らず、User.findで読み直すとnilになります。
ダイジェストはDBに保存される。
トークンをハッシュ化した値です。
ダイジェストから元のトークンは、作成者(サーバー側)を含めて誰も復元できない。
疑問は「他者がってこと?作成者含めてだれも復元できない?」でした。bcrypt-rubyのREADMEに、こうあります。
"Because this process is not reversible, there's no way to go from the hash back to the password."
照合は「同じハッシュを2つ作って比べる」のではなく、再ハッシュで行う。
bcryptにはソルトがあるため、同じトークンでも毎回違うダイジェストになるはずです。READMEでは、ソルトが自動で生成・保存されることまでは確認できました。
BCrypt::Password.new(digest).is_password?(token)が、保存済みダイジェストのソルトを使って、渡されたトークンを照合しています。READMEには==による比較しか載っておらず、is_password?はREADMEでは確認できていません。ただ、今回のuser.rbには実際にそう書いてあります。
5. superと統合テストの分割
11章のテスト(リスト11.33)で、UsersLoginクラスを継承してテストを分割する書き方が出てきました。
class UsersLogin < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
end
class ValidLogin < UsersLogin
def setup
super
post login_path, params: { session: { email: @user.email,
password: 'password' } }
end
end
superは、親クラスの同じ名前のメソッドを呼ぶ。
ValidLoginでsetupを書くと、親のUsersLogin#setupは上書きされます。superがないと@userが作られず、post login_pathの@user.emailでNoMethodErrorになるはずです。
実行順はsuperの位置で決まる。
superより前に書いた処理が先、superで親の処理に入り、戻ってきてからsuperより後ろの処理が走ります。
class A; def hi; puts "A"; end; end
class B < A; def hi; puts "B前"; super; puts "B後"; end; end
B.new.hi
# B前
# A
# B後
括弧なしのsuperは、現在の引数をそのまま親に渡す。
super()と書くと引数なしになります。公式リファレンスには、「括弧と引数が省略された場合には現在のメソッドの引数がそのまま引き渡されます」とあります。setupは引数がないので、今回は影響しません。
Pythonのsuper().setUp()とほぼ同じ発想ですが、Rubyではsuperだけでメソッド名も引数も省略できる点が違うみたいです。
6. Minitestとは(復習)
rails tで動いているのはMinitestです。Rubyのテストフレームワークで、Railsがデフォルトで採用しています。
| テストコード | Minitest側の仕組み |
|---|---|
class UsersLogin < ActionDispatch::IntegrationTest |
Minitest::Testを土台にしたテストクラス |
def setup |
各テストの直前に自動実行される |
test "valid login" do ... end |
test_valid_loginというメソッドが定義される |
assert、assert_equal
|
アサーション |
assert_response、assert_redirected_to
|
Railsが追加したアサーション |
Railsガイドによれば、ActiveSupport::TestCaseがMinitest::Testを継承しているので、setupとsuperは、この継承の上で動いていることになります。
7. 統合テストが2件失敗:see_otherとtitleの完全一致
user_show_test.rbを追加したところ、2件失敗しました。
Expected response to be a <303: see_other>, but was a <302: Found> redirect to <http://www.example.com/>
--- expected
+++ actual
@@ -1 +1 @@
-"Sterling Archer"
+"Sterling Archer | Ruby on Rails Tutorial Sample App"
1件目:getのリダイレクトは標準の302。
users_controller.rbのshowはredirect_to root_urlで、ステータスを指定していません。303(See Other)は「結果は別のURLにあるので、GETで取得してください」という意味で、deleteの後のリダイレクトなどに付けます。
MDNには、「リダイレクトされたリソースを取得する方法は、常にGETを使用してください」とあります。302との詳しい比較はMDNのページにはなく、RFC 9110に基づく補足です。
showはもともとgetなので、303は必須ではありません。テスト側を302の想定に合わせました。
2件目:assert_select "title", "..."は完全一致。
実際のtitleはfull_titleのルールで「名前 | アプリ名」の形になっています。期待値を名前だけにしていたのが原因でした。
3件目(おまけ):カンマ忘れのSyntaxError。
直す途中で、assert_select "title" @activated_user.name + ...と、"title"のあとのカンマを抜かしてしまいました。
syntax error, unexpected instance variable, expecting end-of-input
引数が2つ並んでいるのに区切りがなく、Rubyは@activated_userを予期しない位置のインスタンス変数として読みます。構文エラーなのでテストは1件も実行されず、直したら本来の失敗の表示に戻りました。
8. toggle!の挙動
演習のコンソールでUser.paginate(page: 1).first.toggle!(:activated)が出てきました。
toggle!は真偽を反転して保存する。
Rails APIには、保存時にバリデーションは実行されないとあります。内部でupdate_attributeを使うため、コールバックは呼ばれます。2回実行すると元に戻ります。
paginateが返すものは、公式Wikiでは「遅延評価されるRelation」とあるだけで、firstの中身までは確認できていません。.firstが誰になるかは並び順次第なので、実行前後に名前を表示して確かめるのが安全です。
まとめ
-
sendはメソッド名を動的に呼ぶだけで、private も呼べる。public だけに限りたいならpublic_send -
attributeは特別なものではなく、:rememberや:activationを渡す普通の引数名 - トークンは仮想属性でメモリ上のみ、ダイジェストはDBに保存される
- ダイジェストから元のトークンは、作成者も含めて誰も復元できない。照合は再ハッシュで行う
- メソッドの引数を変えたら、呼び出し側(本体とテストの両方)を探して直す
-
superは親の同名メソッドを呼ぶ。実行順はsuperを書いた位置で決まる -
assert_select "title", "..."は完全一致で、full_titleの形に合わせる -
getのリダイレクトは302でよく、303(see_other)はdeleteなどの後に使う