Skip to content

Commit 0a4c668

Browse files
test: add two-factor test infrastructure and integration tests
Add test_otp 2FA method for integration testing with a simple OTP check. Add model tests for TwoFactorAuthenticatable, strategy tests for the base TwoFactor strategy, and end-to-end integration tests covering the full sign-in flow, failure recall, and URL helpers.
1 parent f9f22cf commit 0a4c668

14 files changed

Lines changed: 414 additions & 40 deletions

File tree

test/devise_test.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,47 @@ class DeviseTest < ActiveSupport::TestCase
8686
Devise::CONTROLLERS.delete(:kivi)
8787
end
8888

89+
test 'register_two_factor_method stores config and populates STRATEGIES' do
90+
Devise.register_two_factor_method(:fake_2fa, model: 'devise/models/fake_2fa', strategy: :fake_2fa_strategy)
91+
assert_equal({ model: 'devise/models/fake_2fa', strategy: :fake_2fa_strategy }, Devise.two_factor_method_configs[:fake_2fa])
92+
assert_equal :fake_2fa_strategy, Devise::STRATEGIES[:fake_2fa]
93+
ensure
94+
Devise.two_factor_method_configs.delete(:fake_2fa)
95+
Devise::STRATEGIES.delete(:fake_2fa)
96+
end
97+
98+
test 'register_two_factor_method with controller populates CONTROLLERS' do
99+
Devise.register_two_factor_method(:fake_mgmt, model: 'x', controller: :fake_mgmt)
100+
assert_equal :fake_mgmt, Devise::CONTROLLERS[:fake_mgmt]
101+
ensure
102+
Devise.two_factor_method_configs.delete(:fake_mgmt)
103+
Devise::CONTROLLERS.delete(:fake_mgmt)
104+
end
105+
106+
test 'register_two_factor_method with route populates ROUTES and URL_HELPERS' do
107+
Devise.register_two_factor_method(:fake_rt, model: 'x', route: { fake_rt: [nil, :new] })
108+
assert_equal :fake_rt, Devise::ROUTES[:fake_rt]
109+
assert_equal [nil, :new], Devise::URL_HELPERS[:fake_rt]
110+
ensure
111+
Devise.two_factor_method_configs.delete(:fake_rt)
112+
Devise::ROUTES.delete(:fake_rt)
113+
Devise::URL_HELPERS.delete(:fake_rt)
114+
end
115+
116+
test 'register_two_factor_method rejects unknown options' do
117+
assert_raises(ArgumentError) do
118+
Devise.register_two_factor_method(:bad, model: 'x', unknown: true)
119+
end
120+
end
121+
122+
test 'add_module no longer accepts two_factor option' do
123+
assert_raises(ArgumentError) do
124+
Devise.add_module(:test_mod, two_factor: true)
125+
end
126+
ensure
127+
Devise::ALL.delete(:test_mod)
128+
end
129+
89130
test 'Devise.secure_compare fails when comparing different strings or nil' do
90131
[nil, ""].each do |empty|
91132
assert_not Devise.secure_compare(empty, "something")

