LoginSignup
0
1

More than 3 years have passed since last update.

Railsチュートリアル 第8章 基本的なログイン機構 - セッション

Posted at

認証システムと認可モデル

認証システム(Authentification System)

以下のような仕組みのことを言います。

  • Webブラウザが、ログインしている状態を保持する
  • ユーザーによってWebブラウザが閉じられたら状態を破棄する

認可モデル(Authorization Model)

以下のような仕組みのことを言います。

  • ログイン済みのユーザー(current user)だけがアクセスできるページの制御
  • ログイン済みのユーザーが扱える機能の制御

認証システムと認可モデルを基盤として実現する機能

以下のような機能は、認証システムと認可モデルを基盤として実現される機能です。

  • ログインしているユーザーのみが、ユーザー一覧のページへ移動できるようにする
  • 正当なユーザーだけが自分のプロフィール情報を編集できるようにする
  • 管理者だけが他のユーザーをデータベースから削除できるようにする

また、以下のような機能の実装には、「誰がログインしているのか」という情報が必須となります。

  • 他のユーザーをフォローする機能
  • 自分のフィード一覧

Sessionsコントローラ

HTTPはステートレスなプロトコルである

「ステートレス」というのは、「状態を持たない」という意味です。「状態を持たない」という文面には、以下の事柄が含意されます。

  • HTTPのリクエストは、1つ1つが独立したものとして捉えられる
  • 以前のリクエストの情報は全く利用できないように構築されている

上述の特性ゆえに、HTTPというプロトコルの枠組み内には、「Webブラウザで特定のページから別のページに移動した場合にユーザーのIDを保持する」ための手段は全くありません。そうした機能が欲しいならば、HTTPの枠外で準備する必要があるのです。

HTTPの枠外でユーザーのログイン情報を保持する - セッション

ユーザー端末のWebブラウザとWebアプリケーションのサーバーの間では、HTTPとは別個に、セッションと呼ばれる半永久的な接続が設定されます。セッションに関する仕組みは、RailsをはじめとしたWebアプリケーションの側で実装されます。

セッションはHTTPとは階層が異なる(HTTPより上位の階層に属する)アプリケーションの機能なので、HTTPの枠外でユーザーのIDを保持する機能を実現することができます。

余談 - 郵便チェスで説明するHTTPとセッションの関係

Railsチュートリアルの本文では、訳注として、郵便将棋の事例が紹介されています。その注釈を見て私が思い出したのは「郵便チェス」というゲームの存在です。「HTTPとセッションの関係は、郵便システムと郵便チェスの関係にたとえることができる」というのは目から鱗でした。

郵便チェスにおいて、HTTPに相当するのは郵便システム、セッションに相当するのは郵便はがきに書かれた本文です。重要なポイントは以下になります。

  • 郵便チェスにおいては、郵便配達人がチェスのルールや対局の進行状況、対局の内容について知っている必要がない(ステートレス)
  • 対局者相互が盤の状態を保持していれば郵便チェスは成立する(セッション)

Cookieとは

Cookieは、Railsその他多くのWebアプリケーションにおいて、セッション機能を実現するために一般に使われる技術です。Cookieの実体は、ユーザーのWebブラウザに保存される小さなテキストファイルです。

Cookieの内容は、あるページから別のページに移動したときにも破棄されません。よって、Cookieを利用することにより、「Webブラウザで特定のページから別のページに移動した場合にユーザーのIDを保持する」という要求を達成することができます。Webアプリケーションは、Cookieの内容を用いて、例えばログイン中のユーザーが所有する情報をRDBから取り出すことができます。

セッションに関するRailsのメソッド

sessionメソッド

memcachedというツールを基盤技術として、オンメモリでセッション情報を保持するためのメソッドです。sessionメソッドにより生成されたセッション情報は、Webブラウザを閉じると自動的に破棄されます。

なお、「オンメモリ」の裏では、実は結構複雑な仕組みが動いています。そのあたりの仕組みに興味があるのであれば、@zettaittenaniさんのRails の session を完全に理解した - Qiita読んでみるのがいいかと思います。

cookiesメソッド

ファイルとしてのCookieを用いた、sessionメソッドによるものより生存期間が長いセッションを生成するメソッドです。Railsチュートリアルに置いては、今後の学習内容となります。

RESTfulなセッション

