月別アーカイブ: 2021年12月

Rails-tutorial(13.4マイクロポストを削除する 主に演習)

13.3から続く

13.3.4 マイクロポストを削除する

最後の機能として、マイクロポストリソースにポストを削除する機能を追加します。
これはユーザー削除と同様に”delete” リンクで実現します。
ユーザーの削除は管理者ユーザーのみが行えるように制限されていたのに対し、
今回は自分が投稿したマイクロポストに対してのみ削除リンクが
動作するようにします。

最初のステップとして、マイクロポストのパーシャルに削除リンクを追加します。作成したコードを示します。

マイクロポストのパーシャルに削除リンクを追加する
app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
 <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
 <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
 <span class="content"><%= micropost.content %></span>
 <span class="timestamp">
  Posted <%= time_ago_in_words(micropost.created_at) %> ago.
   <% if current_user?(micropost.user) %>
    <%= link_to "delete", micropost, method: :delete,
        data: { confirm: "You sure?" } %>
   <% end %>
 </span>
</li>

次にMicropostsコントローラのdestroyアクションを定義しましょう。これも、ユーザーにおける実装とだいたい同じです。
大きな違いは、admin_userフィルターで@user変数を使うのではなく、関連付けを使ってマイクロポストを見つけるようにしている点です。

これにより、あるユーザーが他のユーザーのマイクロポストを
削除しようとすると、自動的に失敗するようになります。
具体的には、correct_userフィルター内でfindメソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有しているかどうかを確認します。作成したコードを↓に示します。

Micropostsコントローラのdestroyアクション
app/controllers/microposts_controller.rb

before_action :correct_user, only: :destroy
 def destroy
  @micropost.destroy
  flash[:success] = "Micropost deleted"
  redirect_to request.referrer || root_url
 end

private

 def correct_user
 @micropost = current_user.microposts.find_by(id: params[:id])
 redirect_to root_url if @micropost.nil?
 end

destroyメソッドではリダイレクトを使っている点に注目してください。

request.referrerというメソッドを使っています。
このメソッドはフレンドリーフォワーディングのrequest.url変数
と似ていて、一つ前のURLを返します(今回の場合、Homeページになります)

演習

1:マイクロポストを作成し、その後作成したマイクロポストを
削除してみましょう。Railsサーバーのログを見てみて、
DELETE文の内容を確認してみてください。

DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 303]]

2:redirect_to request.referrer || root_urlの行を
redirect_back(fallback_location: root_url)と置き換えても
うまく動くことを、ブラウザを使って確認してみましょう
(このメソッドはRails 5から新たに導入されました)。

redirect_back(fallback_location: root_url)

13.3.5 フィード画面のマイクロポストをテストする

残っている箇所は、Micropostsコントローラの認可をチェックする短いテストとそれらをまとめる統合テストを書くことです。

まずはマイクロポスト用のfixtureに、
別々のユーザーに紐付けられたマイクロポストを追加していきます

別のユーザーに所属しているマイクロポストを追加する
test/fixtures/microposts.yml

ants:
content: "Oh, is that what you want? Because that's how you get ants!"
created_at: <%= 2.years.ago %>
user: archer

zone:
content: "Danger zone!"
created_at: <%= 3.days.ago %>
user: archer

tone:
content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
created_at: <%= 10.minutes.ago %>
user: lana

van:
content: "Dude, this van's, like, rolling probable cause."
created_at: <%= 4.hours.ago %>
user: lana

次に、自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストで確認します。

間違ったユーザーによるマイクロポスト削除に対してテストする green
test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

test "should redirect destroy for wrong micropost" do
 log_in_as(users(:michael))
 micropost = microposts(:ants)
 assert_no_difference 'Micropost.count' do
  delete micropost_path(micropost)
 end
 assert_redirected_to root_url
end

最後に、統合テストを書きます。今回の統合テストでは、ログイン、
マイクロポストのページ分割の確認、無効なマイクロポストを投稿、
有効なマイクロポストを投稿、マイクロポストの削除、
そして他のユーザーのマイクロポストには [delete] リンクが
表示されないことを確認、といった順でテストしていきます。
いつものように、統合テストを生成するところから始めましょう。

rails generate integration_test microposts_interface

先ほどの順で書いた統合テストは、↓のようになります。書いたコードと、先ほどのステップが結合されている点に注意してください。

マイクロポストのUIに対する統合テスト green
test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest

def setup
  @user = users(:michael)
end

test "micropost interface" do
 log_in_as(@user)
 get root_path
 assert_select 'div.pagination'
# 無効な送信
 assert_no_difference 'Micropost.count' do
 post microposts_path, params: { micropost: { content: "" } }
end
 assert_select 'div#error_explanation'
# 有効な送信
 content = "This micropost really ties the room together"
 assert_difference 'Micropost.count', 1 do
 post microposts_path, params: { micropost: { content: content } }
end
 assert_redirected_to root_url
 follow_redirect!
 assert_match content, response.body
# 投稿を削除する
 assert_select 'a', text: 'delete'
 first_micropost = @user.microposts.paginate(page: 1).first
 assert_difference 'Micropost.count', -1 do
 delete micropost_path(first_micropost)
end
# 違うユーザーのプロフィールにアクセス (削除リンクがないことを確認)
 get user_path(users(:archer))
  assert_select 'a', text: 'delete', count: 0
 end
end

演習

1:4つのコメント(無効な送信など)のそれぞれに対して、
テストが正しく動いているか確認してみましょう。
具体的には、対応するアプリケーション側のコードをコメントアウトし、テストが redになることを確認し、元に戻すとgreenになることを確認してみましょう。