test/helpers/devise_helper_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,16 @@ class DeviseHelperTest < Devise::IntegrationTest
4444
assert_have_selector '#error_explanation'
4545
assert_contain "Can't save the user because of 2 errors"
4646
end
47+
48+
test 'two_factor_method_links returns empty string when no other methods' do
49+
resource = mock('resource')
50+
resource.stubs(:enabled_two_factors).returns([:test_two_factor])
51+
52+
helper = Class.new(ActionView::Base) do
53+
include DeviseHelper
54+
end.new(ActionView::LookupContext.new([]), {}, nil)
55+
56+
result = helper.two_factor_method_links(resource, :test_two_factor)
57+
assert_equal '', result
58+
end
4759
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class TwoFactorAuthenticationTest < Devise::IntegrationTest
6+
test 'sign in redirects to two factor challenge when 2FA is enabled' do
7+
user = create_user_with_two_factor(otp_secret: '123456')
8+
9+
visit new_user_with_two_factor_session_path
10+
fill_in 'email', with: user.email
11+
fill_in 'password', with: '12345678'
12+
click_button 'Log In'
13+
14+
assert_not warden.authenticated?(:user_with_two_factor)
15+
assert_equal user.id, session[:devise_two_factor_resource_id]
16+
end
17+
18+
test 'sign in without 2FA enabled proceeds normally' do
19+
user = create_user_with_two_factor(otp_secret: nil)
20+
21+
visit new_user_with_two_factor_session_path
22+
fill_in 'email', with: user.email
23+
fill_in 'password', with: '12345678'
24+
click_button 'Log In'
25+
26+
assert warden.authenticated?(:user_with_two_factor)
27+
assert_nil session[:devise_two_factor_resource_id]
28+
end
29+
30+
test 'password reset with 2FA enabled redirects to two factor challenge' do
31+
user = create_user_with_two_factor(otp_secret: '123456')
32+
raw_token = user.send_reset_password_instructions
33+
34+
visit edit_user_with_two_factor_password_path(reset_password_token: raw_token)
35+
fill_in 'New password', with: 'newpassword123'
36+
fill_in 'Confirm new password', with: 'newpassword123'
37+
click_button 'Change my password'
38+
39+
assert_not warden.authenticated?(:user_with_two_factor)
40+
assert session[:devise_two_factor_resource_id]
41+
end
42+
43+
test 'password reset without 2FA signs in directly' do
44+
user = create_user_with_two_factor(otp_secret: nil)
45+
raw_token = user.send_reset_password_instructions
46+
47+
visit edit_user_with_two_factor_password_path(reset_password_token: raw_token)
48+
fill_in 'New password', with: 'newpassword123'
49+
fill_in 'Confirm new password', with: 'newpassword123'
50+
click_button 'Change my password'
51+
52+
assert warden.authenticated?(:user_with_two_factor)
53+
end
54+
55+
test 'two-factor routes generate correct paths' do
56+
assert_equal '/user_with_two_factors/two_factor/test_otp/new',
57+
user_with_two_factor_new_two_factor_test_otp_path
58+
assert_equal '/user_with_two_factors/two_factor',
59+
user_with_two_factor_two_factor_path
60+
end
61+
62+
test 'full two-factor sign-in: password -> challenge -> OTP -> authenticated' do
63+
user = create_user_with_two_factor(otp_secret: '123456')
64+
65+
# Step 1: Submit password
66+
post user_with_two_factor_session_path, params: {
67+
user_with_two_factor: { email: user.email, password: '12345678' }
68+
}
69+
70+
# Step 2: Redirected to the default 2FA method's challenge page
71+
assert_redirected_to user_with_two_factor_new_two_factor_test_otp_path
72+
follow_redirect!
73+
assert_response :success
74+
75+
# Step 3: Submit correct OTP
76+
post user_with_two_factor_two_factor_path, params: {
77+
otp_attempt: user.otp_secret
78+
}
79+
80+
# Step 4: Authenticated and redirected to after_sign_in_path
81+
assert_response :redirect
82+
assert warden.authenticated?(:user_with_two_factor)
83+
end
84+
85+
test 'two-factor sign-in with wrong OTP recalls challenge page' do
86+
user = create_user_with_two_factor(otp_secret: '123456')
87+
88+
post user_with_two_factor_session_path, params: {
89+
user_with_two_factor: { email: user.email, password: '12345678' }
90+
}
91+
assert_redirected_to user_with_two_factor_new_two_factor_test_otp_path
92+
93+
# Submit wrong OTP
94+
post user_with_two_factor_two_factor_path, params: {
95+
otp_attempt: 'wrong'
96+
}
97+
98+
# Should recall (re-render) the challenge page, not redirect
99+
assert_response :success
100+
assert_not warden.authenticated?(:user_with_two_factor)
101+
end
102+
103+
private
104+
105+
def create_user_with_two_factor(attributes = {})
106+
UserWithTwoFactor.create!(
107+
username: 'usertest',
108+
email: generate_unique_email,
109+
password: '12345678',
110+
password_confirmation: '12345678',
111+
**attributes
112+
)
113+
end
114+
end

test/models/serializable_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class SerializableTest < ActiveSupport::TestCase
99

1010
test 'should not include unsafe keys on JSON' do
1111
keys = from_json().keys.select{ |key| !key.include?("id") }
12-
assert_equal %w(created_at email facebook_token updated_at username), keys.sort
12+
assert_equal %w(created_at email facebook_token otp_secret updated_at username), keys.sort
1313
end
1414