セッション情報についても、ユーザー情報と同様にRESTfulなリソースとしてモデリングすることが可能です。具体的には、以下のような考え方になります。

  • ログインページでは、newで新しいセッションを生成・保存する
  • ログアウトするとdestroyでセッションを削除する

「情報の取り扱い方法に共通性をもたせることができる」というのは便利です。実際に、この後セッション機能を実装するために、Usersコントローラと同様のSessionsコントローラを実装していきます。

一方で、セッション情報とユーザー情報ではデータの保存場所が異なります。

  • ユーザー情報を永続化したものはサーバー側のRDBに保存される
  • セッション情報を永続化したものはクライアント側のCookieに保存される

Sessionsコントローラ

Railsにおいて、セッション情報をRESTfulなリソースとして実装する…となるとまず必要になるのはコントローラですね。

Sessionsコントローラに必要となる動作は以下のとおりです。

  • ログイン情報入力フォームを処理するnewアクション
  • newからPOSTリクエストを受け、実際のログイン(セッション生成)処理を行うcreateアクション
  • DELETEリクエストを受け、実際のログアウト(セッション破棄)処理を行うdestroyアクション

セッション情報については、RDB等が介在するわけではないため、Active RecordsライブラリやORM、モデルオブジェクトといった要素は登場しません。代わりに登場するのはActive Modelライブラリです。

Sessionsコントローラの生成

Sessionsコントローラの生成には、例によってrails generateコマンドを使用します。

まずはじめに、newアクションが定義されたSessionsコントローラを生成してみましょう。

# rails generate controller Sessions new

Running via Spring preloader in process 367
      create  app/controllers/sessions_controller.rb
       route  get 'sessions/new'
      invoke  erb
      create    app/views/sessions
      create    app/views/sessions/new.html.erb
      invoke  test_unit
      create    test/controllers/sessions_controller_test.rb
      invoke  helper
      create    app/helpers/sessions_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/sessions.coffee
      invoke    scss
      create      app/assets/stylesheets/sessions.scss

newアクションに対応するビューには、Railsチュートリアルの図 8.1のような内容のフォームを実装していきます。

なぜrails generatenewのみを生成したのか

rails generateでアクションを生成すると、対応するビューも生成されます。

create    app/views/sessions/new.html.erb

今回実装するSessionsリソースの場合、createdestroyに対応するビューは必要ありません。無駄なビューが生成されることを防ぐために、rails generateで生成するのはあえてnewのみにした、ということです。

Sessionsコントローラに対応するルーティングを定義する

今回必要になるのは、sessionsnewcreatedestroyに対するルーティングとなります。それぞれに対して割り付けるエンドポイントとリクエスト、対応する名前付きルートは以下の通りになります。

アクション エンドポイント リクエスト 名前付きルート
sessions#new /login GET login_path
sessions#create /login POST login_path
sessions#destroy /logout DELETE logout_path

config/routes.rbにおける、対応する変更は以下の通りになります。

config/routes.rb
  Rails.application.routes.draw do
    root    'static_pages#home'
    get     '/help',    to: 'static_pages#help'
    get     '/about',   to: 'static_pages#about'
    get     '/contact', to: 'static_pages#contact'
    get     '/signup',  to: 'users#new'
    post    '/signup',  to: 'users#create'
+   get     '/login',   to: 'sessions#new'
+   post    '/login',   to: 'sessions#create'
+   delete  '/logout',  to: 'sessions#destroy'
    resources :users
  end

rails generateにより、上述以外のルーティングも生成されますが、上述以外のルーティングは削除して構わないそうです。

rails routesコマンド

Railsにおいて、現状で定義済みのルーティングの一覧を表示するコマンドです。例えば、現状のsampleアプリケーションで定義されているルーティングの一覧であれば以下のようになります。

# rails routes
   Prefix Verb   URI Pattern               Controller#Action
     root GET    /                         static_pages#home
     help GET    /help(.:format)           static_pages#help
    about GET    /about(.:format)          static_pages#about
  contact GET    /contact(.:format)        static_pages#contact
   signup GET    /signup(.:format)         users#new
          POST   /signup(.:format)         users#create
    login GET    /login(.:format)          sessions#new
          POST   /login(.:format)          sessions#create
   logout DELETE /logout(.:format)         sessions#destroy
    users GET    /users(.:format)          users#index
          POST   /users(.:format)          users#create
 new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
     user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

