Skip to content

Commit 532d5ee

Browse files
test: add integration tests for two-factor sign-in flow
Add end-to-end integration tests covering the full 2FA sign-in flow, failure recall, password reset enforcement, route verification, and URL helpers. Update serializable_test for new otp_secret column.
1 parent 7d4f343 commit 532d5ee

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

test/helpers/devise_helper_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,33 @@ 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
59+
60+
test 'two_factor_method_links renders link partials for other enabled methods' do
61+
resource = mock('resource')
62+
resource.stubs(:enabled_two_factors).returns([:webauthn, :totp, :backup_codes])
63+
64+
helper = Class.new(ActionView::Base) do
65+
include DeviseHelper
66+
end.new(ActionView::LookupContext.new([]), {}, nil)
67+
68+
helper.stubs(:render).with("devise/two_factor/totp_link").returns('<a href="/totp">Use TOTP</a>'.html_safe)
69+
helper.stubs(:render).with("devise/two_factor/backup_codes_link").returns('<a href="/backup">Use backup codes</a>'.html_safe)
70+
71+
result = helper.two_factor_method_links(resource, :webauthn)
72+
assert_includes result, "Use TOTP"
73+
assert_includes result, "Use backup codes"
74+
assert_not_includes result, "webauthn"
75+
end
4776
end
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
test 'two-factor sign-in with expired session does not authenticate' do
104+
user = create_user_with_two_factor(otp_secret: '123456')
105+
106+
post user_with_two_factor_session_path, params: {
107+
user_with_two_factor: { email: user.email, password: '12345678' }
108+
}
109+
assert_redirected_to user_with_two_factor_new_two_factor_test_otp_path
110+
111+
# Simulate session expiration between password and OTP submission
112+
reset!
113+
114+
post user_with_two_factor_two_factor_path, params: {
115+
otp_attempt: user.otp_secret
116+
}
117+
118+
assert_not warden.authenticated?(:user_with_two_factor)
119+
end
120+
121+
test 'visiting two-factor challenge page without sign-in redirects to login' do
122+
get user_with_two_factor_new_two_factor_test_otp_path
123+
124+
assert_redirected_to new_user_with_two_factor_session_path
125+
assert_not warden.authenticated?(:user_with_two_factor)
126+
end
127+
128+
private
129+
130+
def create_user_with_two_factor(attributes = {})
131+
UserWithTwoFactor.create!(
132+
username: 'usertest',
133+
email: generate_unique_email,
134+
password: '12345678',
135+
password_confirmation: '12345678',
136+
**attributes
137+
)
138+
end
139+
end

0 commit comments

Comments
 (0)