大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【Rspec】devise + slack-omniauthのテスト方法

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

今回はPFにあたり詰まった「devise + slack-omniauthのテスト方法」を紹介します。

slackログインの正式な方法についてはQiitaにてあげる予定ですが、記憶が新しいうちに紹介しておきたく、、、!

では早速解説していきます。

テスト内容

テスト内容について触れておきます。

slackログインの認証方法としてdeviseを使用しています。なので認証時にはコールバックコントローラに返すように指定します。

# app/controllers/users/omniauth_callbacks_controller.rb
module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    skip_before_action :authenticate_user!
    def slack

      user_info = get_user_info(request.env['omniauth.strategy'])
      @user = User.from_omniauth(request.env['omniauth.auth'], user_info)

      if @user.persisted?
        sign_in_and_redirect @user, event: :authentication
      else
        redirect_to root_path
      end
    end

    def failure
      flash[:alert] = 'Slack認証に失敗しました。'
      redirect_to root_path
    end
  end
end

deviseのSNS認証はメジャーかと思いますのでコードの見た目はほぼ同じかと思います。認証成功時にはslackメソッドがリダイレクトされ、逆に認証ができなかった場合failureメソッドにリダイレクトします。

テストする内容としては下記2点のみです。

  • [ ] 認証成功時にslackメソッドにリダイレクトし、意図したページにアクセスすること
  • [ ] 認証失敗時にfailureメソッドに失敗し、トップページにアクセスすること

長文になりますが補足で説明します。Rspecとはあまり関係がありませんが、Rspecのテストの方法に影響があるので念のため説明させてください。

上述したように、コールバックコントローラの見た目はほぼ同じですが、1点違う箇所があります。

user_info = get_user_info(request.env['omniauth.strategy'])
@user = User.from_omniauth(request.env['omniauth.auth'], user_info)

get_user_infoでログインしたユーザー情報にアクセスし、JSON形式で返却するようにしています。

def get_user_info(request)
  request.access_token.user_token.get('/api/users.identity').parsed
end

基本的にdeviseでのSNS認証時にrequest.env['omniauth.auth']という値は使う必要がありません。

例えば、QiitaでLGMTが多い下記記事のcallbackメソッドの例は以下のようになります。

https://qiita.com/kazuooooo/items/47e7d426cbb33355590e

# common callback method
def callback_for(provider)
  @user = User.from_omniauth(request.env["omniauth.auth"])
  if @user.persisted?
    sign_in_and_redirect @user, event: :authentication #this will throw if @user is not activated
    set_flash_message(:notice, :success, kind: "#{provider}".capitalize) if is_navigational_format?
  else
    session["devise.#{provider}_data"] = request.env["omniauth.auth"].except("extra")
    redirect_to new_user_registration_url
  end
end

@user = User.from_omniauth(request.env["omniauth.auth"])とあるように、request.env["omniauth.auth"]をのみを使っています。そしてrequest.env["omniauth.auth"]の値を使って、名前だったりユーザーの画像だったりを格納します。上記の記事ではemailのみしか取得していなかったのですが、他の記事を当たると多くの情報を格納するようです。

https://qiita.com/sakakinn/items/321e6b49c92b9f02f83f

def self.from_omniauth(auth)
  where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
    user.provider = auth.provider
    user.uid = auth.uid
    user.name = auth.name
    user.email = auth.info.email
    user.password = Devise.friendly_token[0, 20] # ランダムなパスワードを作成
    user.image = auth.info.image.gsub("_normal","") if user.provider == "twitter"
    user.image = auth.info.image.gsub("picture","picture?type=large") if user.provider == "facebook"
  end
end

では、なぜ自分のコードではAPIを取得して追加情報の取得が必要なのかというと、slackより返却されるrequest.env["omniauth.auth"]の値が他のSNS認証時に返却されるものとは異なるからです。というよりは返却される情報が少ないと行った方が正しいです。

実はこのことに関する記事はありません。slack-omniauthのgemのGithub上でissueでちょこっと触れられていました。

https://github.com/ginjo/omniauth-slack/issues/10#issuecomment-621560008

この記事で述べている箇所はrequest.env["omniauth.auth"]の値の例です。

{
  "enterprise" : null,
  "ok" : true,
  "team" : {
    "id" : "T0BXXXXXX"
  },
  "authed_user" : {
    "scope" : "identity.basic,identity.email,identity.avatar,identity.team",
    "id" : "U0BXXXXXX",
    "token_type" : "user",
    "access_token" : "xoxp-111111111111-22222222222-3333333333333-fa39d45841fa1daab3a98f945a133d02"
  },
  "app_id" : "A0XXXXXXXXX"
}