Sessionsコントローラに対するテストを修正する

rails generateにより、テストの骨格は生成されました。

invoke  test_unit
create    test/controllers/sessions_controller_test.rb

しかしながら、newアクションで発行されるGETリクエストに対するアクセス先は変更する必要があります。具体的には、www.example.com/sessions/newではなく/loginにアクセスするようにします。違いは以下ですね。

  • フルURLではなく、アプリケーションルートを基準にする
  • エンドポイントは/sessions/newではなく/loginである
test/controllers/sessions_controller_test.rb
  require 'test_helper'

  class SessionsControllerTest < ActionDispatch::IntegrationTest
    test "should get new" do
-     get sessions_new_url
+     get login_path
      assert_response :success
    end

  end

演習 - Sessionsコントローラ

1. GET login_pathPOST login_pathとの違いを説明できますか? 少し考えてみましょう。

GETというのは、何らかの永続化されたリソースを読み込むためのリクエストとなります。永続化されたリソース(今回であればセッション情報)には何らの変更も行いません。また、永続化されたリソースの内容は、同じリクエストを何回送出しても同じになります。GET login_pathであれば、sessions#newに割り付けられた静的ページを読み込む動作を行います。

POSTというのは、永続化されたリソースを新たに生成するためのリクエストとなります。永続化されたリソース(今回であればセッション情報)の変更を伴います。また、リクエストを送出するたびに新たなリソースが生成されます。POST login_pathであれば、sessions#createに割り付けられた処理によって新たなセッション情報を生成する動作を行います。

2. Sessionsリソースに関するルーティングだけを表示させてみましょう。現在、いくつのSessionsリソースがあるでしょうか?

# rails routes | grep "sessions"
    login GET    /login(.:format)          sessions#new
          POST   /login(.:format)          sessions#create
   logout DELETE /logout(.:format)         sessions#destroy

Sessionsリソースの数は3つです。newcreatedestroyですね。

ターミナルのパイプ機能を使ってrails routesの実行結果とgrepコマンドを繋ぐことで、Usersリソースに関するルーティングだけを表示させることができます。

# rails routes | grep "users"
   signup GET    /signup(.:format)         users#new
          POST   /signup(.:format)         users#create
    users GET    /users(.:format)          users#index
          POST   /users(.:format)          users#create
 new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
     user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

ログインフォーム

Sessionsリソースに対し、ここまででコントローラとルーティングの定義が完了しました。続いてはビューの定義です。

ログイン失敗時の動作

ログインフォームで入力した情報に誤りがあった場合、エラーメッセージを含むログインページを表示するようにします。このときのログイン画面は、Railsチュートリアルの図 8.2のようなものを目指して実装していきます。

Usersリソースにおけるエラーメッセージは、Active Recordsの機能を使って実装してきました。しかしながら、Sessionsリソースは、Usersリソースの場合と異なり、RDBを扱いません。RDBを扱わないということは、Active Recordsも使っていないということです。Active Recordsの機能が使えないので、エラーを表示するメカニズムは、自分で実装する必要があります。

今回は、フラッシュメッセージを使ってエラーを表示するメカニズムを実装することとします。

Sessionsリソースにモデルオブジェクトは存在しない

Usersリソースとは異なり、Sessionsリソースにモデルオブジェクトは存在しません。よって、@userのようなインスタンス変数に相当するものも存在しません。

form_for(@user)

以上のような書き方をすること、あるいはインスタンス変数に基づく対象リソースの自動判定機能1を使うことはできないのです。

これは何を意味するのか。「リソースの名前と、それに対応するURLを、具体的に指定して渡さなければならない」ということです。

具体的なコードとしては以下の通りになります。

form_for(:session, url: login_path)

「シンボルとオプションハッシュを引数とするメソッド」であることがわかりますね。

実際のログインフォームのコード

実際のログインフォームのコードは以下の様になります。

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 %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

ユーザー登録ページへのリンク

ユーザー登録ページへのリンク部
<p>New user? <%= link_to "Sign up now!", signup_path %></p>

「ユーザー登録ページへのリンクが記述されている」というのは一つのポイントです。「まだ登録されていない新規ユーザーが直接ログインページに到達した場合の配慮」といった配慮は、Webサイト訪問者のユーザー経験の質を高めることに繋がります。

