Back to Repositories

Testing Authentication Failure Handling in Devise

This test suite examines the failure handling functionality in Devise’s authentication system, focusing on various redirect scenarios, HTTP responses, and authentication failures. It verifies proper error handling, flash messages, and authentication redirects across different contexts.

Test Coverage Overview

The test suite provides comprehensive coverage of Devise’s FailureApp functionality, examining redirect behaviors, HTTP responses, and authentication failure scenarios.

  • Tests various redirect scenarios including default locations, subdomains, and relative URL roots
  • Verifies HTTP status codes and response formats (HTML, JSON, XML)
  • Covers authentication failure messages and i18n translations
  • Tests XHR requests and WWW-authenticate header handling

Implementation Analysis

The testing approach uses ActiveSupport::TestCase with multiple test contexts and mock objects to simulate different failure scenarios.

Key patterns include:
  • Custom failure app implementations for specific test cases
  • Environment parameter manipulation for request simulation
  • Configuration swapping for testing different Devise settings
  • Mock objects for testing routing and URL helper scenarios

Technical Details

Testing tools and configuration:

  • Minitest framework with ActiveSupport::TestCase
  • Mock objects and OpenStruct for request simulation
  • Custom FailureApp implementations for specific test cases
  • ActionDispatch::Request for HTTP request handling
  • I18n integration for localization testing

Best Practices Demonstrated

The test suite demonstrates excellent testing practices through comprehensive coverage and well-structured test organization.

  • Isolated test cases with clear contexts
  • Thorough edge case coverage
  • Proper use of setup and teardown through environment manipulation
  • Clear test naming conventions
  • Effective use of mock objects and stubs

heartcombo/devise

test/failure_app_test.rb

            
# frozen_string_literal: true

require 'test_helper'
require 'ostruct'