他のrequest.env["omniauth.auth"]の値と比べて圧倒的に少ないです。他のrequest.env["omniauth.auth"]の値にはユーザー情報として、ユーザーのアイコン、名前、メールアドレスが含まれるはずなのですが、slackの場合はそれら個人の情報が一切ないです。そのため、追加でユーザー情報を取得しないと、十分なユーザー情報を格納できないのです。

そしてこのissueの中でユーザーへのアクセスはユーザーのアクセストークンを使用して直接取得するように記載されています。

@bot_token = env['omniauth.strategy'].access_token
@user_token = @bot_token.user_token
@user_token.get("/api/users.identity", ...).parsed
# --> response data hash from Slack

これを見て自分もユーザー情報を追加で取得してアイコンなどの追加情報を取得しました。

テストの方法

先にテストコードを紹介してする前に、必要な設定を紹介します。

実はOmniauthのテストにドキュメントがあります。

https://github.com/omniauth/omniauth/wiki/Integration-Testing

必要な設定として、spec/rails_helper.rbに下記を記載して下さい。

# spec/rails_helper.rb
# Turn on "test mode" for OmniAuth
OmniAuth.config.test_mode = true

これによりどうなるのかというと、SNS認証を行う際、一度アプリケーションのブラウザからSNSのサイトの認証画面に移行する必要があります。テストでも同じように認証画面にアクセスすることはあまり好ましくないと思います。毎回テストするたびに認証画面に移行してもし万が一のことがあったら危険ですし、なにより通信超過のためにテストに時間を要します。それを防ぐために上記の設定を行うことで、認証画面への移行をスキップし、そのままコールバックコントローラにリダイレクトしてくれます。

加えて、新規ファイルでconfig/initializers/omniauth.rbを作成します。

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  on_failure { |env| Users::OmniauthCallbacksController.action(:failure).call(env) }
end

この設定が必要な理由は、デフォルトの仕様ですと、認証失敗時の挙動は例外の発生となります。しかし、devise認証ではホーム画面にリダイレクトするように設定していますので例外は発生しません。それを防ぐために、上記の設定が必要となります。

※ 公式では以下のように設定していますが、自分の場合こちらの設定では反映されませんでした。のちほど調査します。

OmniAuth.config.on_failure = Proc.new { |env|
  OmniAuth::FailureEndpoint.new(env).redirect_to_failure
}

設定が終わったところでテストコードを紹介します。最初の検証が失敗時のテストで、次にテストしているのが成功時の検証です。

# spec/system/slack_login_spec.rb
require 'rails_helper'

RSpec.describe "SlackLogin", type: :system do

  before do
    Rails.application.env_config["devise.mapping"] = Devise.mappings[:user] # Devise使用時に必要な記載
    Rails.application.env_config["omniauth.auth"] = set_slack_omniauth # omniauth.authの値を代入
    allow_any_instance_of(ApplicationController).to receive(:get_user_info).and_return(set_user_info) # strategy.authの値を代入
  end

  context 'oauthがinvali_omniauthの場合' do
    before do
      Rails.application.env_config["omniauth.auth"] = set_invalid_omniauth
      visit root_path
      click_on 'Slackログイン'
    end
    it "トップページにリダイレクトされる" do
      expect(current_path).to eq(root_path), 'ルートパスにリダイレクトされていません'
      expect(page).to have_content('Slack認証に失敗しました。'), 'フラッシュメッセージが表示されていません'
    end
  end

  context '初めてアプリにログインする時' do
    before do
      visit root_path
      click_on 'Slackログイン'
    end
    it 'プロフィール新規作成画面にアクセスする' do
      expect(page).to have_content('新規登録完了しました。次にプロフィールを作成してください。'), '意図したフラッシュメッセージが表示されていません'
      expect(current_path).to eq(new_profile_path), 'プロフィール新規作成画面にアクセスしていません'
    end
  end
end

最初に下記の記述です。

Rails.application.env_config["devise.mapping"] = Devise.mappings[:user] # Devise使用時に必要な記載

これは公式の説明にも書いてあります。deviseを使用する場合は必須のようですね。

https://github.com/omniauth/omniauth/wiki/Integration-Testing

公式の記述

before do
  Rails.application.env_config["devise.mapping"] = Devise.mappings[:user] # If using Devise
  Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:twitter]
end

次にモックを導入してテストをしています。

Rails.application.env_config["omniauth.auth"] = set_slack_omniauth # omniauth.authの値を代入
allow_any_instance_of(ApplicationController).to receive(:get_user_info).and_return(set_user_info) # get_user_infoの値を代入

これによりrequest.env["omniauth.auth"]の値とget_user_infoメソッドの返り値をモックかしています。モックファイルは下記に記載します。

