From a9e4ad7f60b5a81b54cf9e5c177713ccd5a1bd2d Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sat, 22 Feb 2025 17:58:45 -0800 Subject: [PATCH 1/6] Replace I18n.available_locales with I18n.backend.eager_load! Currently I18n.available_locales is called from the tests to ensure that the I18n backend is initialized before the tests run. This, however, is not only unintuitive to initialize the backend, but also unreliable because if the available locales are cached, calling I18n.available_locales won't call further into the backend to initialize it. This is used in combination with I18n.reload! which leaves the backend in uninitialized state but it doesn't clear the global I18n.available_locales, i.e. the available locales are still cached. Consequently, I18n.available_locales won't do what's expected of it - namely initializing the backend by preloading the translations - and tests relying on custom translations may break when the I18n backend initializes itself while looking up a translation key. During this init phase the I18n backend may override translations defined in the tests by translations loaded from the locale YML files. I18n.backend.eager_load! is independent of the cached available locales. If the backend is already initialized, it returns early, otherwise it loads the translations from the YML files. --- test/helpers/devise_helper_test.rb | 2 +- test/support/helpers.rb | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/test/helpers/devise_helper_test.rb b/test/helpers/devise_helper_test.rb index 754e82d819..8e151c6004 100644 --- a/test/helpers/devise_helper_test.rb +++ b/test/helpers/devise_helper_test.rb @@ -14,7 +14,7 @@ class DeviseHelperTest < Devise::IntegrationTest mongoid: model_labels } - I18n.available_locales + I18n.backend.eager_load! I18n.backend.store_translations(:en, translations) end diff --git a/test/support/helpers.rb b/test/support/helpers.rb index 01dc6aa562..1945ebcf7d 100644 --- a/test/support/helpers.rb +++ b/test/support/helpers.rb @@ -8,11 +8,10 @@ def setup_mailer end def store_translations(locale, translations, &block) - # Calling 'available_locales' before storing the translations to ensure - # that the I18n backend will be initialized before we store our custom - # translations, so they will always override the translations for the - # YML file. - I18n.available_locales + # Eager-loading the backend before storing the translations ensures that the I18n backend will + # always be initialized before we store our custom translations, so the test-specific + # translations override the translations from the YML file. + I18n.backend.eager_load! I18n.backend.store_translations(locale, translations) yield ensure From 25f8411e4c7a910dbad680f9d0c99830a4597825 Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sun, 23 Feb 2025 00:48:28 -0800 Subject: [PATCH 2/6] Support multiple languages with the store_translations helper --- test/integration/authenticatable_test.rb | 4 +++- test/integration/confirmable_test.rb | 4 ++-- test/integration/database_authenticatable_test.rb | 12 +++++++++--- test/integration/lockable_test.rb | 10 ++-------- test/integration/timeoutable_test.rb | 8 ++------ test/mailers/confirmation_instructions_test.rb | 8 ++++++-- test/mailers/email_changed_test.rb | 8 ++++++-- test/mailers/reset_password_instructions_test.rb | 8 ++++++-- test/mailers/unlock_instructions_test.rb | 8 ++++++-- test/support/helpers.rb | 4 ++-- 10 files changed, 44 insertions(+), 30 deletions(-) diff --git a/test/integration/authenticatable_test.rb b/test/integration/authenticatable_test.rb index ea338f6fc1..045cd61715 100644 --- a/test/integration/authenticatable_test.rb +++ b/test/integration/authenticatable_test.rb @@ -334,7 +334,9 @@ class AuthenticationRedirectTest < Devise::IntegrationTest end test 'require_no_authentication should set the already_authenticated flash message as admin' do - store_translations :en, devise: { failure: { admin: { already_authenticated: 'You are already signed in as admin.' } } } do + store_translations en: { + devise: { failure: { admin: { already_authenticated: 'You are already signed in as admin.' } } } + } do sign_in_as_admin visit new_admin_session_path assert_equal "You are already signed in as admin.", flash[:alert] diff --git a/test/integration/confirmable_test.rb b/test/integration/confirmable_test.rb index c951eb0bbd..7a2b6b1aec 100644 --- a/test/integration/confirmable_test.rb +++ b/test/integration/confirmable_test.rb @@ -206,8 +206,8 @@ def resend_confirmation end test 'error message is configurable by resource name' do - store_translations :en, devise: { - failure: { user: { unconfirmed: "Not confirmed user" } } + store_translations en: { + devise: { failure: { user: { unconfirmed: "Not confirmed user" } } } } do sign_in_as_user(confirm: false) assert_contain 'Not confirmed user' diff --git a/test/integration/database_authenticatable_test.rb b/test/integration/database_authenticatable_test.rb index 20097a8718..cd950fb447 100644 --- a/test/integration/database_authenticatable_test.rb +++ b/test/integration/database_authenticatable_test.rb @@ -55,7 +55,9 @@ class DatabaseAuthenticationTest < Devise::IntegrationTest end test 'sign in with invalid email should return to sign in form with error message' do - store_translations :en, devise: { failure: { admin: { not_found_in_database: 'Invalid email address' } } } do + store_translations en: { + devise: { failure: { admin: { not_found_in_database: 'Invalid email address' } } } + } do sign_in_as_admin do fill_in 'email', with: 'wrongemail@test.com' end @@ -76,7 +78,9 @@ class DatabaseAuthenticationTest < Devise::IntegrationTest test 'when in paranoid mode and without a valid e-mail' do swap Devise, paranoid: true do - store_translations :en, devise: { failure: { not_found_in_database: 'Not found in database' } } do + store_translations en: { + devise: { failure: { not_found_in_database: 'Not found in database' } } + } do sign_in_as_user do fill_in 'email', with: 'wrongemail@test.com' end @@ -88,7 +92,9 @@ class DatabaseAuthenticationTest < Devise::IntegrationTest end test 'error message is configurable by resource name' do - store_translations :en, devise: { failure: { admin: { invalid: "Invalid credentials" } } } do + store_translations en: { + devise: { failure: { admin: { invalid: "Invalid credentials" } } } + } do sign_in_as_admin do fill_in 'password', with: 'abcdef' end diff --git a/test/integration/lockable_test.rb b/test/integration/lockable_test.rb index e5dd5ee08b..78ffc6084d 100644 --- a/test/integration/lockable_test.rb +++ b/test/integration/lockable_test.rb @@ -103,10 +103,7 @@ def send_unlock_request end test 'error message is configurable by resource name' do - store_translations :en, devise: { - failure: {user: {locked: "You are locked!"}} - } do - + store_translations en: { devise: { failure: { user: { locked: "You are locked!"} } } } do user = create_user(locked: true) user.failed_attempts = User.maximum_attempts + 1 user.save! @@ -117,10 +114,7 @@ def send_unlock_request end test "user should not be able to sign in when locked" do - store_translations :en, devise: { - failure: {user: {locked: "You are locked!"}} - } do - + store_translations en: { devise: { failure: { user: { locked: "You are locked!"} } } } do user = create_user(locked: true) user.failed_attempts = User.maximum_attempts + 1 user.save! diff --git a/test/integration/timeoutable_test.rb b/test/integration/timeoutable_test.rb index d11d59105c..5e8ad211d5 100644 --- a/test/integration/timeoutable_test.rb +++ b/test/integration/timeoutable_test.rb @@ -141,9 +141,7 @@ def last_request_at end test 'error message with i18n' do - store_translations :en, devise: { - failure: { user: { timeout: 'Session expired!' } } - } do + store_translations en: { devise: { failure: { user: { timeout: 'Session expired!' } } } } do user = sign_in_as_user get expire_user_path(user) @@ -154,9 +152,7 @@ def last_request_at end test 'error message with i18n with double redirect' do - store_translations :en, devise: { - failure: { user: { timeout: 'Session expired!' } } - } do + store_translations en: { devise: { failure: { user: { timeout: 'Session expired!' } } } } do user = sign_in_as_user get expire_user_path(user) diff --git a/test/mailers/confirmation_instructions_test.rb b/test/mailers/confirmation_instructions_test.rb index 5b46331219..e339736a54 100644 --- a/test/mailers/confirmation_instructions_test.rb +++ b/test/mailers/confirmation_instructions_test.rb @@ -69,13 +69,17 @@ def mail end test 'set up subject from I18n' do - store_translations :en, devise: { mailer: { confirmation_instructions: { subject: 'Account Confirmation' } } } do + store_translations en: { + devise: { mailer: { confirmation_instructions: { subject: 'Account Confirmation' } } } + } do assert_equal 'Account Confirmation', mail.subject end end test 'subject namespaced by model' do - store_translations :en, devise: { mailer: { confirmation_instructions: { user_subject: 'User Account Confirmation' } } } do + store_translations en: { + devise: { mailer: { confirmation_instructions: { user_subject: 'User Account Confirmation' } } } + } do assert_equal 'User Account Confirmation', mail.subject end end diff --git a/test/mailers/email_changed_test.rb b/test/mailers/email_changed_test.rb index f324165452..8cf26b3759 100644 --- a/test/mailers/email_changed_test.rb +++ b/test/mailers/email_changed_test.rb @@ -73,13 +73,17 @@ def mail end test 'set up subject from I18n' do - store_translations :en, devise: { mailer: { email_changed: { subject: 'Email Has Changed' } } } do + store_translations en: { + devise: { mailer: { email_changed: { subject: 'Email Has Changed' } } } + } do assert_equal 'Email Has Changed', mail.subject end end test 'subject namespaced by model' do - store_translations :en, devise: { mailer: { email_changed: { user_subject: 'User Email Has Changed' } } } do + store_translations en: { + devise: { mailer: { email_changed: { user_subject: 'User Email Has Changed' } } } + } do assert_equal 'User Email Has Changed', mail.subject end end diff --git a/test/mailers/reset_password_instructions_test.rb b/test/mailers/reset_password_instructions_test.rb index 5a344cbf09..164fb7d79e 100644 --- a/test/mailers/reset_password_instructions_test.rb +++ b/test/mailers/reset_password_instructions_test.rb @@ -65,13 +65,17 @@ def mail end test 'set up subject from I18n' do - store_translations :en, devise: { mailer: { reset_password_instructions: { subject: 'Reset instructions' } } } do + store_translations en: { + devise: { mailer: { reset_password_instructions: { subject: 'Reset instructions' } } } + } do assert_equal 'Reset instructions', mail.subject end end test 'subject namespaced by model' do - store_translations :en, devise: { mailer: { reset_password_instructions: { user_subject: 'User Reset Instructions' } } } do + store_translations en: { + devise: { mailer: { reset_password_instructions: { user_subject: 'User Reset Instructions' } } } + } do assert_equal 'User Reset Instructions', mail.subject end end diff --git a/test/mailers/unlock_instructions_test.rb b/test/mailers/unlock_instructions_test.rb index dff580e2eb..e7cc0031d8 100644 --- a/test/mailers/unlock_instructions_test.rb +++ b/test/mailers/unlock_instructions_test.rb @@ -66,13 +66,17 @@ def mail end test 'set up subject from I18n' do - store_translations :en, devise: { mailer: { unlock_instructions: { subject: 'Yo unlock instructions' } } } do + store_translations en: { + devise: { mailer: { unlock_instructions: { subject: 'Yo unlock instructions' } } } + } do assert_equal 'Yo unlock instructions', mail.subject end end test 'subject namespaced by model' do - store_translations :en, devise: { mailer: { unlock_instructions: { user_subject: 'User Unlock Instructions' } } } do + store_translations en: { + devise: { mailer: { unlock_instructions: { user_subject: 'User Unlock Instructions' } } } + } do assert_equal 'User Unlock Instructions', mail.subject end end diff --git a/test/support/helpers.rb b/test/support/helpers.rb index 1945ebcf7d..44b825dce0 100644 --- a/test/support/helpers.rb +++ b/test/support/helpers.rb @@ -7,12 +7,12 @@ def setup_mailer ActionMailer::Base.deliveries = [] end - def store_translations(locale, translations, &block) + def store_translations(translations) # Eager-loading the backend before storing the translations ensures that the I18n backend will # always be initialized before we store our custom translations, so the test-specific # translations override the translations from the YML file. I18n.backend.eager_load! - I18n.backend.store_translations(locale, translations) + translations.each { |locale, entries| I18n.backend.store_translations(locale, entries) } yield ensure I18n.reload! From 3b13437098ad8188be1b52bffb3dfc2f35c135f0 Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sun, 23 Feb 2025 01:40:45 -0800 Subject: [PATCH 3/6] Save and restore the available locales --- test/support/helpers.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/support/helpers.rb b/test/support/helpers.rb index 44b825dce0..42600ae70d 100644 --- a/test/support/helpers.rb +++ b/test/support/helpers.rb @@ -12,9 +12,12 @@ def store_translations(translations) # always be initialized before we store our custom translations, so the test-specific # translations override the translations from the YML file. I18n.backend.eager_load! + original_locales = I18n.available_locales + I18n.available_locales = Set.new(original_locales + translations.keys).to_a translations.each { |locale, entries| I18n.backend.store_translations(locale, entries) } yield ensure + I18n.available_locales = original_locales I18n.reload! end From 7917c18d7cee98d29c09feae14479496aa96d436 Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sun, 23 Feb 2025 01:42:26 -0800 Subject: [PATCH 4/6] Forward the current locale to Warden Pass the used locale to `throw :warden` when the session times out and when an inactive user tries to sign in. --- lib/devise/hooks/activatable.rb | 2 +- lib/devise/hooks/timeoutable.rb | 2 +- test/integration/activatable_test.rb | 22 ++++++++++++++++++++++ test/integration/timeoutable_test.rb | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 test/integration/activatable_test.rb diff --git a/lib/devise/hooks/activatable.rb b/lib/devise/hooks/activatable.rb index b2eaea199f..f8b14c0ff9 100644 --- a/lib/devise/hooks/activatable.rb +++ b/lib/devise/hooks/activatable.rb @@ -7,6 +7,6 @@ if record && record.respond_to?(:active_for_authentication?) && !record.active_for_authentication? scope = options[:scope] warden.logout(scope) - throw :warden, scope: scope, message: record.inactive_message + throw :warden, scope: scope, message: record.inactive_message, locale: options[:locale] end end diff --git a/lib/devise/hooks/timeoutable.rb b/lib/devise/hooks/timeoutable.rb index 772eb142b7..655bcaa6b4 100644 --- a/lib/devise/hooks/timeoutable.rb +++ b/lib/devise/hooks/timeoutable.rb @@ -25,7 +25,7 @@ record.timedout?(last_request_at) && !proxy.remember_me_is_active?(record) Devise.sign_out_all_scopes ? proxy.sign_out : proxy.sign_out(scope) - throw :warden, scope: scope, message: :timeout + throw :warden, scope: scope, message: :timeout, locale: options[:locale] end unless env['devise.skip_trackable'] diff --git a/test/integration/activatable_test.rb b/test/integration/activatable_test.rb new file mode 100644 index 0000000000..49c3a61b52 --- /dev/null +++ b/test/integration/activatable_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ActivatableTest < Devise::IntegrationTest + test 'shows the localized error message for inactive accounts' do + store_translations( + en: { devise: { failure: { unconfirmed: 'Unconfirmed account.' } } }, + de: { devise: { failure: { unconfirmed: 'Unbestätigtes Konto!' } } } + ) do + I18n.with_locale(:de) do + user = create_user(confirm: false, confirmation_sent_at: 1.hour.ago) + get new_user_session_path + fill_in 'email', with: user.email + fill_in 'password', with: user.password + click_button 'Log In' + + assert_contain('Unbestätigtes Konto!') + end + end + end +end diff --git a/test/integration/timeoutable_test.rb b/test/integration/timeoutable_test.rb index 5e8ad211d5..f516244b67 100644 --- a/test/integration/timeoutable_test.rb +++ b/test/integration/timeoutable_test.rb @@ -163,6 +163,24 @@ def last_request_at end end + test 'shows the localized error message on session timeout' do + # Set up the error message in the default and in a different locale + store_translations( + en: { devise: { failure: { user: { timeout: 'Session expired!' } } } }, + de: { devise: { failure: { timeout: 'Sitzung abgelaufen!' } } } + ) do + I18n.with_locale(:de) do + user = sign_in_as_user + + get expire_user_path(user) + get users_path + follow_redirect! + follow_redirect! + assert_contain('Sitzung abgelaufen!') + end + end + end + test 'time out not triggered if remembered' do user = sign_in_as_user remember_me: true get expire_user_path(user) From 8408808963fe8a2c30673be8dabff714db4debdd Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sun, 23 Feb 2025 17:26:06 -0800 Subject: [PATCH 5/6] Prevent concurrent access to store_translations --- test/support/helpers.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/support/helpers.rb b/test/support/helpers.rb index 42600ae70d..a1ce7e53d1 100644 --- a/test/support/helpers.rb +++ b/test/support/helpers.rb @@ -10,7 +10,11 @@ def setup_mailer def store_translations(translations) # Eager-loading the backend before storing the translations ensures that the I18n backend will # always be initialized before we store our custom translations, so the test-specific - # translations override the translations from the YML file. + # translations override the translations from the YML files. Locking on a mutex guarantees that + # concurrent tests calling the same method don't interfere with each other. Note that I18n has a + # global state so all interactions with it outside of store_translations may cause unexpected + # runtime errors. + self.class.i18n_mutex.lock I18n.backend.eager_load! original_locales = I18n.available_locales I18n.available_locales = Set.new(original_locales + translations.keys).to_a @@ -19,6 +23,7 @@ def store_translations(translations) ensure I18n.available_locales = original_locales I18n.reload! + self.class.i18n_mutex.unlock end def generate_unique_email @@ -91,4 +96,8 @@ def clear_cached_variables(options) end end end + + def self.i18n_mutex + @i18n_mutex ||= Mutex.new + end end From 217af99723e2c2c438a799886031b1ec17efee15 Mon Sep 17 00:00:00 2001 From: sandrasi Date: Sun, 23 Feb 2025 18:09:22 -0800 Subject: [PATCH 6/6] Allow setting any locale without changing the global I18n state --- test/support/helpers.rb | 14 +------------- test/test_helper.rb | 2 ++ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/test/support/helpers.rb b/test/support/helpers.rb index a1ce7e53d1..3b237c5d07 100644 --- a/test/support/helpers.rb +++ b/test/support/helpers.rb @@ -10,20 +10,12 @@ def setup_mailer def store_translations(translations) # Eager-loading the backend before storing the translations ensures that the I18n backend will # always be initialized before we store our custom translations, so the test-specific - # translations override the translations from the YML files. Locking on a mutex guarantees that - # concurrent tests calling the same method don't interfere with each other. Note that I18n has a - # global state so all interactions with it outside of store_translations may cause unexpected - # runtime errors. - self.class.i18n_mutex.lock + # translations override the translations from the YML files. I18n.backend.eager_load! - original_locales = I18n.available_locales - I18n.available_locales = Set.new(original_locales + translations.keys).to_a translations.each { |locale, entries| I18n.backend.store_translations(locale, entries) } yield ensure - I18n.available_locales = original_locales I18n.reload! - self.class.i18n_mutex.unlock end def generate_unique_email @@ -96,8 +88,4 @@ def clear_cached_variables(options) end end end - - def self.i18n_mutex - @i18n_mutex ||= Mutex.new - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index e6df812037..8cde508472 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,6 +11,8 @@ require "orm/#{DEVISE_ORM}" I18n.load_path.concat Dir["#{File.dirname(__FILE__)}/support/locale/*.yml"] +# Allow setting test-specific locales even if they are not defined in the locale YAML files. +I18n.enforce_available_locales = false require 'mocha/minitest' require 'timecop'