実際にユーザー登録ページを表示してみる

ここまで完了したところで、実際にユーザー登録ページを表示してみます。結果は以下のようになります。

スクリーンショット 2019-10-28 12.45.33.png

現時点では、以下の制限事項があります。

  • アドレスバーに直接 /login とURLを直接入力しなければならない
    • 現時点では、Homeページ他に存在する[Log in]リンクがまだ効かないため
  • 「Log in」ボタンをクリックしても動作しない
    • /login へのPOSTに対する動作がまだ実装されていないため

生成されたログインフォームのHTMLコードは以下のようになります。

<form action="/login" accept-charset="UTF-8" method="post" >
  <input name="utf8" type="hidden" value="✓">
  <input type="hidden" name="authenticity_token" value="I3wZBVVMzlbS5CR3TOMPpgtOYt0YrXJPDqoanRLMQk6DjGW0ebhy8FK6eFOBStDHtBDMjtGCRqVehwxG3b/uBA==">
  <label for="session_email">Email</label>
  <input class="form-control" type="email" name="session[email]" id="session_email">
  <label for="session_password">Password</label>
  <input class="form-control" type="password" name="session[password]" id="session_password" data-kpxc-id="session_password">
  <input type="submit" name="commit" value="Log in" class="btn btn-primary" data-disable-with="Log in">
</form>

このHTMLコードから、POSTメソッドが送出された際のHTTPリクエストからparamsハッシュへの割付けの中身は、以下のような形になることが推測できます。

  • Emailフィールドの内容→params[:session][:email]
  • Passwordメソッドの内容→params[:session][:password]

演習 - ログインフォーム

1. リスト 8.4で定義したフォームで送信すると、Sessionsコントローラのcreateアクションに到達します。Railsはこれをどうやって実現しているでしょうか? 考えてみてください。

ヒント:表 8.1リスト 8.5の1行目に注目してください。

ログイン画面のフォームのHTMLソース
<form action="/login" accept-charset="UTF-8" method="post">
    ....略
</form>
config/routes.rb
Rails.application.routes.draw do
  # ...略
  get     '/login',   to: 'sessions#new'
  post    '/login',   to: 'sessions#create'
  delete  '/logout',  to: 'sessions#destroy'
  # ...略
end

以上のソースコードが要点です。

  • フォームのHTMLソースから、このフォームにsubmitすると、 /login というエンドポイントに対してPOSTリクエストが送出されることがわかる
  • config/routes.rbの記述内容により、 /login というエンドポイントにPOSTリクエストが送出されると、Railsアプリケーションはそのリクエストをsessions#createというアクションで受ける

以上の動作により、「設問で言及されているフォームで送信すると、Sessionsコントローラのcreateアクションに到達する」という動作になるわけです。

ユーザーの検索と認証

ログイン時のセッション機能を実装する上でまずはじめに行うことは、入力が無効な場合の処理を実装することです。ユーザー登録の場合と異なり、実装前に既存のセッション情報が必要とされることはありません。

Sessionsコントローラの最低限の定義

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    def new
    end

    def create
+     render 'new'
    end

    def destroy
    end
  end

以上は、Sessionsコントローラの最低限の定義を記述したコードです。以下3つのアクションの存在が定義されています。

  • newアクション
  • createアクション
  • destroyアクション

また、createアクションにおいては、「アクションを実行するとnewビューが出力される」という動作を実装しています。

現時点におけるparamsハッシュの中身

parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
  ...略
  session: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
    email: user@foobar.com
    password: foobar
  commit: Log in
  controller: sessions
  action: create

paramsハッシュは、中にparams[:session]ハッシュを含む入れ子構造となっています。

paramsハッシュ
{ session: { email: 'user@foobar.com', password: 'foobar' } }
params[:session]ハッシュ
{ email: 'user@foobar.com', password: 'foobar' }
params[:session][:email]     # => "user@foobar.com"
params[:session][:password]  # => "foobar"

認証に必要なすべての情報は、paramsハッシュから取り出すことができます。

ユーザーをデータベースから見つけて検証する

Active Recordのfind_byメソッドと、has_secure_passwordauthenticateメソッドを使って実装します。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # TODO: ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      # TODO: エラーメッセージを作成する
      render 'new'
    end
  end

  def destroy
  end