# spec/support/omniauth_helper.rb
# TODO: API認証時のテストを追加(未実装)
module OmniauthHelpers
  def set_slack_omniauth
    OmniAuth.config.mock_auth[:slack] =
        OmniAuth::AuthHash.new(
            {
                'provider'  => 'slack',
                'uid'       => 'mock_uid_1234',
                'credentials' => {
                  'token' => 'mock_credentails_token_1234',
                },
            }
        )
  end

  def set_user_info
    user_info = {
      "user"=>
        {"name"=>Faker::Name.name,
        "email"=>Faker::Internet.email,
        "image_192"=>
        "https//image-XXXXXXXXXX.png",},
      "team"=>
        {"id"=>rand(10 ** 19).to_s,
        "name"=>"XXXXXXXXXX",
        "image_34"=>"https//image-XXXXXXXXXX.jpg",
        }
    }
    user_info
  end

  def set_invalid_omniauth
    OmniAuth.config.mock_auth[:slack] = :invalid_credentials
  end
end

ここが他の記事とは異なる点です!

他の記事では追加でユーザー情報を取得することがないために、set_slack_omniauth(つまりrequest.env["omniauth.auth"]の値)の箇所だけしか書いておりません。今回の自分の実装の場合、追加情報でAPIにアクセスしないといけないため、その返却値のJSONをモック化しなければいけなかったのです。モックをよく知らなかった自分にとってはかなり難しかったです、、。

allow_any_instance_of(ApplicationController).to receive(:get_user_info).and_return(set_user_info) # get_user_infoの値を代入

テスト箇所ですが、認証成功時の箇所は極めてシンプルです。

context '初めてアプリにログインする時' do
  before do
    visit root_path
    click_on 'Slackログイン'
  end
  it 'プロフィール新規作成画面にアクセスする' do
    expect(page).to have_content('新規登録完了しました。次にプロフィールを作成してください。'), '意図したフラッシュメッセージが表示されていません'
    expect(current_path).to eq(new_profile_path), 'プロフィール新規作成画面にアクセスしていません'
  end
end

当然アプリによってリダイレクト先は異なりますが、それに合わせてテストを設計するだけです。

失敗時のテストですが、これについては説明させてください。

context 'oauthがinvali_omniauthの場合' do
  before do
    Rails.application.env_config["omniauth.auth"] = set_invalid_omniauth
    visit root_path
    click_on 'Slackログイン'
  end
  it "トップページにリダイレクトされる" do
    expect(current_path).to eq(root_path), 'ルートパスにリダイレクトされていません'
    expect(page).to have_content('Slack認証に失敗しました。'), 'フラッシュメッセージが表示されていません'
  end
end

といっても注目箇所はこちらの部分のみです。

Rails.application.env_config["omniauth.auth"] = set_invalid_omniauth

これはモックファイルで定義していた値を格納します。

def set_invalid_omniauth
  OmniAuth.config.mock_auth[:slack] = :invalid_credentials
end

これを使うことで認証失敗時の挙動をテストしてくれます。公式のomniauthのテスト方法のドキュメントでも同じように紹介しています。

OmniAuth.config.mock_auth[:twitter] = :invalid_credentials

重要なのは認証失敗時のテストでは必ずset_invalid_omniauthを使わないといけないということです。例えば、他の記事では代わりにnilを代入していましたが、それでは意図したものとは異なる挙動を見せます。

実際にnilを代入して試してみました。

Rails.application.env_config["omniauth.auth"] = nil

するとなんとfailureメソッドにリダイレクトされず、成功時と同じようにプロフィール作成画面にリダイレクトしました。これはどういうことかというと、先に代入されているset_slack_omniauthの値が既に格納されており、Rails.application.env_config["omniauth.auth"]nilは反映されないためです。

では、set_slack_omniauthの箇所をコメントアウトしてみると失敗するのではと思い、コメントアウトしてnilの代入箇所のみテストしてみました。するとまた別の挙動を見せました。

failureメソッドにはリダイレクトされずslackメソッドにリダイレクトされましたが、request.env['omniauth.auth']の値に全く設定していない値が格納されていたのです。

> request.env['omniauth.auth']
=> {"provider"=>"default", "uid"=>"1234", "info"=>{"name"=>"Example User"}}

これはどうやらデフォルトのOmniauthのモックの値のようです。

つまり、nilを格納しようとしてもnilは反映されず、デフォルト値が代わりに反映される仕様になっているようです。なのでどうやってもnilは格納できない仕様となっています。他の記事ではnilを代入しているところもありますが、おそらくOmniauthのアップデートのせいで使用できなくなったのではないかと思っています。

以上で、本記事を終わりとします。ありがとうございました!