Back to Repositories

Testing Account Locking Mechanisms in Devise Authentication

This test suite validates the Lockable module functionality in Devise, focusing on account locking mechanisms and user authentication security. It thoroughly tests user account locking, unlocking strategies, and failed authentication attempt handling.

Test Coverage Overview

The test suite provides comprehensive coverage of Devise’s Lockable module functionality:
  • Maximum login attempts configuration and tracking
  • Account locking and unlocking mechanisms
  • Failed authentication attempt counting
  • Time-based and email-based unlock strategies
  • Token-based unlock functionality

Implementation Analysis

The testing approach employs ActiveSupport::TestCase with systematic validation of lockable features:
  • Configuration swapping for different unlock strategies
  • Database state verification for failed attempts
  • Token generation and validation testing
  • Email notification testing for unlock instructions

Technical Details

Testing infrastructure includes:
  • Minitest framework integration
  • Mock mailer setup for email verification
  • Custom user factory methods
  • Dynamic configuration swapping using swap helper
  • Database transaction management

Best Practices Demonstrated

The test suite exemplifies robust testing practices:
  • Isolated test cases with clear setup and teardown
  • Edge case coverage for various unlock strategies
  • Comprehensive validation of security-critical functionality
  • Proper error handling verification
  • Clean separation of concerns in test organization

heartcombo/devise

test/models/lockable_test.rb

            
# frozen_string_literal: true

require 'test_helper'