end

コードの意味

user = User.find_by(email: params[:session][:email].downcase)

フォームから送信されたメールアドレスをキーとして、RDBからユーザー情報を取り出すコードです。

downcaseというのは、有効なメールアドレスが入力されたときに確実にマッチするようにするために用いるメソッドです。過去の実装にて「メールアドレスのアルファベットは全て小文字に変換して保存する」という処理を行ったことに対応します。

user && user.authenticate(params[:session][:password])

ユーザー情報が存在し、パスワードが正しいかどうかを検証するコードです。

&&は論理積を表す演算子です。また、Rubyでは、「nilfalse以外の全てのオブジェクトは、真理値として評価するとtrueになる」という性質があります。そのため、以下の表から、「入力したメールアドレスに対応するユーザー情報が存在し、かつパスワードが一致する場合のみ、この式の真理値はtrueになる」ということになります(パスワードが正しくない場合、authenticateメソッドの戻り値はfalseになることに注意してください)。

User Password a && b
存在しない 任意 (nil && [any object]) == false
有効なユーザー 誤ったパスワード (true && false) == false
有効なユーザー 正しいパスワード (true && gtrue) == false

演習 - ユーザーの検索と認証

Railsコンソールを使って、表 8.2のそれぞれの式が合っているか確かめてみましょう. まずはuser = nilの場合を、次にuser = User.firstとした場合を確かめてみてください。

ヒント: 必ず論理値オブジェクトとなるように、4.2.3で紹介した!!のテクニックを使ってみましょう。例: !!(user && user.authenticate('foobar'))

>> user = nil
=> nil
>> !!(user && user.authenticate('foobar'))
=> false

>> user = User.first
>> !!(user && user.authenticate('foobar'))
=> true

フラッシュメッセージを表示する

前提

Userリソースとは異なり、Sessionリソースは特定のActive Recordオブジェクトに関連付けられていません。そのため、「Active Recordに用意された枠組みを用いてエラーメッセージ表示機能を実装する」という手段は使えません。

今回は、「フラッシュメッセージによってログイン時のエラーメッセージ表示機能を実装する」という方法をとることとします。

実装(誤りあり)

フラッシュメッセージによるエラーメッセージ表示機能を実装します。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    def new
    end

    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user && user.authenticate(params[:session][:password])
        # TODO: ユーザーログイン後にユーザー情報のページにリダイレクトする
      else
-       # TODO: エラーメッセージを作成する
+       flash[:danger] = 'Invalid email/password combination' # XXX:このコードには誤りあり
        render 'new'
      end
    end

    def destroy
    end
  end

ただしこのコードは、あえて誤りがある形で実装されています。このコードの誤りについては、後述「フラッシュのテスト」で修正していきます。

フラッシュメッセージが表示されない

実際にログインに失敗してみます。

スクリーンショット 2019-10-30 8.25.29.png

Railsチュートリアルの記述では、ここでフラッシュメッセージが表示されるはずです。しかしながら、フラッシュメッセージは表示されません。

何が原因かといいますと、app/views/layouts/application.html.erbの記述ミスが原因です。

app/views/layouts/application.html.erb
        ...略
        <% flash.each do |message_type, message| %>
-         <% content_tag(:div, message, class: "alert alert-#{message_type}") %>
+         <%= content_tag(:div, message, class: "alert alert-#{message_type}") %>
        <% end %>
        ...略

埋め込みRubyでcontent_tagメソッドを書く場合、書き出しは<%ではなく<%=でなければなりません。「HTMLとしてのレンダリングを含むから」というのが理由ですね。

ログインページを抜けてもフラッシュメッセージが表示されたまま

(このままでは学習が先に進まないので、ひとまず)先ほどの不具合をfixした上で、改めてログインに失敗してみます。

スクリーンショット 2019-10-30 8.34.57.png

今度はフラッシュメッセージがきちんと表示されました。

ここでHomeページヘのリンクをクリックしてみましょう。

スクリーンショット 2019-10-30 8.48.30.png

Homeページでもフラッシュメッセージが表示されたままです。これは想定した動作ではないですね。

フラッシュのテスト

想定した動作ではない。「エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く」というルールが適用される場面です。早速、ログインフォームの送信についての簡単な統合テストを書いてみます。