1515
test 'should not include unsafe keys on JSON even if a new except is provided' do
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class TwoFactorAuthenticatableTest < ActiveSupport::TestCase
6+
test '.two_factor_modules returns the configured two_factor_methods' do
7+
klass = Class.new do
8+
extend Devise::Models::TwoFactorAuthenticatable::ClassMethods
9+
end
10+
klass.instance_variable_set(:@two_factor_methods, [:fake_method])
11+
12+
assert_equal [:fake_method], klass.two_factor_modules
13+
end
14+
15+
test '.two_factor_modules returns empty array when no methods configured' do
16+
klass = Class.new do
17+
extend Devise::Models::TwoFactorAuthenticatable::ClassMethods
18+
end
19+
20+
assert_equal [], klass.two_factor_modules
21+
end
22+
23+
test '#two_factor_enabled? returns true when any method reports enabled' do
24+
klass = Class.new do
25+
include Devise::Models::TwoFactorAuthenticatable
26+
end
27+
klass.instance_variable_set(:@two_factor_methods, [:fake_method])
28+
29+
instance = klass.new
30+
instance.define_singleton_method(:fake_method_two_factor_enabled?) { true }
31+
32+
assert instance.two_factor_enabled?
33+
assert_equal [:fake_method], instance.enabled_two_factors
34+
end
35+
36+
test '#two_factor_enabled? returns false when no method reports enabled' do
37+
klass = Class.new do
38+
include Devise::Models::TwoFactorAuthenticatable
39+
end
40+
klass.instance_variable_set(:@two_factor_methods, [:fake_method])
41+
42+
instance = klass.new
43+
instance.define_singleton_method(:fake_method_two_factor_enabled?) { false }
44+
45+
assert_not instance.two_factor_enabled?
46+
assert_empty instance.enabled_two_factors
47+
end
48+
49+
test '#enabled_two_factors returns only enabled methods' do
50+
klass = Class.new do
51+
include Devise::Models::TwoFactorAuthenticatable
52+
end
53+
klass.instance_variable_set(:@two_factor_methods, [:method_a, :method_b])
54+
55+
instance = klass.new
56+
instance.define_singleton_method(:method_a_two_factor_enabled?) { true }
57+
instance.define_singleton_method(:method_b_two_factor_enabled?) { false }
58+
59+
assert_equal [:method_a], instance.enabled_two_factors
60+
end
61+
62+
test '.two_factor_methods= raises on unknown method' do
63+
klass = Class.new do
64+
extend Devise::Models::TwoFactorAuthenticatable::ClassMethods
65+
end
66+
67+
assert_raises(RuntimeError, /Unknown two-factor method/) do
68+
klass.two_factor_methods = [:nonexistent]
69+
end
70+
end
71+
72+
test '.two_factor_methods= includes model concern from registry' do
73+
# Register a fake method
74+
Devise.register_two_factor_method(:includable_test,
75+
model: 'devise/models/test_otp',
76+
strategy: :test_strategy)
77+
78+
klass = Class.new do
79+
extend Devise::Models::TwoFactorAuthenticatable::ClassMethods
80+
81+
# Stub include to track what gets included
82+
def self.included_modules_tracker
83+
@included_modules_tracker ||= []
84+
end
85+
86+
def self.include(mod)
87+
included_modules_tracker << mod
88+
super
89+
end
90+
end
91+
92+
klass.two_factor_methods = [:includable_test]
93+
assert_equal [:includable_test], Array(klass.instance_variable_get(:@two_factor_methods))
94+
ensure
95+
Devise.two_factor_method_configs.delete(:includable_test)
96+
Devise::STRATEGIES.delete(:includable_test)
97+
end
98+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
require 'shared_user_with_two_factor'
4+
5+
class UserWithTwoFactor < ActiveRecord::Base
6+
self.table_name = 'users'
7+
include Shim
8+
include SharedUserWithTwoFactor
9+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
require 'shared_user_with_two_factor'
4+
5+
class UserWithTwoFactor
6+
include Mongoid::Document
7+
include Shim
8+
include SharedUserWithTwoFactor
9+
10+
field :username, type: String
11+
field :email, type: String, default: ""
12+
field :encrypted_password, type: String, default: ""
13+
field :otp_secret, type: String
14+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<h2>Two-Factor Authentication</h2>
2+
3+
<%= form_tag(two_factor_path(resource_name), method: :post) do %>
4+
<%= text_field_tag :otp_attempt %>
5+
<%= submit_tag "Verify" %>
6+
<% end %>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
# Test-only two-factor method for integration testing.
4+
# Simulates a real 2FA extension with a simple OTP check.
5+
6+
require 'devise/models/two_factor_authenticatable'
7+
8+
module Devise
9+
module Models
10+
module TestOtp
11+
extend ActiveSupport::Concern
12+
13+
def test_otp_two_factor_enabled?
14+
respond_to?(:otp_secret) && otp_secret.present?
15+
end
16+
end
17+
end
18+
end
19+
20+
module Devise
21+
module Strategies
22+
class TestOtp < Devise::Strategies::TwoFactor
23+
def valid?
24+
params[:otp_attempt].present? &&
25+
session[:devise_two_factor_resource_id].present?
26+
end
27+
28+
def verify_two_factor!(resource)
29+
unless resource.respond_to?(:otp_secret) && params[:otp_attempt] == resource.otp_secret
30+
fail!(:invalid_otp)
31+
return
32+
end
33+
end
34+
end
35+
end
36+
end
37+
38+
Warden::Strategies.add(:test_otp, Devise::Strategies::TestOtp)
39+
40+
Devise.register_two_factor_method :test_otp,
41+
model: 'devise/models/test_otp',
42+
strategy: :test_otp

test/rails_app/config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
# Users scope
2323
devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" }
2424

25+
devise_for :user_with_two_factors
26+
2527
devise_for :user_on_main_apps,
2628
class_name: 'UserOnMainApp',
2729
router_name: :main_app,

0 commit comments

Comments
 (0)