@micropost = @user.microposts.build(content: "")にする
Expected false to be truthy.
test/models/micropost_test.rb:11:in `block in <class:MicropostTest>'

@micropost.destroy
flash[:success] = "Micropost deleted"

"Micropost.count" didn't change by -1.
Expected: 38
Actual: 39

ifの部分を消して誰でも見れるようにする

Expected exactly 0 elements matching "a", found 2..
Expected: 0
Actual: 2

2:サイドバーにあるマイクロポストの合計投稿数をテストしてみましょう。このとき、単数形 (micropost) と複数形 (microposts) が
正しく表示されているかどうかもテストしてください。

サイドバーでマイクロポストの投稿数をテストするためのテンプレート
test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

def setup
  @user = users(:michael)
end
.
.
.
test "micropost sidebar count" do
 log_in_as(@user)
 get root_path
 assert_match "#{FILL_IN} microposts", response.body
# まだマイクロポストを投稿していないユーザー
 other_user = users(:malory)
 log_in_as(other_user)
 get root_path
 assert_match "0 microposts", response.body
 other_user.microposts.create!(content: "A micropost")
 get root_path
 assert_match FILL_IN, response.body
 end
end

@user.microposts.count
“1 micropost”

13.4 マイクロポストの画像投稿

ここまででマイクロポストに関する基本的な操作はすべて実装できました。
この節では、応用編として画像付きマイクロポストを投稿できるようにしてみます。
手順としては、まずは開発環境用のβ版を実装し、その後、
いくつかの改善をとおして本番環境用の完成版を実装します。

画像アップロード機能を追加するためには、2つの視覚的な要素が必要です。1つは画像をアップロードするためのフォーム、もう1つは投稿された画像そのものです。

13.4.1 基本的な画像アップロード

投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、今回はCarrierWaveという画像アップローダーを使います。
まずはcarrierwave gemをGemfileに追加しましょう。

gem 'carrierwave', '1.2.2'
gem 'mini_magick', '4.7.0'
group :production do
gem 'fog', '1.42'
end
bundle install

CarrierWaveを導入すると、
Railsのジェネレーターで画像アップローダーが生成できるようになります。早速、次のコマンドを実行してみましょう。

rails generate uploader Picture

(画像のことをimageとすると一般的過ぎるので、
今回はpictureと呼ぶことにします)

picture属性をMicropostモデルに追加するために、マイグレーションファイルを生成し、開発環境のデータベースに適用します。

rails generate migration add_picture_to_microposts picture:string
rails db:migrate

CarrierWaveに画像と関連付けたモデルを伝えるためには、
mount_uploaderというメソッドを使います。このメソッドは、
引数に属性名のシンボルと生成されたアップローダーのクラス名を取ります。

mount_uploader :picture, PictureUploader

(picture_uploader.rbというファイルでPictureUploaderクラスが定義されています。後で修正しますが、今はデフォルトのままで大丈夫です。)
Micropostモデルにアップローダーを追加した結果を↓に示します。

Micropostモデルに画像を追加する
app/models/micropost.rb

class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  mount_uploader :picture, PictureUploader
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

Homeページにアップローダーを追加するためには、
マイクロポストのフォームにfile_fieldタグを含める必要があります

マイクロポスト投稿フォームに画像アップローダーを追加する
app/views/shared/_micropost_form.html.erb

<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
  <span class="picture">
    <%= f.file_field :picture %>
  </span>
<% end %>

Webから更新できる許可リストにpicture属性を追加しましょう。
追加すると、micropost_paramsメソッドは次のようになります。

pictureを許可された属性のリストに追加する
app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  before_action :correct_user,   only: :destroy
  .
  .
  .
  private

    def micropost_params
      params.require(:micropost).permit(:content, :picture)
    end

    def correct_user
      @micropost = current_user.microposts.find_by(id: params[:id])
      redirect_to root_url if @micropost.nil?
    end
end

一度画像がアップロードされれば、Micropostパーシャルのimage_tagヘルパーでその画像を描画できるようになります。

画像の無い (テキストのみの) マイクロポストでは画像を表示させないようにするために、picture?という論理値を返すメソッドを使っている点に
注目してください。このメソッドは、画像用の属性名に応じて、
CarrierWaveが自動的に生成してくれるメソッドです。

マイクロポストの画像表示を追加する
app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content">
    <%= micropost.content %>
    <%= image_tag micropost.picture.url if micropost.picture? %>
  </span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
    <% if current_user?(micropost.user) %>
      <%= link_to "delete", micropost, method: :delete,
                                       data: { confirm: "You sure?" } %>
    <% end %>
  </span>
</li>

演習

1:テンプレートを参考に、画像アップローダーをテストしてください。
テストの準備として、まずはサンプル画像をfixtureディレクトリに
追加してください(cp app/assets/images/rails.png test/fixtures/)。

追加したテストでは、Homeページにあるファイルアップロードと、
投稿に成功した時に画像が表示されているかどうかをチェックしています。
なお、テスト内にあるfixture_file_uploadというメソッドは、
fixtureで定義されたファイルをアップロードする特別なメソッドです。
ヒント: picture属性が有効かどうかを確かめるときは、assignsメソッドを使ってください。

このメソッドを使うと、
投稿に成功した後にcreateアクション内のマイクロポストにアクセスするようになります。

画像アップロードをテストするためのテンプレートtest/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    assert_select 'input[type=FILL_IN]'
    # 無効な送信
    post microposts_path, params: { micropost: { content: "" } }
    assert_select 'div#error_explanation'
    # 有効な送信
    content = "This micropost really ties the room together"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost:
                                      { content: content,
                                        picture: FILL_IN } }
    end
    assert FILL_IN.picture?
    follow_redirect!
    assert_match content, response.body
    # 投稿を削除する
    assert_select 'a', 'delete'
    first_micropost = @user.microposts.paginate(page: 1).first
    assert_difference 'Micropost.count', -1 do
      delete micropost_path(first_micropost)
    end
    # 違うユーザーのプロフィールにアクセスする
    get user_path(users(:archer))
    assert_select 'a', { text: 'delete', count: 0 }
  end
  .
  .
  .
end

答え:↑FILL_INを↓にする。

file,picture,

assigns(:micropost)

13.5に続く

Rails-tutorial(13.3マイクロポストを操作する 主に演習)

その13.2から続く

13.3 マイクロポストを操作する

次はWeb経由でそれらを作成するためのインターフェイスに
取りかかりましょう。この節では、ステータスフィード
(第14章で完成させます) の最初のヒントをお見せします。
最後に、ユーザーがマイクロポストをWeb経由で破棄できるようにします。

Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して実行されるので、
Micropostsコントローラにはnewやeditのようなアクションは不要と
いうことになります。つまり、createとdestroyがあれば十分です。

マイクロポストリソースのルーティング
config/routes.rb

#追加
resources :microposts, only: [:create, :destroy]
HTTPリクエスト URL    アクション 名前付きルート
POST        /microposts create microposts_path
DELETE       /microposts/1 destroy micropost_path(micropost)

13.3.1 マイクロポストのアクセス制御

Micropostsリソースの開発では、Micropostsコントローラ内の
アクセス制御から始めることにしましょう。
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければなりません。

ログイン済みかどうかを確かめるテストでは、
Usersコントローラ用のテストがそのまま役に立ちます。

正しいリクエストを各アクションに向けて発行し、
マイクロポストの数が変化していないかどうか、
また、リダイレクトされるかどうかを確かめればよいのです。

Micropostsコントローラの認可テスト red
test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

def setup
  @micropost = microposts(:orange)
end

test "should redirect create when not logged in" do
 assert_no_difference 'Micropost.count' do
  post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
 end
 assert_redirected_to login_url
end

test "should redirect destroy when not logged in" do
 assert_no_difference 'Micropost.count' do
  delete micropost_path(@micropost)
 end
 assert_redirected_to login_url
 end
end

少しアプリケーション側のコードをリファクタリングしておく必要があります。
というのも、beforeフィルターのlogged_in_userメソッドを使って、
ログインを要求したことについて思い出してください。

Usersコントローラ内にこのメソッドがあったので、
beforeフィルターで指定していましたが、
このメソッドはMicropostsコントローラでも必要です。
そこで、各コントローラが継承するApplicationコントローラに
(4.4.4)、このメソッドを移してしまいましょう。

ogged_in_userメソッドをApplicationコントローラに移す
app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper

private

# ユーザーのログインを確認する
def logged_in_user
 unless logged_in?
  store_location
  flash[:danger] = "Please log in."
  redirect_to login_url
  end
 end
end

コードが重複しないよう、このときUsersコントローラからも
logged_in_userを削除しておきましょう

Usersコントローラ内のlogged_in_userフィルターを削除する red
app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeフィルター

    # 正しいユーザーかどうかを確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end

    # 管理者かどうかを確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

Micropostsコントローラからもlogged_in_userメソッドを呼び出せる
ようになりました。これにより、createアクションやdestroyアクションに
対するアクセス制限が、beforeフィルターで簡単に実装できるようになります

Micropostsコントローラの各アクションに認可を追加する green
app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]

 def create
 end

 def destroy
 end
end

演習

1:なぜUsersコントローラ内にあるlogged_in_userフィルターを
残したままにするとマズイのでしょうか? 考えてみてください。

コードが重複して思わぬ動作が起きるから

13.3.2 マイクロポストを作成する

最後にホーム画面を実装したときは[Sign up now!]ボタンが中央にありましたマイクロポスト作成フォームは、
ログインしている特定のユーザーのコンテキストでのみ機能するので、
この節の一つの目標は、
ユーザーのログイン状態に応じて、ホーム画面の表示を変更することです。

次に、マイクロポストのcreateアクションを作り始めましょう。
このアクションも、ユーザー用アクションと似ています。違いは、
新しいマイクロポストをbuildするためにUser/Micropost関連付けを
使っている点です

micropost_paramsでStrong Parametersを使っていることにより、
マイクロポストのcontent属性だけがWeb経由で変更可能になっている点に
注目してください。

Micropostsコントローラのcreateアクション
app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]

def create
  @micropost = current_user.microposts.build(micropost_params)
 if @micropost.save
  flash[:success] = "Micropost created!"
   redirect_to root_url
  else
   render 'static_pages/home'
 end
end

def destroy
end

private

  def micropost_params
    params.require(:micropost).permit(:content)
  end
end

マイクロポスト作成フォームを構築するために、
サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使います

Homeページ (/) にマイクロポストの投稿フォームを追加するapp/views/static_pages/home.html.erb

<% if logged_in? %>
 <div class="row">
  <aside class="col-md-4">
   <section class="user_info">
    <%= render 'shared/user_info' %>
   </section>
   <section class="micropost_form">
    <%= render 'shared/micropost_form' %>
   </section>
  </aside>
 </div>
<% else %>


<% end %>

コードを動かすためには、いくつかのパーシャルを作る必要があります。まずはHomeページの新しいサイドバーからです。
次のようになります。

サイドバーで表示するユーザー情報のパーシャル
app/views/shared/_user_info.html.erb

<%= link_to gravatar_for(current_user, size: 50),
current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count,
"micropost") %></span>

そのユーザーが投稿したマイクロポストの総数が表示されていることに注目してください。ただし少し表示に違いがあります。

プロフィールサイドバーでは、 “Microposts” をラベルとし、
「Microposts (1)」と表示することは問題ありません。
しかし、今回のように “1 microposts” と表示してしまうと
英語の文法上誤りになってしまいます。そこで、pluralizeメソッドを使って
“1 micropost” や “2 microposts” と表示するように調整しています。

 

次はマイクロポスト作成フォームを定義します。
これはユーザー登録フォームに似ています。
マイクロポスト投稿フォームのパーシャル

app/views/shared/_micropost_form.html.erb

<%= form_for(@micropost) do |f| %>
 <%= render 'shared/error_messages', object: f.object %>
 <div class="field">
   <%= f.text_area :content, placeholder: "Compose new micropost..." %>
 </div>
 <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

フォームが動くようにするためには、2箇所の変更が必要です。1つは、
(以前と同様) 関連付けを使って次のように@micropostを定義することです。

homeアクションにマイクロポストのインスタンス変数を追加するapp/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController

  def home
    @micropost = current_user.microposts.build if logged_in?
  end

  def help
  end

  def about
  end

  def contact
  end
end

current_userメソッドはユーザーがログインしているときしか使えません
つまり、@micropost変数もログインしているときのみ
定義されるようになります。

リスト13.39を動かすためのもう1つの変更は、
エラーメッセージのパーシャルを再定義することです。でなければ、
次のコードが動きません。

<%= render 'shared/error_messages', object: f.object %>

form_for(@user) do |f|上のようにf.objectが@userとなる場合と、

form_for(@micropost) do |f|

上のようにf.objectが@micropostになる場合などがあります。

パーシャルにオブジェクトを渡すために、値がオブジェクトで、
キーがパーシャルでの変数名と同じハッシュを利用します。
これで、2行目のコードが完成します。言い換えると、object: f.objectはerror_messagesパーシャルの中でobjectという変数名を
作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということです。

Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する red
app/views/shared/_error_messages.html.erb

<% if object.errors.any? %>
<div id="error_explanation">
 <div class="alert alert-danger">
  The form contains <%= pluralize(object.errors.count, "error") %>.
 </div>
<ul>
<% object.errors.full_messages.each do |msg| %>
    <li><%= msg %></li>
   <% end %>
  </ul>
 </div>
<% end %>

なぜ失敗しているのでしょうか。error_messagesパーシャルの他の
出現場所です。
このパーシャルは他の場所でも使われていたため、
ユーザー登録、パスワード再設定、そしてユーザー編集のそれぞれのビューを更新する必要があったのです。
各ビューを更新した結果を示します。

ユーザー登録時のエラー表示を更新するapp/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>
      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
リスト 13.44: ユーザー編集時のエラー表示を更新するapp/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails">change</a>
    </div>
  </div>
</div>
リスト 13.45: パスワード再設定時のエラー表示を更新するapp/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Password reset</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

これで、すべてのテストが greenになるはずです。

rails test

演習

1:Homeページをリファクタリングして、if-else文の分岐のそれぞれに対してパーシャルを作ってみましょう。

<% if logged_in? %>
<%= render 'static_pages/if'%>
<% else %>
<%= render 'static_pages/else'%>
<% end %>

13.3.3 フィードの原型

マイクロポスト投稿フォームが動くようになりましたが、今の段階では投稿した内容をすぐに見ることができません。Homeページにまだマイクロポストを表示する部分が実装されていないからです。

すべてのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然です。
フィードの原型では、まずは現在ログインしているユーザーの
マイクロポストをすべて取得してきます。
なお、次章で完全なフィードを実装するため、
今回は紹介したwhereメソッドでこれを実現します。
Userモデルに変更を加えた結果を示します

マイクロポストのステータスフィードを実装するための準備
app/models/user.rb

# 試作feedの定義
# 完全な実装は次章の「ユーザーをフォローする」を参照
def feed
  Micropost.where("user_id = ?", id)
end

疑問符は、セキュリティ上重要な役割を↑果たしています。

SQLクエリに代入する前にidがエスケープされるため、SQLインジェクション(SQL Injection)と呼ばれる深刻なセキュリティホールを避けることができます。
この場合のid属性は単なる整数 (すなわちself.idはユーザーのid)
であるため危険はありませんが、
SQL文に変数を代入する場合は常にエスケープする習慣をぜひ身につけてください。

↑のコードは本質的に↓のコードと同等であることに気付くかもしれません。

def feed
  microposts
end

サンプルアプリケーションにフィード機能を導入するため、
ログインユーザーのフィード用にインスタンス変数@feed_itemsを追加し、Homeページにはフィード用のパーシャルを追加します。

homeアクションにフィードのインスタンス変数を追加する
app/controllers/static_pages_controller.rb

class StaticPagesController < ApplicationController

def home
  if logged_in?
    @micropost = current_user.microposts.build
    @feed_items = current_user.feed.paginate(page: params[:page])
  end
end

ステータスフィードのパーシャル
app/views/shared/_feed.html.erb

<% if @feed_items.any? %>
 <ol class="microposts">
  <%= render @feed_items %>
 </ol>
 <%= will_paginate @feed_items %>
<% end %>

このとき、@feed_itemsの各要素がMicropostクラスを持っていたため、RailsはMicropostのパーシャルを呼び出すことができました。
このように、Railsは対応する名前のパーシャルを、
渡されたリソースのディレクトリ内から探しにいくことができます。

app/views/microposts/_micropost.html.erb

Homeページにステータスフィードを追加する
app/views/static_pages/home.html.erb

<% if logged_in? %>
 <div class="row">
  <aside class="col-md-4">
   <section class="user_info">
    <%= render 'shared/user_info' %>
   </section>
   <section class="micropost_form">
    <%= render 'shared/micropost_form' %>
   </section>
  </aside>
<div class="col-md-8">
 <h3>Micropost Feed</h3>
  <%= render 'shared/feed' %>
 </div>
</div>
<% else %>
#後略
<% end %>

マイクロポストの投稿が失敗すると、Homeページは
@feed_itemsインスタンス変数を期待しているため、現状では壊れてしまいます。

最も簡単な解決方法は、空の配列を渡しておくことです。
残念ですが、この場合はページ分割されたフィードを返してもうまく動きません。

createアクションに空の@feed_itemsインスタンス変数を追加するapp/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
    @micropost = current_user.microposts.build(micropost_params)
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      @feed_items = []
      render 'static_pages/home'
    end
  end

  def destroy
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end
end

演習

1:新しく実装したマイクロポストの投稿フォームを使って、
実際にマイクロポストを投稿してみましょう。
Railsサーバーのログ内にあるINSERT文では、
どういった内容をデータベースに送っているでしょうか? 確認してみてください。

INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "tet\r\n"], ["user_id", 1], ["created_at", "2020-04-10 09:48:54.428673"], ["updated_at", "2020-04-10 09:48:54.428673"]]

2:コンソールを開き、user変数にデータベース上の最初のユーザーを
代入してみましょう。
その後、Micropost.where(“user_id = ?”, user.id)とuser.microposts、そしてuser.feedをそれぞれ実行してみて、
実行結果がすべて同じであることを確認してみてください。
ヒント: ==で比較すると結果が同じかどうか簡単に判別できます。

user = User.first
Micropost.where("user_id = ?", user.id) == user.microposts
Micropost Load (1.1ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC [["user_id", 1]]
Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE (user_id = 1) ORDER BY "microposts"."created_at" DESC
=> true
>> user.feed == user.microposts

13.4に続く

Rails-tutorialのまとめ(13.2.1 マイクロポストの描画 主に演習)

その13から続く

13.2.1 マイクロポストの描画

ユーザーのプロフィール画面 (show.html.erb) でそのユーザーの
マイクロポストを表示させたり、これまでに投稿した総数も表示させたりしていきます。

Micropostのコントローラとビューを作成するために、コントローラを生成しましょう。なお、今回使うのはビューだけで、Micropostsコントローラは 13.3から使っていきます。

rails db:migrate:reset
rails db:seed
rails generate controller Microposts

_micropost.html.erbパーシャルを使ってマイクロポストのコレクションを
表示しようとすると、次のようになります。

<ol class=”microposts”>
<%= render @microposts %>
</ol>

順序無しリストのulタグではなく、順序付きリストのolタグを使っている点に注目してください。
これは、マイクロポストが特定の順序 (新しい→古い)
に依存しているためです。

次に、対応するパーシャルを示します。
1つのマイクロポストを表示するパーシャル
app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
 <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
 <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
 <span class="content"><%= micropost.content %></span>
 <span class="timestamp">
  Posted <%= time_ago_in_words(micropost.created_at) %> ago.
 </span>
</li>

time_ago_in_wordsという↑ヘルパーメソッドを使っています。
メソッド名の表すとおりですが、「3分前に投稿」といった文字列を出力します。

各マイクロポストに対してCSSのidを割り振っています。

<li id=”micropost-<%= micropost.id %>”>

これは一般的に良いとされる慣習で、例えば将来、JavaScriptを使って
各マイクロポストを操作したくなったときなどに役立ちます。

一度にすべてのマイクロポストが表示されてしまう潜在的問題に対処します。10ではページネーションを使いましたが、今回も同じ方法でこの問題を解決します。
前回同様、will_paginateメソッドを使うと次のようになります。

<%= will_paginate @microposts %>

以前は次のように単純なコードでした。

<%= will_paginate %>

実は、上のコードは引数なしで動作していました。
これはwill_paginateが、Usersコントローラのコンテキストに
おいて、@usersインスタンス変数が存在していることを前提としているためです

今回の場合はUsersコントローラのコンテキストからマイクロポストをページネーションしたいため (つまりコンテキストが異なるため)、
明示的に@microposts変数をwill_paginateに渡す必要があります。

したがって、そのようなインスタンス変数をUsersコントローラの
showアクションで定義しなければなりません
@micropostsインスタンス変数をshowアクションに追加する

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(page: params[:page])
  end
  .
  .
  .
end

マイクロポストの投稿数を表示することですが、これはcountメソッドを使うことで解決できます。

user.microposts.count

paginateと同様に、関連付けをとおしてcountメソッドを呼び出すことができます。
大事なことは、countメソッドではデータベース上の
マイクロポストを全部読みだしてから結果の配列に対してlengthを呼ぶ、といった無駄な処理はしていないという点です。
そんなことをしたら、
マイクロポストの数が増加するにつれて効率が低下してしまいます。
そうではなく、(データベース内での計算は高度に最適化されているので)データベースに代わりに計算してもらい、
特定のuser_idに紐付いた
マイクロポストの数をデータベースに問い合わせています。

すべての要素が揃ったので、プロフィール画面にマイクロポストを
表示させてみましょう(このとき、if @user.microposts.any?を使って、ユーザーのマイクロポストが1つもない場合には空のリストを
表示させていない点にも注目してください。)

マイクロポストをユーザーのshowページ (プロフィール画面) に追加する
app/views/users/show.html.erb

<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
  <div class="col-md-8">
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>

演習

今回ヘルパーメソッドとして使ったtime_ago_in_wordsメソッドは、
Railsコンソールのhelperオブジェクトから呼び出すことができます。
このhelperオブジェクトのtime_ago_in_wordsメソッドを使って、
3.weeks.agoや6.months.agoを実行してみましょう。

helper.time_ago_in_words(3.weeks.ago)
=> "21 days"
>> helper.time_ago_in_words(6.months.ago)
=> "6 months"

2:helper.time_ago_in_words(1.year.ago)と実行すると、どういった結果が返ってくるでしょうか?

helper.time_ago_in_words(1.year.ago)
=> "about 1 year"

3:micropostsオブジェクトのクラスは何でしょうか? ヒント: リスト 13.23内のコードにあるように、まずはpaginateメソッド (引数はpage: nil) でオブジェクトを取得し、その後classメソッドを呼び出してみましょう。

user = User.find(1)
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-04-10 02:03:10", updated_at: "2020-04-10 02:03:10", password_digest: "$2a$10$cwFqu4HHjAMUEhZpbM294.4JUOfjV08XlIzGu37zTSD...", remember_digest: nil, admin: true, activation_digest: "$2a$10$mRd3DEhj6awejp8QoTEbouySA73h7GJW4h5MQYUJ3ax...", activated: true, activated_at: "2020-04-10 02:03:09", reset_digest: nil, reset_sent_at: nil>
>> microposts = user.microposts.paginate(page:nil)
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC LIMIT ? OFFSET ? [["user_id", 1], ["LIMIT", 11], ["OFFSET", 0]]
=> #<ActiveRecord::AssociationRelation []>
>> microposts.class
=> Micropost::ActiveRecord_AssociationRelation

13.2.2 マイクロポストのサンプル

すべてのユーザーにマイクロポストを追加しようとすると時間が掛かり過ぎるので、takeメソッドを使って最初の6人だけに追加します。

User.order(:created_at).take(6)

このとき、orderメソッドを経由することで、
作成されたユーザーの最初の6人を明示的に呼び出すようにしています。

この6人については、1ページの表示限界数(30)を越えさせるために、
それぞれ50個分のマイクロポストを追加するようにしています。
また、各投稿内容についてですが、Faker gem
にLorem.sentenceという便利なメソッドがあるので、これを使います。

サンプルデータにマイクロポストを追加する
db/seeds.rb

users = User.order(:created_at).take(6)
50.times do
content = Faker::Lorem.sentence(5)
users.each { |user| user.microposts.create!
(content: content) }
end
rails db:migrate:reset
rails db:seed

マイクロポスト用のCSS (本章で利用するCSSのすべて)
app/assets/stylesheets/custom.scss

.
.
/* microposts */

.microposts {
  list-style: none;
  padding: 0;
  li {
    padding: 10px 0;
    border-top: 1px solid #e8e8e8;
  }
  .user {
    margin-top: 5em;
    padding-top: 0;
  }
  .content {
    display: block;
    margin-left: 60px;
    img {
      display: block;
      padding: 5px 0;
    }
  }
  .timestamp {
    color: $gray-light;
    display: block;
    margin-left: 60px;
  }
  .gravatar {
    float: left;
    margin-right: 10px;
    margin-top: 5px;
  }
}

aside {
  textarea {
    height: 100px;
    margin-bottom: 5px;
  }
}

span.picture {
  margin-top: 10px;
  input {
    border: 0;
  }
}

演習

1:(1..10).to_a.take(6)というコードの実行結果を推測できますか?
推測した値が合っているかどうか、コンソールを使って確認してみましょう。

(1..10).to_a.take(6)
=> [1, 2, 3, 4, 5, 6]

2:先ほどの演習にあったto_aメソッドの部分は本当に必要でしょうか? 確かめてみてください。

(1..10).take(6)
=> [1, 2, 3, 4, 5, 6]

3:Fakerのドキュメント (英語) を眺めながら画面に出力する方法を学び、実際に大学名や電話番号を画面に出力してみましょう。

Faker::Hipster.words
=> ["whatever", "schlitz", "kickstarter"]
>> Faker::ChuckNorris.fact
=> "Quantum cryptography does not work on Chuck Norris. When something is being observed by Chuck it stays in the same state until he's finished."

13.2.3 プロフィール画面のマイクロポストをテストする

プロフィール画面で表示されるマイクロポストに対して、
統合テストを書いていきます。まずは、プロフィール画面用の統合テストを生成してみましょう。

rails generate integration_test users_profile

プロフィール画面におけるマイクロポストをテストするためには、
ユーザーに紐付いたマイクロポストのテスト用データが必要になります。
Railsの慣習に従って、関連付けされたテストデータをfixtureファイルに追加すると、次のようになります。

orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael

userにmichaelという値を渡すと、
Railsはfixtureファイル内の対応するユーザーを探し出して、
(もし見つかれば) マイクロポストに関連付けてくれます。

マイクロポストのページネーションをテストするためには、
マイクロポスト用のfixtureにいくつかテストデータを追加する必要が
ありますが、これはリスト 10.47でユーザーを追加したときと同様に、埋め込みRubyを使うと簡単です。

<% 30.times do |n| %>
micropost_<%= n %>:
content: <%= Faker::Lorem.sentence(5) %>
created_at: <%= 42.days.ago %>
user: michael
<% end %>

ユーザーと関連付けされたマイクロポストのfixture
test/fixtures/microposts.yml

orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>
  user: michael

tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>
  user: michael

cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
  user: michael

most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>
  user: michael

<% 30.times do |n| %>
micropost_<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  created_at: <%= 42.days.ago %>
  user: michael
<% end %>

Userプロフィール画面に対するテストgreen

test/integration/users_profile_test.rb

require 'test_helper'

class UsersProfileTest < ActionDispatch::IntegrationTest
include ApplicationHelper

def setup
  @user = users(:michael)
end

test "profile display" do
 get user_path(@user)
 assert_template 'users/show'
 assert_select 'title', full_title(@user.name)
 assert_select 'h1', text: @user.name
 assert_select 'h1>img.gravatar'
 assert_match @user.microposts.count.to_s, response.body
 assert_select 'div.pagination'
  @user.microposts.paginate(page: 1).each do |micropost|
   assert_match micropost.content, response.body
  end
 end
end

response.bodyにはそのページの完全なHTMLが含まれています
(HTMLのbodyタグだけではありません)。つまり、そのページのどこかしらに
マイクロポストの投稿数が存在するのであれば、
次のように探し出してマッチできるはずです。

assert_match @user.microposts.count.to_s, response.body

これはassert_selectよりもずっと抽象的なメソッドです。
特に、assert_selectではどのHTMLタグを探すのか伝える必要が
ありますが、assert_matchメソッドではその必要がない点が違います。

assert_selectの引数では、
ネストした文法を使っている点にも注目してください。

assert_select 'h1>img.gravatar'

h1タグ (トップレベルの見出し) の内側にある、
gravatarクラス付きのimgタグがあるかどうかをチェックできます。

green
rails test

演習

1:2つの’h1’のテストが正しいか確かめるため、
該当するアプリケーション側のコードをコメントアウトしてみましょう。greenからredに変わることを確認してみてください。

Expected at least 1 element matching "h1", found 0..
Expected 0 to be >= 1.
REDになる

2:テストを変更して、will_paginateが1度のみ表示されていることを
テストしてみましょう。ヒント: 表 5.2を参考にしてください。

assert_select 'div.pagination', count:1

13.3に続く

Rails-tutorialのまとめ(第13章ユーザーのマイクロポスト 主に演習)

その12から続く

第13章ユーザーのマイクロポスト

ユーザーというリソースだけが、
Active Recordによってデータベース上のテーブルと紐付いています。
全ての準備が整った今、
ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していきます。

Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行い、さらに、結果を処理し表示するために必要なフォームとその部品を作成します

13.1 Micropostモデル

この新しいMicropostモデルもデータ検証とUserモデルの関連付けを含んでいます。
以前のモデルとは違って、
今回のマイクロポストモデルは完全にテストされ、
デフォルトの順序を持ち、また親であるユーザーが破棄された場合には自動的に破棄されるようにします。

13.1.1 基本的なモデル

Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持ちます。

マイクロポストの投稿にString型ではなくText型を使っている。
Text型の方が表現豊かなマイクロポストを実現できるから
Text型の方が将来における柔軟性に富んでいて
Text用のテキストエリアを使うため、
より自然な投稿フォームが実現できます。

rails generate model Micropost content:text user:references

ApplicationRecordを継承したモデルが作られます。
ただし、今回は生成されたモデルの中に、
ユーザーと1対1の関係であることを表す。
belongs_toのコードも追加されています。
user:referencesという引数も含めていたからです。

このgenerateコマンドはmicropostsテーブルを作成するための
マイグレーションファイルを生成します。

Userモデルとの最大の違いはreferences型を利用している点です。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、
UserとMicropostを関連付けする下準備をしてくれます。

インデックスが付与されたMicropostのマイグレーション
db/migrate/[timestamp]_create_microposts.rb

  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at]
  end
end

user_idとcreated_atカラムにインデックスが付与されていることに注目こうすることで、
user_idに関連付けられたすべてのマイクロポストを
作成時刻の逆順で取り出しやすくなります。

user_idとcreated_atの両方を1つの配列に含めている点にも注目です。
こうすることでActive Recordは、両方のキーを同時に扱う複合キーインデックス(Multiple Key Index) を作成します。

rails db:migrate

続きを読む

Rails-tutorialのまとめ12.3(パスワードの再設定をテストする 主に演習)

12章から続く

12.3.3 パスワードの再設定をテストする

パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点がありますが、
テストの冒頭部分には次のような違いがあります。
最初に「forgot password」フォームを表示して無効なメールアドレスを送信し、
次はそのフォームで有効なメールアドレスを送信します。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信されます。

続いて、メールのリンクを開いて無効な情報を送信し、

次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを
確認します。作成したテストを示します。
このテストはコードリーディングのよい練習台になりますよ。

パスワード再設定の統合テスト
test/integration/password_resets_test.rb

require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

def setup
  ActionMailer::Base.deliveries.clear
@user = users(:michael)
end

test "password resets" do
 get new_password_reset_path
 assert_template 'password_resets/new'
 # メールアドレスが無効
 post password_resets_path, params: { password_reset: { email: "" } }
 assert_not flash.empty?
 assert_template 'password_resets/new'
# メールアドレスが有効
 post password_resets_path,
 params: { password_reset: { email: @user.email } }
 assert_not_equal @user.reset_digest, @user.reload.reset_digest
 assert_equal 1, ActionMailer::Base.deliveries.size
 assert_not flash.empty?
 assert_redirected_to root_url
# パスワード再設定フォームのテスト
 user = assigns(:user)
# メールアドレスが無効
 get edit_password_reset_path(user.reset_token, email: "")
 assert_redirected_to root_url
# 無効なユーザー
 user.toggle!(:activated)
 get edit_password_reset_path(user.reset_token, email: user.email)
 assert_redirected_to root_url
 user.toggle!(:activated)
# メールアドレスが有効で、トークンが無効
 get edit_password_reset_path('wrong token', email: user.email)
 assert_redirected_to root_url
# メールアドレスもトークンも有効
 get edit_password_reset_path(user.reset_token, email: user.email)
 assert_template 'password_resets/edit'
 assert_select "input[name=email][type=hidden][value=?]", user.email
# 無効なパスワードとパスワード確認
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "foobaz",
 password_confirmation: "barquux" } }
 assert_select 'div#error_explanation'
# パスワードが空
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "",
 password_confirmation: "" } }
 assert_select 'div#error_explanation'
# 有効なパスワードとパスワード確認
 patch password_reset_path(user.reset_token),
 params: { email: user.email,
 user: { password: "foobaz",
 password_confirmation: "foobaz" } }
 assert is_logged_in?
 assert_not flash.empty?
 assert_redirected_to user
 end
end

assert_select “input[name=email][type=hidden][value=?]”
, user.email

上のコードは、inputタグに正しい名前、type=”hidden”、
メールアドレスがあるかどうかを確認します。

green
rails test

演習

1:create_reset_digestメソッドはupdate_attributeを
2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。

テンプレートを使って、update_attributeの呼び出しを
1回のupdate_columns呼び出しにまとめてみましょう。
(これでデータベースへの問い合わせが1回で済むようになります)。
また、変更後にテストを実行し、 greenになることも確認してください。

user.rb
(前略)
  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest:  User.digest(reset_token), reset_sent_at: Time.zone.now)
  end

2:テンプレートを埋めて、期限切れのパスワード再設定で発生する分岐を統合テストで網羅してみましょう(response.bodyは、
そのページのHTML本文をすべて返すメソッドです)。
期限切れをテストする方法はいくつかありますが、
オススメした手法を使えば、
レスポンスの本文に「expired」という語があるかどうかでチェックできます(なお、大文字と小文字は区別されません)。

assert_match /FILL_IN/i, response.body
expired
password_resets_test.rb

(前略)
  test "expired token" do
    get new_password_reset_path
    post password_resets_path,
         params: { password_reset: { email: @user.email } }

    @user = assigns(:user)
    @user.update_attribute(:reset_sent_at, 3.hours.ago)
    patch password_reset_path(@user.reset_token),
          params: { email: @user.email,
                    user: { password:              "foobar",
                            password_confirmation: "foobar" } }
    assert_response :redirect
    follow_redirect!
    #追加
    assert_match "expired", response.body   
  end

3:2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。
しかし、もっと良くする方法はまだあります。
例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。
仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを
表示させ、パスワードを更新してしまうことができてしまいます
(しかもそのままログイン機構まで突破されてしまいます!)。
この問題を解決するために、コードを追加し、
パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょ。

パスワード再設定が成功したらダイジェストをnilにする
app/controllers/password_resets_controller.rb

@user.update_attribute(:reset_digest, nil)

4:1行追加し、1つ前の演習課題に対するテストを書いてみましょう。assert_nilメソッドとuser.reloadメソッドを組み合わせて、
reset_digest属性を直接テストしてみましょう。

password_resets_test.rb
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
(中略)
  test "password resets" do
(中略)
  assert_redirected_to user
  assert_nil user.reload['reset_digest']
end

12.4 本番環境でのメール送信 (再掲)

もし既に前章でセットアップを終わらせていたら 演習までスキップしてしまっても大丈夫です。

演習

1:production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

動作確認するだけ

2:メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
ヒント: ターミナルからheroku logsコマンドを実行してみましょう。

動作確認するだけ: ターミナルからheroku logsコマンドを実行。

3:アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?

動作確認するだけ

12.6 証明: 期限切れの比較

12.3では、パスワードの期限が切れたかどうかを調べるために、次の比較を行いました。

reset_sent_at < 2.hours.ago

リスト 12.17で説明したように、この式を「少ない」と解釈すると逆の意味になってしまいますので、「早い」と解釈してみてください 

最初に、期間を2つ定義します。Δtrをパスワード再設定メールを送信してからの期間、Δteをパスワード再設定の有効な期間 (例: 2時間) と定めます。

パスワードの再設定は、メールが送信された時刻から経過した期間が、有効期間よりも長くなった場合に「期限切れ」となります。これを次のように表します。

ここで、現在時刻 (訳注: 比較を行った時刻) をtN、パスワード再設定メールの送信時刻をtr、有効期間が切れる時刻 (例: 2時間経過後) をteと表すと、次の2つの関係式を得ることができます。


式 (12.2)式 (12.3)式 (12.1)に代入すると、次の結果が得られます。





両辺に−1をかけると、次の式が得られます。

式 (12.4)をRailsのコードに置き換え、値をte=2 時間前とすると、
password_reset_expired?メソッドと同じコードになります。

def password_reset_expired?
  reset_sent_at < 2.hours.ago
end

<記号を「〜より少ない」ではなく「〜より早い時刻」と解釈すれば、「パスワードの再設定は、現在より2時間以上前の時刻に行われた」という言明と一致します。

12章のまとめ

1:パスワードの再設定は Active Recordオブジェクトではないが、
セッションやアカウント有効化の場合と同様に、リソースでモデル化できる

2:Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる

3:Action MailerではテキストメールとHTMLメールの両方を利用できる

4:メイラーアクションで定義したインスタンス変数は、
他のアクションやビューと同様、メイラーのビューから参照できる

5:パスワードを再設定させるために、生成したトークンを使って一意のURLを作る

6:より安全なパスワード再設定のために、ハッシュ化したトークン (ダイジェスト) を使う

7:メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用

8:SendGridを使うとproduction環境からメールを送信できる

その13に続く

Rails-tutorialのまとめ(第12章 パスワードの再設定 主に演習)

その11から続く

第12章パスワードの再設定

全体の流れは次のとおりです。

  1. ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける。
  2. 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定ダイジェストを生成する。
  3. 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。
  4. ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)。
  5. 認証に成功したら、パスワード変更用のフォームをユーザーに表示する。

12.1 PasswordResetsリソース
12.1.1 PasswordResetsコントローラ

今回はビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意してください。

rails generate controller PasswordResets new edit
--no-test-framework

新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、
new、create、edit、updateのルーティングも用意しましょう。
この変更は、前回と同様にルーティングファイルのresources行で行います。

パスワード再設定用リソースを追加する
config/routes.rb

resources :password_resets, only: [:new, :create, :edit, :update]

HTTPリクエスト URL                        Action      名前付きルート
GET        /password_resets/new          new     new_password_reset_path
POST       /password_resets              create  password_resets_path
GET        /password_resets/<token>/edit edit    edit_password_reset_url(token)
PATCH      /password_resets/<token>      update  password_reset_url(token)

PasswordResetsリソースで提供されるRESTfulルーティング

パスワード再設定画面へのリンクを追加する
app/views/sessions/new.html.erb

<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>
      <%= f.label :password %>
      <%= link_to "(forgot password)", new_password_reset_path %>
      <%= f.password_field :password, class: 'form-control' %>
      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>
      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>
    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

演習

1:この時点で、テストスイートが greenになっていることを確認してみましょう

rails test

2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?

トークンのURLは絶対パス(https://〜)を使用する必要があるからだよ

12.1.2 新しいパスワードの設定

パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していきます。
もしトークンをハッシュ化せずに (つまり平文で)データベースに
保存してしまうとすると、
攻撃者によってデータベースからトークンを読み出されたとき、
セキュリティ上の問題が生じます。

パスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。
それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。

rails generate migration add_reset_to_users reset_digest:string \
reset_sent_at:datetime
rails db:migrate

新しいパスワード再設定画面ビュー
app/views/password_resets/new.html.erb

<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
 <div class="col-md-6 col-md-offset-3">
  <%= form_for(:password_reset, url: password_resets_path) do |f| %>
   <%= f.label :email %>
   <%= f.email_field :email, class: 'form-control' %>
   <%= f.submit "Submit", class: "btn btn-primary" %>
 <% end %>
 </div>
</div>

演習

1:form_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか?考えてみてください。

シンボルを使ってフォームをするとRailsが自動で送信先に値を割り当ててくれるから。

12.1.3 createアクションでパスワード再設定

メールアドレスをキーとしてユーザーをデータベースから見つけ、
パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を
更新する必要があります。それに続いてルートURLにリダイレクトし、
フラッシュメッセージをユーザーに表示します。送信が無効の場合は、
ログインと同様にnewページを出力してflash.nowメッセージを表示します。

パスワード再設定用のcreateアクション
app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController

 def new
 end

def create
  @user = User.find_by(email: params[:password_reset][:email].downcase)
 if @user
  @user.create_reset_digest
  @user.send_password_reset_email
  flash[:info] = "Email sent with password reset instructions"
  redirect_to root_url
 else
  flash.now[:danger] = "Email address not found"
  render 'new'
 end
end

  def edit
  end
end

Userモデル内のコードは、before_createコールバック内で
使わるcreate_activation_digestメソッドと似ています

Userモデルにパスワード再設定用メソッドを追加する
app/models/user.rb

class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  #中略

  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_attribute(:reset_digest,  User.digest(reset_token))
    update_attribute(:reset_sent_at, Time.zone.now)
  end

  # パスワード再設定のメールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  private
  #後略
end

演習

1:試しに有効なメールアドレスをフォームから送信してみましょう
どんなエラーメッセージが表示されたでしょうか?

ArgumentError in PasswordResetsController#create
wrong number of arguments (given 1, expected 0)

2:コンソールに移り、先ほどの演習課題で送信した結果、
(エラーと表示されてはいるものの) 該当するuserオブジェクトには
reset_digestとreset_sent_atがあることを確認してみましょう。
また、それぞれの値はどのようになっていますか?

"reset_digest",
"$2a$10$an7jBdlMlSmv2peunVbzZODIILfhU3EaXCvqYIG"

“reset_sent_at”, “2021-04-08 13:57:15.435675”

12.2 パスワード再設定のメール送信(11章やってれば飛ばしてOK)

12.2.1 パスワード再設定のメールとテンプレート

パスワード再設定のリンクをメール送信する
app/mailers/user_mailer.rb

class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end
end

演習

1:ブラウザから、送信メールのプレビューをしてみましょう。
「Date」の欄にはどんな情報が表示されているでしょうか?

http://3cfb3f407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

↑にアクセスすると
Date:Wed, 08 Apr 2021 14:12:22 +0000

2:パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。

rails sで確認するだけ

3:コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。
オブジェクトを見つけたら、そのオブジェクトが
持つreset_digestとreset_sent_atの値を確認してみましょう。

reset_digest: "$2a$10$5aTNJOr.f
/EGDHrzKmwMEOQ0.dgECVhOaKh8Y7e..."

reset_sent_at: "2021-04-08 14:09:52">

演習

1:メイラーのテストだけを実行してみてください。このテストは
greenになっているでしょうか?

rails test:mailers

2:2つ目のCGI.escapeを削除すると、
テストがredになることを確認してみましょう。

ArgumentError: ArgumentError: wrong number of arguments
(given 1, expected 2..3)

12.3.1 editアクションで再設定

パスワード再設定フォームを表示するビューが必要です。このビューはユーザーの編集フォームと似ていますが、
今回はパスワード入力フィールドと確認用フィールドだけで
十分です。

メールアドレスをキーとしてユーザーを検索するためには、
editアクションとupdateアクションの両方でメールアドレスが必要になるからです。
例のメールアドレス入りリンクのおかげで、editアクションで
メールアドレスを取り出すことは問題ありません。
しかしフォームを一度送信してしまうと、この情報は消えてしまいます。

隠しフィールドとしてページ内に保存する手法をとります。
これにより、フォームから送信したときに、
他の情報と一緒にメールアドレスが送信されるようになります。

パスワード再設定のフォームapp/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages' %>
      <%= hidden_field_tag :email, @user.email %>
      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>
      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>
      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

フォームタグヘルパーを使っている点にご注意ください。

hidden_field_tag :email, @user.email

これまでは次のようなコードを書いていましたが、今回は書き方が異なっています。

f.hidden_field :email, @user.email

これは再設定用のリンクをクリックすると、前者 (hidden_field_tag) ではメールアドレスがparams[:email]に保存されますが、
後者ではparams[:user][:email] に保存されてしまうからです。

このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義していきます。

params[:email]のメールアドレスに対応するユーザーをこの変数に保存します。
続いて、params[:id]の再設定用トークンと、抽象化したauthenticated?メソッドを使って、
このユーザーが正当なユーザーである
(ユーザーが存在する、有効化されている、認証済みである) ことを確認します。

editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行います。

パスワード再設定のeditアクション
app/controllers/password_resets_controller.rb

class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  .
  .
  .
  def edit
  end

  private

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 正しいユーザーかどうか確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end
end

演習

1:手順に従って、Railsサーバーのログから送信メールを探し出し、
そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて下さい。

http://3cfb3f48c69407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

2:先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?

成功する

12.3.2 パスワードを更新する

AccountActivationsコントローラのeditアクションでは、
ユーザーの有効化ステータスをfalseからtrueに変更しましたが、
今回の場合はフォームから新しいパスワードを送信するようになっています。
したがって、フォームからの送信に対応するupdateアクションが必要になります。
このupdateアクションでは、次の4つのケースを考慮する必要があります。

1:パスワード再設定の有効期限が切れていないか
2:無効なパスワードであれば失敗させる (失敗した理由も表示する)
3:新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4:新しいパスワードが正しければ、更新する

check_expirationメソッドでは、期限切れかどうかを確認する
インスタンスメソッド「password_reset_expired?」を使っています

# 期限切れかどうかを確認する
def check_expiration
  if @user.password_reset_expired?
    flash[:danger] = "Password reset has expired."
    redirect_to new_password_reset_url
  end
end

errors.addを使ってエラーメッセージを追加します。

このように書くと、パスワードが空だった時に空の文字列に対するデフォルトの
メッセージを表示してくれるようになります。

パスワード再設定のupdateアクションapp/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,         only: [:edit, :update]
  before_action :valid_user,       only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update]    # (1) への対応

  def new
  end

  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest
      @user.send_password_reset_email
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

  def edit
  end

  def update
    if params[:user][:password].empty?                  # (3) への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)          # (4) への対応
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'                                     # (2) への対応
    end
  end

  private

    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end

    # beforeフィルタ

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 有効なユーザーかどうか確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end

    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end
user_paramsメソッドを使ってpasswordpassword_confirmation属性を精査している点に注意してください。
Userモデルにパスワード再設定用メソッドを追加するapp/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

演習

1:Railsサーバーのログから取得をブラウザで表示し、
passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?

「フォームに1つのエラーが含まれています。
パスワードの確認がパスワードと一致しません」

2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。
次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう 。
パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。 新しい値はuser.reloadを通して取得する必要があります。

動作確認するだけ

12.3に続く