テストの生成

# rails generate integration_test users_login
Running via Spring preloader in process 548
      invoke  test_unit
      create    test/integration/users_login_test.rb

統合テストなので、テストを生成するコマンドはrails generate integration_testです。

実際のテストコードの記述とその内容

test/integration/users_login_test.rb
class UsersLoginTest < ActionDispatch::IntegrationTest
  test "login with invalid information" do
    get login_path
    assert_templete 'sessions/new'
    post login_path, params: { session: { email: "", password: ""} }
    assert_templete 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
end

このテストコードでは、以下の事柄についてテストを行っています。

  • login_pathに対してGETリクエストを実行した場合に、sessions/newテンプレートが正しく返ってくること
  • login_pathに対して正しくないメールアドレスとパスワードの組み合わせでPOSTリクエストを実行した場合に、以下の条件がすべて満たされていること
    • sessions/newテンプレートが正しく返ってくること
    • flash変数が空でないこと
  • さらにその後に別のエンドポイント(この場合はroot_path)にGETリクエストを実行した場合に、flash変数が空であること

テストを実行してみる、そして通らない

rails testの引数にファイルパスを与えれば、指定したテストファイルのみに対してテストを実行することができます。今回は、rails test test/integration/users_login_test.rbとしてテストを実行してみましょう。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 645
Started with run options --seed 43137

 FAIL["test_login_with_invalid_information", UsersLoginTest, 1.3804758000042057]
 test_login_with_invalid_information#UsersLoginTest (1.38s)
        Expected false to be truthy.
        test/integration/users_login_test.rb:11:in `block in <class:UsersLoginTest>'

  1/1: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.38317s
1 tests, 4 assertions, 1 failures, 0 errors, 0 skips

現状でテストは通りません。テストが通らなかった場所はtest/integration/users_login_test.rb:11とあります。

test/integration/users_login_test.rb
assert flash.empty?  # 11行目

「Homeページでもフラッシュメッセージが表示されたまま」という不具合にきちんと対応していますね。

テストが通るようにする

このテストが通るようにするには、app/controllers/sessions_controller.rbflashflash_nowに変更すればOKです。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    def new
    end

    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user && user.authenticate(params[:session][:password])
        # TODO: ユーザーログイン後にユーザー情報のページにリダイレクトする
      else
-       flash[:danger] = 'Invalid email/password combination' # XXX:このコードには誤りあり
+       flash.now[:danger] = 'Invalid email/password combination'
        render 'new'
      end
    end

    def destroy
    end
  end

改めてテストを実行してみます。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 658
Started with run options --seed 26869

  1/1: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.80917s
1 tests, 4 assertions, 0 failures, 0 errors, 0 skips

今度こそテストが通りました。

flash.nowとは

flash.nowとは、「次にアクションが実行されるまでの間に限り、フラッシュメッセージが表示されるようにする」という動作をするメソッドです。

  • flash.nowの直後にrenderが実行されると、フラッシュメッセージが表示された状態のHTMLが生成される
    • renderはアクションではないため、flash.nowの生存条件を満たす
  • flash.nowの後にgetredirect_toが実行されると、以降フラッシュメッセージは表示されなくなる
    • getredirect_toはアクションであり、flash.nowの生存条件を満たさない

余談 - 「フラッシュメッセージがHTMLとして正しく表示されているか」のテストを追加する

先ほどの「フラッシュメッセージがHTMLとして正しく表示されていない」というのも、「エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く」というルールが適用される場面ですね。実際にテストを書いてみます。

test/integration/users_login_test.rb
  require 'test_helper'

  class UsersLoginTest < ActionDispatch::IntegrationTest
    test "login with invalid information" do
      get login_path
      assert_template 'sessions/new'
      post login_path, params: { session: { email: "", password: ""} }
      assert_template 'sessions/new'
      assert_not flash.empty?
+     assert_select 'div.alert-danger'
      get root_path
      assert flash.empty?
    end
  end

テストが通らない場合

app/views/layouts/application.html.erb
  <!DOCTYPE html>
  <html>
    ...略

    <body>
      <%= render 'layouts/header' %>
      <div class="container">
        <% flash.each do |message_type, message| %>
+         <% content_tag(:div, message, class: "alert alert-#{message_type}") %>
        <% end %>
        ...略
      </div>
    </body>
  </html>

rails test test/integration/users_login_test.rbを実行してみます。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 684
Started with run options --seed 58388

 FAIL["test_login_with_invalid_information", UsersLoginTest, 2.4125660999998217]
 test_login_with_invalid_information#UsersLoginTest (2.41s)
        Expected at least 1 element matching "div.alert-danger", found 0..
        Expected 0 to be >= 1.
        test/integration/users_login_test.rb:10:in `block in <class:UsersLoginTest>'

  1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.42038s