class FailureTest < ActiveSupport::TestCase
  class RootFailureApp < Devise::FailureApp
    def fake_app
      Object.new
    end
  end

  class FailureWithSubdomain < RootFailureApp
    routes = ActionDispatch::Routing::RouteSet.new

    routes.draw do
      scope subdomain: 'sub' do
        root to: 'foo#bar'
      end
    end

    include routes.url_helpers
  end

  class FailureWithI18nOptions < Devise::FailureApp
    def i18n_options(options)
      options.merge(name: 'Steve')
    end
  end

  class FailureWithoutRootPath < Devise::FailureApp
    class FakeURLHelpers
    end

    class FakeRoutesWithoutRoot
      def url_helpers
        FakeURLHelpers.new
      end
    end

    class FakeAppWithoutRootPath
      def routes
        FakeRoutesWithoutRoot.new
      end
    end

    def main_app
      FakeAppWithoutRootPath.new
    end
  end

  class FakeEngineApp < Devise::FailureApp
    class FakeEngine
      def new_user_on_engine_session_url _
        '/user_on_engines/sign_in'
      end
    end

    def main_app
      raise 'main_app router called instead of fake_engine'
    end

    def fake_engine
      @fake_engine ||= FakeEngine.new
    end
  end

  class RequestWithoutFlashSupport < ActionDispatch::Request
    undef_method :flash
  end

  def self.context(name, &block)
    instance_eval(&block)
  end

  def call_failure(env_params = {})
    env = {
      'REQUEST_URI' => 'http://test.host/',
      'HTTP_HOST' => 'test.host',
      'REQUEST_METHOD' => 'GET',
      'warden.options' => { scope: :user },
      'action_dispatch.request.formats' => Array(env_params.delete('formats') || Mime[:html]),
      'rack.input' => "",
      'warden' => OpenStruct.new(message: nil)
    }.merge!(env_params)

    # Passing nil for action_dispatch.request.formats prevents the default from being used in Rails 5, need to remove it
    if env.has_key?('action_dispatch.request.formats') && env['action_dispatch.request.formats'].nil?
      env.delete 'action_dispatch.request.formats' unless env['action_dispatch.request.formats']
    end

    @response = (env.delete(:app) || Devise::FailureApp).call(env).to_a
    @request  = (env.delete(:request_klass) || ActionDispatch::Request).new(env)
  end

  context 'When redirecting' do
    test 'returns to the default redirect location' do
      call_failure
      assert_equal 302, @response.first
      assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
      assert_equal 'http://test.host/users/sign_in', @response.second['Location']
    end

    test 'returns to the default redirect location considering subdomain' do
      call_failure('warden.options' => { scope: :subdomain_user })
      assert_equal 302, @response.first
      assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
      assert_equal 'http://sub.test.host/subdomain_users/sign_in', @response.second['Location']
    end

    test 'returns to the default redirect location for wildcard requests' do
      call_failure 'action_dispatch.request.formats' => nil, 'HTTP_ACCEPT' => '*/*'
      assert_equal 302, @response.first
      assert_equal 'http://test.host/users/sign_in', @response.second['Location']
    end

    test 'returns to the root path if no session path is available' do
      swap Devise, router_name: :fake_app do
        call_failure app: RootFailureApp
        assert_equal 302, @response.first
        assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
        assert_equal 'http://test.host/', @response.second['Location']
      end
    end

    test 'returns to the root path even when it\'s not defined' do
      call_failure app: FailureWithoutRootPath
      assert_equal 302, @response.first
      assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
      assert_equal 'http://test.host/', @response.second['Location']
    end

    test 'returns to the root path considering subdomain if no session path is available' do
      swap Devise, router_name: :fake_app do
        call_failure app: FailureWithSubdomain
        assert_equal 302, @response.first
        assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
        assert_equal 'http://sub.test.host/', @response.second['Location']
      end
    end

    test 'returns to the default redirect location considering the router for supplied scope' do
      call_failure app: FakeEngineApp, 'warden.options' => { scope: :user_on_engine }
      assert_equal 302, @response.first
      assert_equal 'You need to sign in or sign up before continuing.', @request.flash[:alert]
      assert_equal 'http://test.host/user_on_engines/sign_in', @response.second['Location']
    end

    if Rails.application.config.respond_to?(:relative_url_root)
      test 'returns to the default redirect location considering the relative url root' do
        swap Rails.application.config, relative_url_root: "/sample" do
          call_failure
          assert_equal 302, @response.first
          assert_equal 'http://test.host/sample/users/sign_in', @response.second['Location']
        end
      end

      test 'returns to the default redirect location considering the relative url root and subdomain' do
        swap Rails.application.config, relative_url_root: "/sample" do
          call_failure('warden.options' => { scope: :subdomain_user })
          assert_equal 302, @response.first
          assert_equal 'http://sub.test.host/sample/subdomain_users/sign_in', @response.second['Location']
        end
      end
    end

    if Rails.application.config.action_controller.respond_to?(:relative_url_root)
      test "returns to the default redirect location considering action_controller's relative url root" do
        swap Rails.application.config.action_controller, relative_url_root: "/sample" do
          call_failure
          assert_equal 302, @response.first
          assert_equal 'http://test.host/sample/users/sign_in', @response.second['Location']
        end
      end

      test "returns to the default redirect location considering action_controller's relative url root and subdomain" do
        swap Rails.application.config.action_controller, relative_url_root: "/sample" do
          call_failure('warden.options' => { scope: :subdomain_user })
          assert_equal 302, @response.first
          assert_equal 'http://sub.test.host/sample/subdomain_users/sign_in', @response.second['Location']
        end
      end
    end

    test 'uses the proxy failure message as symbol' do
      call_failure('warden' => OpenStruct.new(message: :invalid))
      assert_equal 'Invalid Email or password.', @request.flash[:alert]
      assert_equal 'http://test.host/users/sign_in', @response.second["Location"]
    end

    test 'supports authentication_keys as a Hash for the flash message' do
      swap Devise, authentication_keys: { email: true, login: true } do
        call_failure('warden' => OpenStruct.new(message: :invalid))
        assert_equal 'Invalid Email, Login or password.', @request.flash[:alert]
      end
    end

    test 'uses custom i18n options' do
      call_failure('warden' => OpenStruct.new(message: :does_not_exist), app: FailureWithI18nOptions)
      assert_equal 'User Steve does not exist', @request.flash[:alert]
    end

    test 'respects the i18n locale passed via warden options when redirecting' do
      call_failure('warden' => OpenStruct.new(message: :invalid), 'warden.options' => { locale: :"pt-BR" })

      assert_equal 'Email ou senha inválidos.', @request.flash[:alert]
      assert_equal 'http://test.host/users/sign_in', @response.second["Location"]
    end

    test 'uses the proxy failure message as string' do
      call_failure('warden' => OpenStruct.new(message: 'Hello world'))
      assert_equal 'Hello world', @request.flash[:alert]
      assert_equal 'http://test.host/users/sign_in', @response.second["Location"]
    end

    test 'set content type to default text/html' do
      call_failure
      assert_equal 'text/html; charset=utf-8', @response.second['Content-Type']
    end

    test 'set up a default message' do
      call_failure
      if Devise::Test.rails71_and_up?
        assert_empty @response.last.body
      else
        assert_match(/You are being/, @response.last.body)
        assert_match(/redirected/, @response.last.body)
        assert_match(/users\/sign_in/, @response.last.body)
      end
    end

    test 'works for any navigational format' do
      swap Devise, navigational_formats: [:json] do
        call_failure('formats' => Mime[:json])
        assert_equal 302, @response.first
      end
    end

    test 'redirects the correct format if it is a non-html format request' do
      swap Devise, navigational_formats: [:js] do
        call_failure('formats' => Mime[:js])
        assert_equal 'http://test.host/users/sign_in.js', @response.second["Location"]
      end
    end
  end

  context 'For HTTP request' do
    test 'return 401 status' do
      call_failure('formats' => Mime[:json])
      assert_equal 401, @response.first
    end

    test 'return appropriate body for xml' do
      call_failure('formats' => Mime[:xml])
      result = %(<?xml version="1.0" encoding="UTF-8"?>
<errors>
  <error>You need to sign in or sign up before continuing.</error>
</errors>
)
      assert_equal result, @response.last.body
    end

    test 'return appropriate body for json' do
      call_failure('formats' => Mime[:json])
      result = %({"error":"You need to sign in or sign up before continuing."})
      assert_equal result, @response.last.body
    end

    test 'return 401 status for unknown formats' do
      call_failure 'formats' => []
      assert_equal 401, @response.first
    end

    test 'return WWW-authenticate headers if model allows' do
      call_failure('formats' => Mime[:json])
      assert_equal 'Basic realm="Application"', @response.second["WWW-Authenticate"]
    end

    test 'does not return WWW-authenticate headers if model does not allow' do
      swap Devise, http_authenticatable: false do
        call_failure('formats' => Mime[:json])
        assert_nil @response.second["WWW-Authenticate"]
      end
    end

    test 'works for any non navigational format' do
      swap Devise, navigational_formats: [] do
        call_failure('formats' => Mime[:html])
        assert_equal 401, @response.first
      end
    end

    test 'uses the failure message as response body' do
      call_failure('formats' => Mime[:xml], 'warden' => OpenStruct.new(message: :invalid))
      assert_match '<error>Invalid Email or password.</error>', @response.third.body
    end

    test 'respects the i18n locale passed via warden options when responding to HTTP request' do
      call_failure('formats' => Mime[:json], 'warden' => OpenStruct.new(message: :invalid), 'warden.options' => { locale: :"pt-BR" })

      assert_equal %({"error":"Email ou senha inválidos."}), @response.third.body
    end

    context 'on ajax call' do
      context 'when http_authenticatable_on_xhr is false' do
        test 'dont return 401 with navigational formats' do
          swap Devise, http_authenticatable_on_xhr: false do
            call_failure('formats' => Mime[:html], 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')
            assert_equal 302, @response.first
            assert_equal 'http://test.host/users/sign_in', @response.second["Location"]
          end
        end

        test 'dont return 401 with non navigational formats' do
          swap Devise, http_authenticatable_on_xhr: false do
            call_failure('formats' => Mime[:json], 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')
            assert_equal 302, @response.first
            assert_equal 'http://test.host/users/sign_in.json', @response.second["Location"]
          end
        end
      end

      context 'when http_authenticatable_on_xhr is true' do
        test 'return 401' do
          swap Devise, http_authenticatable_on_xhr: true do
            call_failure('formats' => Mime[:html], 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')
            assert_equal 401, @response.first
          end
        end

        test 'skip WWW-Authenticate header' do
          swap Devise, http_authenticatable_on_xhr: true do
            call_failure('formats' => Mime[:html], 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest')
            assert_nil @response.second['WWW-Authenticate']
          end
        end
      end
    end
  end

  context 'With recall' do
    test 'calls the original controller if invalid email or password' do
      env = {
        "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in" },
        "devise.mapping" => Devise.mappings[:user],
        "warden" => stub_everything
      }
      call_failure(env)
      assert_includes @response.third.body, '<h2>Log in</h2>'
      assert_includes @response.third.body, 'Invalid Email or password.'
    end

    test 'calls the original controller if not confirmed email' do
      env = {
        "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in", message: :unconfirmed },
        "devise.mapping" => Devise.mappings[:user],
        "warden" => stub_everything
      }
      call_failure(env)
      assert_includes @response.third.body, '<h2>Log in</h2>'
      assert_includes @response.third.body, 'You have to confirm your email address before continuing.'
    end

    test 'calls the original controller if inactive account' do
      env = {
        "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in", message: :inactive },
        "devise.mapping" => Devise.mappings[:user],
        "warden" => stub_everything
      }
      call_failure(env)
      assert_includes @response.third.body, '<h2>Log in</h2>'
      assert_includes @response.third.body, 'Your account is not activated yet.'
    end

    if Rails.application.config.respond_to?(:relative_url_root)
      test 'calls the original controller with the proper environment considering the relative url root' do
        swap Rails.application.config, relative_url_root: "/sample" do
          env = {
            "warden.options" => { recall: "devise/sessions#new", attempted_path: "/sample/users/sign_in"},
            "devise.mapping" => Devise.mappings[:user],
            "warden" => stub_everything
          }
          call_failure(env)
          assert_includes @response.third.body, '<h2>Log in</h2>'
          assert_includes @response.third.body, 'Invalid Email or password.'
          assert_equal '/sample', @request.env["SCRIPT_NAME"]
          assert_equal '/users/sign_in', @request.env["PATH_INFO"]
        end
      end
    end

    test 'respects the i18n locale passed via warden options when recalling original controller' do
      env = {
        "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in", locale: :"pt-BR" },
        "devise.mapping" => Devise.mappings[:user],
        "warden" => stub_everything
      }
      call_failure(env)

      assert_includes @response.third.body, '<h2>Log in</h2>'
      assert_includes @response.third.body, 'Email ou senha inválidos.'
    end

    # TODO: remove conditional/else when supporting only responders 3.1+
    if ActionController::Responder.respond_to?(:error_status=)
      test 'respects the configured responder `error_status` for the status code' do
        swap Devise.responder, error_status: :unprocessable_entity do
          env = {
            "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in" },
            "devise.mapping" => Devise.mappings[:user],
            "warden" => stub_everything
          }
          call_failure(env)

          assert_equal 422, @response.first
          assert_includes @response.third.body, 'Invalid Email or password.'
        end
      end

      test 'respects the configured responder `redirect_status` if the recall app returns a redirect status code' do
        swap Devise.responder, redirect_status: :see_other do
          env = {
            "warden.options" => { recall: "devise/registrations#cancel", attempted_path: "/users/cancel" },
            "devise.mapping" => Devise.mappings[:user],
            "warden" => stub_everything
          }
          call_failure(env)

          assert_equal 303, @response.first
        end
      end
    else
      test 'uses default hardcoded responder `error_status` for the status code since responders version does not support configuring it' do
        env = {
          "warden.options" => { recall: "devise/sessions#new", attempted_path: "/users/sign_in" },
          "devise.mapping" => Devise.mappings[:user],
          "warden" => stub_everything
        }
        call_failure(env)

        assert_equal 200, @response.first
        assert_includes @response.third.body, 'Invalid Email or password.'
      end

      test 'users default hardcoded responder `redirect_status` for the status code since responders version does not support configuring it' do
        env = {
          "warden.options" => { recall: "devise/registrations#cancel", attempted_path: "/users/cancel" },
          "devise.mapping" => Devise.mappings[:user],
          "warden" => stub_everything
        }
        call_failure(env)

        assert_equal 302, @response.first
      end
    end
  end

  context "Lazy loading" do
    test "loads" do
      assert_equal "yes it does", Devise::FailureApp.new.lazy_loading_works?
    end
  end

  context "Without Flash Support" do
    test "returns to the default redirect location without a flash message" do
      call_failure request_klass: RequestWithoutFlashSupport
      assert_equal 302, @response.first
      assert_equal 'http://test.host/users/sign_in', @response.second['Location']
    end
  end
end