class LockableTest < ActiveSupport::TestCase
  def setup
    setup_mailer
  end

  test "should respect maximum attempts configuration" do
    user = create_user
    user.confirm
    swap Devise, maximum_attempts: 2 do
      2.times { user.valid_for_authentication?{ false } }
      assert user.reload.access_locked?
    end
  end

  test "should increment failed_attempts on successful validation if the user is already locked" do
    user = create_user
    user.confirm

    swap Devise, maximum_attempts: 2 do
      2.times { user.valid_for_authentication?{ false } }
      assert user.reload.access_locked?
    end

    user.valid_for_authentication?{ true }
    assert_equal 3, user.reload.failed_attempts
  end

  test "should not touch failed_attempts if lock_strategy is none" do
    user = create_user
    user.confirm
    swap Devise, lock_strategy: :none, maximum_attempts: 2 do
      3.times { user.valid_for_authentication?{ false } }
      assert_not user.access_locked?
      assert_equal 0, user.failed_attempts
    end
  end

  test "should read failed_attempts from database when incrementing" do
    user = create_user
    initial_failed_attempts = user.failed_attempts
    same_user = User.find(user.id)

    user.increment_failed_attempts
    same_user.increment_failed_attempts

    assert_equal initial_failed_attempts + 2, user.reload.failed_attempts
  end

  test "reset_failed_attempts! updates the failed attempts counter back to 0" do
    user = create_user(failed_attempts: 3)
    assert_equal 3, user.failed_attempts

    user.reset_failed_attempts!
    assert_equal 0, user.failed_attempts

    user.reset_failed_attempts!
    assert_equal 0, user.failed_attempts
  end

  test "reset_failed_attempts! does not run model validations" do
    user = create_user(failed_attempts: 1)
    user.expects(:after_validation_callback).never

    assert user.reset_failed_attempts!
    assert_equal 0, user.failed_attempts
  end

  test "reset_failed_attempts! does not try to reset if not using failed attempts strategy" do
    admin = create_admin

    assert_not_respond_to admin, :failed_attempts
    assert_not admin.reset_failed_attempts!
  end

  test 'should be valid for authentication with a unlocked user' do
    user = create_user
    user.lock_access!
    user.unlock_access!
    assert user.valid_for_authentication?{ true }
  end

  test "should verify whether a user is locked or not" do
    user = create_user
    assert_not user.access_locked?
    user.lock_access!
    assert user.access_locked?
  end

  test "active_for_authentication? should be the opposite of locked?" do
    user = create_user
    user.confirm
    assert user.active_for_authentication?
    user.lock_access!
    assert_not user.active_for_authentication?
  end

  test "should unlock a user by cleaning locked_at, failed_attempts and unlock_token" do
    user = create_user
    user.lock_access!
    assert_not_nil user.reload.locked_at
    assert_not_nil user.reload.unlock_token

    user.unlock_access!
    assert_nil user.reload.locked_at
    assert_nil user.reload.unlock_token
    assert_equal 0, user.reload.failed_attempts
  end

  test "new user should not be locked and should have zero failed_attempts" do
    assert_not new_user.access_locked?
    assert_equal 0, create_user.failed_attempts
  end

  test "should unlock user after unlock_in period" do
    swap Devise, unlock_in: 3.hours do
      user = new_user
      user.locked_at = 2.hours.ago
      assert user.access_locked?

      Devise.unlock_in = 1.hour
      assert_not user.access_locked?
    end
  end

  test "should not unlock in 'unlock_in' if :time unlock strategy is not set" do
    swap Devise, unlock_strategy: :email do
      user = new_user
      user.locked_at = 2.hours.ago
      assert user.access_locked?
    end
  end

  test "should set unlock_token when locking" do
    user = create_user
    assert_nil user.unlock_token
    user.lock_access!
    assert_not_nil user.unlock_token
  end

  test "should never generate the same unlock token for different users" do
    unlock_tokens = []
    3.times do
      user = create_user
      user.lock_access!
      token = user.unlock_token
      assert_not_includes unlock_tokens, token
      unlock_tokens << token
    end
  end

  test "should not generate unlock_token when :email is not an unlock strategy" do
    swap Devise, unlock_strategy: :time do
      user = create_user
      user.lock_access!
      assert_nil user.unlock_token
    end
  end

  test "should send email with unlock instructions when :email is an unlock strategy" do
    swap Devise, unlock_strategy: :email do
      user = create_user
      assert_email_sent do
        user.lock_access!
      end
    end
  end

  test "doesn't send email when you pass option send_instructions to false" do
    swap Devise, unlock_strategy: :email do
      user = create_user
      assert_email_not_sent do
        user.lock_access! send_instructions: false
      end
    end
  end

  test "sends email when you pass options other than send_instructions" do
    swap Devise, unlock_strategy: :email do
      user = create_user
      assert_email_sent do
        user.lock_access! foo: :bar, bar: :foo
      end
    end
  end

  test "should not send email with unlock instructions when :email is not an unlock strategy" do
    swap Devise, unlock_strategy: :time do
      user = create_user
      assert_email_not_sent do
        user.lock_access!
      end
    end
  end

  test 'should find and unlock a user automatically based on raw token' do
    user = create_user
    raw  = user.send_unlock_instructions
    locked_user = User.unlock_access_by_token(raw)
    assert_equal user, locked_user
    assert_not user.reload.access_locked?
  end

  test 'should return a new record with errors when a invalid token is given' do
    locked_user = User.unlock_access_by_token('invalid_token')
    assert_not locked_user.persisted?
    assert_equal "is invalid", locked_user.errors[:unlock_token].join
  end

  test 'should return a new record with errors when a blank token is given' do
    locked_user = User.unlock_access_by_token('')
    assert_not locked_user.persisted?
    assert locked_user.errors.added?(:unlock_token, :blank)
  end

  test 'should find a user to send unlock instructions' do
    user = create_user
    user.lock_access!
    unlock_user = User.send_unlock_instructions(email: user.email)
    assert_equal user, unlock_user
  end

  test 'should return a new user if no email was found' do
    unlock_user = User.send_unlock_instructions(email: "[email protected]")
    assert_not unlock_user.persisted?
  end

  test 'should add error to new user email if no email was found' do
    unlock_user = User.send_unlock_instructions(email: "[email protected]")
    assert_equal 'not found', unlock_user.errors[:email].join
  end

  test 'should find a user to send unlock instructions by authentication_keys' do
    swap Devise, authentication_keys: [:username, :email] do
      user = create_user
      unlock_user = User.send_unlock_instructions(email: user.email, username: user.username)
      assert_equal user, unlock_user
    end
  end

  test 'should require all unlock_keys' do
    swap Devise, unlock_keys: [:username, :email] do
      user = create_user
      unlock_user = User.send_unlock_instructions(email: user.email)
      assert_not unlock_user.persisted?
      assert unlock_user.errors.added?(:username, :blank)
    end
  end

  test 'should not be able to send instructions if the user is not locked' do
    user = create_user
    assert_not user.resend_unlock_instructions
    assert_not user.access_locked?
    assert_equal 'was not locked', user.errors[:email].join
  end

  test 'should not be able to send instructions if the user if not locked and have username as unlock key' do
    swap Devise, unlock_keys: [:username] do
      user = create_user
      assert_not user.resend_unlock_instructions
      assert_not user.access_locked?
      assert_equal 'was not locked', user.errors[:username].join
    end
  end

  test 'should unlock account if lock has expired and increase attempts on failure' do
    swap Devise, unlock_in: 1.minute do
      user = create_user
      user.confirm

      user.failed_attempts = 2
      user.locked_at = 2.minutes.ago

      user.valid_for_authentication? { false }
      assert_equal 1, user.failed_attempts
    end
  end

  test 'should unlock account if lock has expired on success' do
    swap Devise, unlock_in: 1.minute do
      user = create_user
      user.confirm

      user.failed_attempts = 2
      user.locked_at = 2.minutes.ago

      user.valid_for_authentication? { true }
      assert_equal 0, user.failed_attempts
      assert_nil user.locked_at
    end
  end

  test 'required_fields should contain the all the fields when all the strategies are enabled' do
    swap Devise, unlock_strategy: :both do
      swap Devise, lock_strategy: :failed_attempts do
        assert_equal [
          :failed_attempts,
          :locked_at,
          :unlock_token
        ], Devise::Models::Lockable.required_fields(User)
      end
    end
  end

  test 'required_fields should contain only failed_attempts and locked_at when the strategies are time and failed_attempts are enabled' do
    swap Devise, unlock_strategy: :time do
      swap Devise, lock_strategy: :failed_attempts do
        assert_equal [
          :failed_attempts,
          :locked_at
        ], Devise::Models::Lockable.required_fields(User)
      end
    end
  end

  test 'required_fields should contain only failed_attempts and unlock_token when the strategies are token and failed_attempts are enabled' do
    swap Devise, unlock_strategy: :email do
      swap Devise, lock_strategy: :failed_attempts do
        assert_equal [
          :failed_attempts,
          :unlock_token
        ], Devise::Models::Lockable.required_fields(User)
      end
    end
  end

  test 'should not return a locked unauthenticated message if in paranoid mode' do
    swap Devise, paranoid: :true do
      user = create_user
      user.failed_attempts = Devise.maximum_attempts + 1
      user.lock_access!

      assert_equal :invalid, user.unauthenticated_message
    end
  end

  test 'should return last attempt message if user made next-to-last attempt of password entering' do
    swap Devise, last_attempt_warning: true, lock_strategy: :failed_attempts do
      user = create_user
      user.failed_attempts = Devise.maximum_attempts - 2
      assert_equal :invalid, user.unauthenticated_message

      user.failed_attempts = Devise.maximum_attempts - 1
      assert_equal :last_attempt, user.unauthenticated_message

      user.failed_attempts = Devise.maximum_attempts
      assert_equal :locked, user.unauthenticated_message
    end
  end

  test 'should not return last attempt message if last_attempt_warning is disabled' do
    swap Devise, last_attempt_warning: false, lock_strategy: :failed_attempts do
      user = create_user
      user.failed_attempts = Devise.maximum_attempts - 1
      assert_equal :invalid, user.unauthenticated_message
    end
  end

  test 'should return locked message if user was programatically locked' do
    user = create_user
    user.lock_access!
    assert_equal :locked, user.unauthenticated_message
  end

  test 'unlock_strategy_enabled? should return true for both, email, and time strategies if :both is used' do
    swap Devise, unlock_strategy: :both do
      user = create_user
      assert_equal true, user.unlock_strategy_enabled?(:both)
      assert_equal true, user.unlock_strategy_enabled?(:time)
      assert_equal true, user.unlock_strategy_enabled?(:email)
      assert_equal false, user.unlock_strategy_enabled?(:none)
      assert_equal false, user.unlock_strategy_enabled?(:an_undefined_strategy)
    end
  end

  test 'unlock_strategy_enabled? should return true only for the configured strategy' do
    swap Devise, unlock_strategy: :email do
      user = create_user
      assert_equal false, user.unlock_strategy_enabled?(:both)
      assert_equal false, user.unlock_strategy_enabled?(:time)
      assert_equal true, user.unlock_strategy_enabled?(:email)
      assert_equal false, user.unlock_strategy_enabled?(:none)
      assert_equal false, user.unlock_strategy_enabled?(:an_undefined_strategy)
    end
  end
end