1 tests, 4 assertions, 1 failures, 0 errors, 0 skips

テストは通りません。

テストログには以下のようにあります。

Expected at least 1 element matching "div.alert-danger", found 0..
Expected 0 to be >= 1.
test/integration/users_login_test.rb:10:in `block in <class:UsersLoginTest>'

alert-dangerというクラスが定義されたdiv要素が存在しない、という意味ですね。ソースの不具合をきちんと捉えてくれています。

テストが通る場合

app/views/layouts/application.html.erb
  <!DOCTYPE html>
  <html>
    ...略

    <body>
      <%= render 'layouts/header' %>
      <div class="container">
        <% flash.each do |message_type, message| %>
-         <% content_tag(:div, message, class: "alert alert-#{message_type}") %>
+         <%= content_tag(:div, message, class: "alert alert-#{message_type}") %>
        <% end %>
        ...略
      </div>
    </body>
  </html>

改めてrails test test/integration/users_login_test.rbを実行してみます。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 697
Started with run options --seed 30877

  1/1: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.92592s
1 tests, 5 assertions, 0 failures, 0 errors, 0 skips

今度こそテストが通りました。

演習 - フラッシュのテスト

1. 8.1.4の処理の流れが正しく動いているかどうか、ブラウザで確認してみてください。特に、flashがうまく機能しているかどうか、フラッシュメッセージの表示後に違うページに移動することを忘れないでください。

まず、 /login をブラウザで開いてみます。

スクリーンショット 2019-10-30 13.17.31.png

現時点でフラッシュメッセージは表示されていません。Email・Password、いずれも入力せずに「Log in」ボタンをクリックしてみます。

スクリーンショット 2019-10-30 13.19.59.png

「Invalid email/password combination」というエラーメッセージを含むフラッシュメッセージが表示されました。続いて、Homeへのリンクをクリックしてみましょう。

スクリーンショット 2019-10-30 13.23.17.png

フラッシュメッセージが表示されなくなりました。想定通りの挙動です。

以下はrails serverのログです。

Started GET "/login" ...略
Processing by SessionsController#new as HTML
  Rendering sessions/new.html.erb within layouts/application
  ...略
Completed 200 OK in 672ms (Views: 638.4ms | ActiveRecord: 0.0ms)


Started POST "/login" ...略
Processing by SessionsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"GnMb9uYd/DvOG6bQmzX65PzE480s5TCxzTRqk0W8mxq6g2dHyulAnU5F+vRWnCWFQ5pNnuXKBFudGXxIis83UA==", "session"=>{"email"=>"", "password"=>"[FILTERED]"}, "commit"=>"Log in"}
  User Load (5.9ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", ""], ["LIMIT", 1]]
  Rendering sessions/new.html.erb within layouts/application
  ...略
Completed 200 OK in 770ms (Views: 793.9ms | ActiveRecord: 5.9ms)


Started GET "/" ...略
Processing by StaticPagesController#home as HTML
  Rendering static_pages/home.html.erb within layouts/application
  ...略
Completed 200 OK in 539ms (Views: 491.2ms | ActiveRecord: 0.0ms)

ポイントは以下です。

  • 以下3つのリクエストが送出されている
    • GET "/login"…最初にログイン画面を表示した際
    • POST "/login"…ログイン「Log in」ボタンをクリックした際
    • GET "/"…ログイン失敗後にHomeへのリンクをクリックした際
  • POSTリクエストの結果が以下のようになっている
    • sessions/new.html.erbのレンダリングが行われている
    • sessions/new.html.erbのレンダリングに際し、別のHTTPリクエストが送出されていない
    • 302 Foundで終わっていない

  1. 当該例でいえば、「このフォームのactionは、/usersというURLに対するPOST動作である」というのが自動で判定されます。 

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1