From 2e723a6454c331a29a7b7a3cab1c558652ed3a4b Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 10 Aug 2026 14:23:38 +0900 Subject: [PATCH 01/16] Use the supported update API for Rails 7 OTP flows Rails 7 removes Active Record's deprecated update_attributes method. Direct OTP issuance and cleanup would therefore fail before an authentication flow could complete. Switch these calls to update and rename the non-persisted GuestUser test double's matching method so the test harness continues to mirror the production interface. --- .../models/two_factor_authenticatable.rb | 4 ++-- spec/rails_app/app/models/guest_user.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb index 6d73a0fb..d23cae87 100644 --- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb @@ -101,7 +101,7 @@ def generate_totp_secret def create_direct_otp(options = {}) # Create a new random OTP and store it in the database digits = options[:length] || self.class.direct_otp_length || 6 - update_attributes( + update( direct_otp: random_base10(digits), direct_otp_sent_at: Time.now.utc ) @@ -122,7 +122,7 @@ def direct_otp_expired? end def clear_direct_otp - update_attributes(direct_otp: nil, direct_otp_sent_at: nil) + update(direct_otp: nil, direct_otp_sent_at: nil) end end diff --git a/spec/rails_app/app/models/guest_user.rb b/spec/rails_app/app/models/guest_user.rb index 8003624c..1222279a 100644 --- a/spec/rails_app/app/models/guest_user.rb +++ b/spec/rails_app/app/models/guest_user.rb @@ -7,7 +7,7 @@ class GuestUser attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email, :second_factor_attempts_count, :totp_timestamp - def update_attributes(attrs) + def update(attrs) attrs.each do |key, value| send(key.to_s + '=', value) end From cde578b76695f028f30f9dd131fb9eaa76995a68 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 10 Aug 2026 14:23:58 +0900 Subject: [PATCH 02/16] Adapt TOTP persistence and routes for Rails 8 With the newer ROTP versions required by the Rails 8 matrix, verification returns a Unix timestamp while the persisted column expects a datetime. Convert it explicitly to UTC Time before assignment. Rails 8 also validates the resource action list strictly: resend_code is declared as a separate collection route and must not be listed as a resource action. Keep that endpoint while limiting the resource actions to show and update. --- .../models/two_factor_authenticatable.rb | 2 +- lib/two_factor_authentication/routes.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb index d23cae87..ea1955b7 100644 --- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb @@ -44,7 +44,7 @@ def authenticate_totp(code, options = {}) drift_ahead: drift, drift_behind: drift, after: totp_timestamp ) return false unless new_timestamp - self.totp_timestamp = new_timestamp + self.totp_timestamp = Time.at(new_timestamp).utc true end diff --git a/lib/two_factor_authentication/routes.rb b/lib/two_factor_authentication/routes.rb index 543059a2..5e5442ba 100644 --- a/lib/two_factor_authentication/routes.rb +++ b/lib/two_factor_authentication/routes.rb @@ -3,7 +3,7 @@ class Mapper protected def devise_two_factor_authentication(mapping, controllers) - resource :two_factor_authentication, :only => [:show, :update, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do + resource :two_factor_authentication, :only => [:show, :update], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do collection { get "resend_code" } end end From ee873de2651eee1fef48cbf649a0c6c8ae83f962 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 03/16] Load Logger before booting older Active Support releases Current concurrent-ruby releases no longer load Ruby's logger standard library as a side effect. Older Active Support versions still reference Logger constants during initialization, so the dummy application can fail before the test suite starts when those dependencies are resolved together. Require logger explicitly before loading the Rails boot files. This keeps the historical framework targets bootable without constraining concurrent-ruby or changing application behavior. --- spec/rails_app/config/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/rails_app/config/application.rb b/spec/rails_app/config/application.rb index 2d31d588..729fd66e 100644 --- a/spec/rails_app/config/application.rb +++ b/spec/rails_app/config/application.rb @@ -1,3 +1,4 @@ +require 'logger' require File.expand_path('../boot', __FILE__) require "active_record/railtie" @@ -60,4 +61,3 @@ class Application < Rails::Application config.secret_key_base = 'secretvalue' end end - From d692528335595faa63f6a38f378e9d8714e941dd Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 04/16] Declare the dummy application assets for modern Sprockets The historical dummy application predates the manifest required by current sprockets-rails releases. When the expanded matrix boots a modern Rails stack with the asset railtie enabled, initialization stops because no application asset manifest is present. Add the standard manifest and link the existing JavaScript and stylesheet directories. Older Rails targets retain their existing asset behavior while newer targets can complete application boot. --- spec/rails_app/app/assets/config/manifest.js | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 spec/rails_app/app/assets/config/manifest.js diff --git a/spec/rails_app/app/assets/config/manifest.js b/spec/rails_app/app/assets/config/manifest.js new file mode 100644 index 00000000..21a78805 --- /dev/null +++ b/spec/rails_app/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link_directory ../javascripts .js +//= link_directory ../stylesheets .css From 035940232b6f2a6d392679208fe282834a9ecec9 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 05/16] Load the SMS test provider across Rails autoloaders Zeitwerk infers SmsProvider from sms_provider.rb by default, while the dummy application intentionally defines SMSProvider. Register SMS as an acronym so the file name and constant agree when modern Rails validates or autoloads the test application. Require the standalone provider explicitly from spec_helper as well. This makes it available before examples and support hooks run under both the classic and Zeitwerk autoloaders instead of relying on generation-specific load timing. --- spec/rails_app/config/initializers/inflections.rb | 4 ++++ spec/spec_helper.rb | 1 + 2 files changed, 5 insertions(+) diff --git a/spec/rails_app/config/initializers/inflections.rb b/spec/rails_app/config/initializers/inflections.rb index 5d8d9be2..1d5e5332 100644 --- a/spec/rails_app/config/initializers/inflections.rb +++ b/spec/rails_app/config/initializers/inflections.rb @@ -13,3 +13,7 @@ # ActiveSupport::Inflector.inflections do |inflect| # inflect.acronym 'RESTful' # end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'SMS' +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 63704333..189d5844 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,6 @@ ENV["RAILS_ENV"] ||= "test" require File.expand_path("../rails_app/config/environment.rb", __FILE__) +require File.expand_path("../rails_app/lib/sms_provider.rb", __FILE__) require 'rspec/rails' require 'timecop' From 5b745673158111771e425012a5b0953cd7263312 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 06/16] Remove legacy Ammeter assumptions from generator specs The generator spec wrote its output into a repository-relative tmp directory, tying test artifacts to the checkout layout. Use Ruby's system temporary directory so Rails' generator test helpers own an isolated destination consistently across local and hosted environments. The existence assertion also relied on Ammeter's deprecated override of RSpec's exist matcher. That compatibility matcher warns through the legacy ActiveSupport::Deprecation class delegator, which newer Rails releases no longer expose. Check File.exist? directly through satisfy to preserve the assertion without depending on Ammeter or RSpec internals. --- .../two_factor_authentication_generator_spec.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/generators/active_record/two_factor_authentication_generator_spec.rb b/spec/generators/active_record/two_factor_authentication_generator_spec.rb index 5a8989d0..1c9957b9 100644 --- a/spec/generators/active_record/two_factor_authentication_generator_spec.rb +++ b/spec/generators/active_record/two_factor_authentication_generator_spec.rb @@ -1,9 +1,10 @@ require 'spec_helper' +require 'tmpdir' require 'generators/active_record/two_factor_authentication_generator' describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do - destination File.expand_path('../../../../../tmp', __FILE__) + destination File.join(Dir.tmpdir, 'two_factor_authentication_generator') before do prepare_destination @@ -23,7 +24,7 @@ describe 'the migration' do subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') } - it { is_expected.to exist } + it { is_expected.to satisfy { |path| File.exist?(path) } } it { is_expected.to be_a_migration } it { is_expected.to contain /def change/ } it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ } From f165c7ae864d496c772882bd5f30c5714233a139 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 07/16] Assert provisioning URIs by decoded components Newer ROTP releases percent-encode provisioning labels, so matching the raw URI rejects output that is semantically equivalent and valid. Parse the URI and verify its scheme, host, decoded path, and secret independently to preserve the authentication contract across ROTP generations. Use URI.decode_www_form for query parameters in both examples and decode the issuer path before comparison. This removes the separate CGI parsing shape and keeps the assertions focused on the values an authenticator receives rather than their wire encoding. --- .../models/two_factor_authenticatable_spec.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb index 6fb4f505..3cf6f90d 100644 --- a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +++ b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb @@ -137,8 +137,13 @@ def instance.send_two_factor_authentication_code(code) end it "returns uri with user's email" do - expect(instance.provisioning_uri). - to match(%r{otpauth://totp/houdini@example.com\?secret=\w{32}}) + uri = URI.parse(instance.provisioning_uri) + params = URI.decode_www_form(uri.query).to_h + + expect(uri.scheme).to eq('otpauth') + expect(uri.host).to eq('totp') + expect(URI.decode_www_form_component(uri.path)).to eq('/houdini@example.com') + expect(params['secret']).to match(/\w{32}/) end it 'returns uri with issuer option' do @@ -147,15 +152,14 @@ def instance.send_two_factor_authentication_code(code) end it 'returns uri with issuer option' do - require 'cgi' uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic')) - params = CGI.parse(uri.query) + params = URI.decode_www_form(uri.query).to_h expect(uri.scheme).to eq('otpauth') expect(uri.host).to eq('totp') - expect(uri.path).to eq('/Magic:houdini') - expect(params['issuer'].shift).to eq('Magic') - expect(params['secret'].shift).to match(/\w{32}/) + expect(URI.decode_www_form_component(uri.path)).to eq('/Magic:houdini') + expect(params['issuer']).to eq('Magic') + expect(params['secret']).to match(/\w{32}/) end end end From fff86f18d88b12d369a332ebbe401028331d884d Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:33 +0900 Subject: [PATCH 08/16] Allow maintained Capybara releases on Ruby 4 The pessimistic Capybara 2.5 constraint keeps the test bundle on the 2.x line, which uses a Proc API removed by Ruby 4 and prevents the feature suite from booting on the newest matrix targets. Retain 2.5 as the lower bound while allowing any release below Capybara 4. This lets Bundler select the maintained 3.x line for current Rubies without dropping the older Capybara versions needed by the historical Ruby and Rails combinations. --- two_factor_authentication.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/two_factor_authentication.gemspec b/two_factor_authentication.gemspec index 9580f117..98d5b99c 100644 --- a/two_factor_authentication.gemspec +++ b/two_factor_authentication.gemspec @@ -34,7 +34,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'bundler' s.add_development_dependency 'rake' s.add_development_dependency 'rspec-rails', '>= 3.0.1' - s.add_development_dependency 'capybara', '~> 2.5' + s.add_development_dependency 'capybara', '>= 2.5', '< 4' s.add_development_dependency 'pry' s.add_development_dependency 'timecop' end From c6f7305099030641b6600d8cf48f92677393d985 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:07:47 +0900 Subject: [PATCH 09/16] Resolve dependencies for the full Ruby and Rails matrix A single unconstrained dependency set no longer resolves to versions that can boot every supported runtime. Older Rubies need the last compatible Nokogiri releases, and Loofah must stay below the release that assumes the Nokogiri HTML4 namespace. Rails 4.2 and Rails 5.2 through 7.0 also require different SQLite adapter generations. Add ostruct only for Ruby 4, where it is no longer bundled as a default library, and declare sprockets-rails for the dummy application instead of depending on historical Rails defaults. Interpret a two-component RAILS_VERSION as an exact minor series by expanding it before applying the pessimistic constraint. This prevents a Rails 8.0 job from silently resolving Rails 8.1. Rename the edge selector from master to main and pin the Rails repository branch explicitly. --- Gemfile | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 810ce296..67468031 100644 --- a/Gemfile +++ b/Gemfile @@ -6,22 +6,44 @@ gemspec rails_version = ENV["RAILS_VERSION"] || "default" rails = case rails_version - when "master" - {github: "rails/rails"} + when "main" + {github: "rails/rails", branch: "main"} when "default" "~> 5.2" else - "~> #{rails_version}" + requirement = rails_version.split('.').length == 2 ? "#{rails_version}.0" : rails_version + "~> #{requirement}" end gem "rails", rails -if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0') +ruby_version = Gem::Version.new(RUBY_VERSION) + +if ruby_version >= Gem::Version.new('2.2.0') gem "test-unit", "~> 3.0" end +if ruby_version < Gem::Version.new('2.3.0') + gem 'nokogiri', '~> 1.8.5' +elsif ruby_version < Gem::Version.new('2.5.0') + gem 'nokogiri', '~> 1.10.10' +elsif ruby_version < Gem::Version.new('2.6.0') + gem 'nokogiri', '~> 1.12.5' +end + +gem 'loofah', '< 2.21' if ruby_version < Gem::Version.new('2.5.0') + group :test, :development do - gem 'sqlite3' + gem 'ostruct' if ruby_version >= Gem::Version.new('4.0.0') + case rails_version + when '4.2' + gem 'sqlite3', '~> 1.3.6' + when 'default', '5.2', '6.0', '6.1', '7.0' + gem 'sqlite3', '~> 1.4' + else + gem 'sqlite3' + end + gem 'sprockets-rails' end group :test do From 63e7c889b2f62cfb2b60212b38683e67c466b7db Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:08:06 +0900 Subject: [PATCH 10/16] Replace Travis CI with a required GitHub Actions matrix The Travis configuration only covered Ruby 2.2 through 2.5 against Rails 4.2, 5.2, and the former master branch. Move the project to GitHub Actions and exercise 22 explicit combinations spanning Ruby 2.2 through 4.0 and Rails 4.2 through 8.1 plus Rails main. Every versioned Rails entry is required because the compatibility fixes now allow those releases to complete database setup and all 79 examples. Rails main follows unreleased framework changes, so allow only that job to fail while still reporting upstream compatibility regressions. Keep fail-fast disabled so one failure does not hide results from the rest of the matrix. Run the entire matrix on ubuntu-latest from the job definition and keep each matrix record focused on its dependency combination. Derive the Rails main exception directly from matrix.rails instead of repeating operating-system and experimental flags in every record. Use actions/checkout v7 without persisted credentials, ruby/setup-ruby with Bundler caching, read-only contents permission, and a per-job timeout. Remove Travis only after the replacement workflow and the expanded matrix have been prepared. --- .github/workflows/ci.yml | 85 ++++++++++++++++++++++++++++++++++++++++ .travis.yml | 28 ------------- 2 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ca8a3ae1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.rails == 'main' }} + timeout-minutes: 20 + + strategy: + fail-fast: false + matrix: + include: + - ruby: "2.2" + rails: "4.2" + - ruby: "2.3" + rails: "4.2" + - ruby: "2.4" + rails: "4.2" + - ruby: "2.5" + rails: "4.2" + - ruby: "2.3" + rails: "5.2" + - ruby: "2.4" + rails: "5.2" + - ruby: "2.5" + rails: "5.2" + - ruby: "2.5" + rails: "6.0" + - ruby: "2.7" + rails: "6.0" + - ruby: "2.5" + rails: "6.1" + - ruby: "3.1" + rails: "6.1" + - ruby: "2.7" + rails: "7.0" + - ruby: "3.2" + rails: "7.0" + - ruby: "2.7" + rails: "7.1" + - ruby: "3.3" + rails: "7.1" + - ruby: "3.1" + rails: "7.2" + - ruby: "4.0" + rails: "7.2" + - ruby: "3.2" + rails: "8.0" + - ruby: "4.0" + rails: "8.0" + - ruby: "3.2" + rails: "8.1" + - ruby: "4.0" + rails: "8.1" + - ruby: "4.0" + rails: main + + env: + RAILS_VERSION: ${{ matrix.rails }} + + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Set up test database + run: bundle exec rake app:db:setup + + - name: Run specs + run: bundle exec rake spec diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c3fc35f6..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: ruby - -env: - - "RAILS_VERSION=4.2" - - "RAILS_VERSION=5.2" - - "RAILS_VERSION=master" - -rvm: - - 2.3.8 - - 2.4.5 - - 2.5.3 - -matrix: - fast_finish: true - allow_failures: - - env: "RAILS_VERSION=master" - include: - - rvm: 2.2 - env: RAILS_VERSION=4.2 - -before_install: - - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true - - gem install bundler -v '< 2' - -before_script: - - bundle exec rake app:db:setup - -script: bundle exec rake spec From 73948bb91155bd4270a0983924c2fd907c7fa4e5 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:25:31 +0900 Subject: [PATCH 11/16] Use Bundler 1 for Rails 4.2 jobs The first hosted GitHub Actions run showed that ruby/setup-ruby selected Bundler 2.3 for Ruby 2.3 through 2.5. Rails 4.2.11.3 declares bundler >= 1.3 and < 2.0, so dependency resolution stopped during the setup step before the database or specs could run. Pin Bundler 1.17.3 on each Rails 4.2 matrix entry and retain the setup action default for every newer Rails target. Ruby 2.2 had already selected a compatible Bundler automatically, but an explicit pin keeps all Rails 4.2 jobs deterministic. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca8a3ae1..7d86a37e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,16 @@ jobs: include: - ruby: "2.2" rails: "4.2" + bundler: "1.17.3" - ruby: "2.3" rails: "4.2" + bundler: "1.17.3" - ruby: "2.4" rails: "4.2" + bundler: "1.17.3" - ruby: "2.5" rails: "4.2" + bundler: "1.17.3" - ruby: "2.3" rails: "5.2" - ruby: "2.4" @@ -76,6 +80,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + bundler: ${{ matrix.bundler || 'default' }} bundler-cache: true - name: Set up test database From 6d92eca22c12b85529e7cd10c2d9d3159527c7b3 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 3 Aug 2026 18:27:54 +0900 Subject: [PATCH 12/16] Keep Psych 4 for Ruby 2.7 and Rails 7.1 The hosted Ruby 2.7 / Rails 7.1 job resolved Psych 5.4 and sqlite3 1.7.3. Bundler installed their native extensions in parallel, allowing sqlite3 extconf to load Ruby 2.7's bundled Psych Ruby code with the Psych 5 extension. The incompatible parser APIs caused sqlite3 compilation to fail before tests started. Constrain this one historical dependency set to Psych 4, whose extension remains compatible with Ruby 2.7's loader. Newer Ruby and Rails combinations continue using the latest available Psych. The CI-equivalent Bundler 2.4 parallel install now reaches database setup and passes all 79 examples. --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 67468031..11f34419 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ elsif ruby_version < Gem::Version.new('2.6.0') end gem 'loofah', '< 2.21' if ruby_version < Gem::Version.new('2.5.0') +gem 'psych', '< 5' if rails_version == '7.1' && ruby_version < Gem::Version.new('3.0.0') group :test, :development do gem 'ostruct' if ruby_version >= Gem::Version.new('4.0.0') From 3dc698977b493d636a21ea58b56191860f498cc5 Mon Sep 17 00:00:00 2001 From: relsett Date: Mon, 24 Aug 2026 17:26:03 +0300 Subject: [PATCH 13/16] LT-53534: validate LevelTravel Rails and Devise matrix --- .github/workflows/ci.yml | 66 +++++-------------- Gemfile | 6 ++ .../models/two_factor_authenticatable_spec.rb | 13 ++++ .../two_factor_authentication/routes_spec.rb | 10 +++ 4 files changed, 44 insertions(+), 51 deletions(-) create mode 100644 spec/lib/two_factor_authentication/routes_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d86a37e..5714e923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,66 +9,31 @@ permissions: jobs: test: - name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / Devise ${{ matrix.devise }} runs-on: ubuntu-latest - continue-on-error: ${{ matrix.rails == 'main' }} timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - ruby: "2.2" - rails: "4.2" - bundler: "1.17.3" - - ruby: "2.3" - rails: "4.2" - bundler: "1.17.3" - - ruby: "2.4" - rails: "4.2" - bundler: "1.17.3" - - ruby: "2.5" - rails: "4.2" - bundler: "1.17.3" - - ruby: "2.3" - rails: "5.2" - - ruby: "2.4" - rails: "5.2" - - ruby: "2.5" - rails: "5.2" - - ruby: "2.5" - rails: "6.0" - - ruby: "2.7" - rails: "6.0" - - ruby: "2.5" - rails: "6.1" - - ruby: "3.1" - rails: "6.1" - - ruby: "2.7" - rails: "7.0" - - ruby: "3.2" - rails: "7.0" - - ruby: "2.7" - rails: "7.1" - - ruby: "3.3" - rails: "7.1" - - ruby: "3.1" - rails: "7.2" - - ruby: "4.0" - rails: "7.2" - - ruby: "3.2" - rails: "8.0" - - ruby: "4.0" - rails: "8.0" - - ruby: "3.2" - rails: "8.1" - - ruby: "4.0" - rails: "8.1" - - ruby: "4.0" - rails: main + - ruby: "3.4.10" + rails: "8.0.5.1" + devise: "4.9.4" + - ruby: "3.4.10" + rails: "8.0.5.1" + devise: "5.0.4" + - ruby: "3.4.10" + rails: "8.1.3.1" + devise: "4.9.4" + - ruby: "3.4.10" + rails: "8.1.3.1" + devise: "5.0.4" env: RAILS_VERSION: ${{ matrix.rails }} + DEVISE_VERSION: ${{ matrix.devise }} + ROTP_VERSION: "6.2.0" steps: - name: Check out repository @@ -80,7 +45,6 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - bundler: ${{ matrix.bundler || 'default' }} bundler-cache: true - name: Set up test database diff --git a/Gemfile b/Gemfile index 11f34419..7ec1c3f6 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,12 @@ rails = case rails_version gem "rails", rails +devise_version = ENV["DEVISE_VERSION"] +gem "devise", devise_version if devise_version + +rotp_version = ENV["ROTP_VERSION"] +gem "rotp", rotp_version if rotp_version + ruby_version = Gem::Version.new(RUBY_VERSION) if ruby_version >= Gem::Version.new('2.2.0') diff --git a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb index 3cf6f90d..1ae9d798 100644 --- a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +++ b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb @@ -105,6 +105,19 @@ def do_invoke(code, user) it_behaves_like 'authenticate_totp', GuestUser.new it_behaves_like 'authenticate_totp', EncryptedUser.new + + it 'persists the verification timestamp and rejects the same code after reload' do + instance = create_user('not_encrypted') + instance.update!(otp_secret_key: '2z6hxkdwi3uvrnpn') + code = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length).totp_code + + expect(instance.authenticate_totp(code)).to eq(true) + instance.save! + + instance.reload + expect(instance.totp_timestamp).to be_a(Time) + expect(instance.authenticate_totp(code)).to eq(false) + end end describe '#send_two_factor_authentication_code' do diff --git a/spec/lib/two_factor_authentication/routes_spec.rb b/spec/lib/two_factor_authentication/routes_spec.rb new file mode 100644 index 00000000..55571760 --- /dev/null +++ b/spec/lib/two_factor_authentication/routes_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' + +describe 'two-factor authentication routes', type: :routing do + it 'routes resend_code through the collection endpoint' do + expect(get: '/users/two_factor_authentication/resend_code').to route_to( + controller: 'devise/two_factor_authentication', + action: 'resend_code' + ) + end +end From 88a77e92f8fcc8961b245aa90f072e6093e8e9eb Mon Sep 17 00:00:00 2001 From: relsett Date: Mon, 24 Aug 2026 17:27:28 +0300 Subject: [PATCH 14/16] LT-53534: avoid duplicate CI runs --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5714e923..bd054ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,14 @@ name: CI on: push: + branches: + - master pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read From fb433d4ddee9e62fd60a77bb46739889f04e267e Mon Sep 17 00:00:00 2001 From: relsett Date: Mon, 24 Aug 2026 17:31:59 +0300 Subject: [PATCH 15/16] LT-53534: make controller specs order independent --- spec/support/controller_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index 2ca3a31f..cd9170e4 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -11,6 +11,7 @@ def sign_in(user = create_user('not_encrypted')) config.include ControllerHelper, type: :controller config.before(:example, type: :controller) do - @request.env['devise.mapping'] = Devise.mappings[:user] + Rails.application.reload_routes! unless Devise.mappings.key?(:user) + @request.env['devise.mapping'] = Devise.mappings.fetch(:user) end end From 3b07a184ced8c12ff8b3408b24e432344a807323 Mon Sep 17 00:00:00 2001 From: relsett Date: Mon, 24 Aug 2026 17:37:04 +0300 Subject: [PATCH 16/16] LT-53534: cover encrypted TOTP continuity --- .../models/two_factor_authenticatable_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb index 1ae9d798..d72c8e1a 100644 --- a/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +++ b/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb @@ -118,6 +118,16 @@ def do_invoke(code, user) expect(instance.totp_timestamp).to be_a(Time) expect(instance.authenticate_totp(code)).to eq(false) end + + it 'decrypts a secret encrypted before the Devise 5 upgrade' do + allow(Devise).to receive(:otp_secret_encryption_key).and_return('a' * 32) + instance = EncryptedUser.new + instance.encrypted_otp_secret_key = "qqtceBScHArOXNFRTZfNyDih+kzYDujh7emlkGi4V6A=\n" + instance.encrypted_otp_secret_key_iv = "ezgScHq7FcShFtQ2WYPP2g==\n" + instance.encrypted_otp_secret_key_salt = "_NemuOAzhuv7qvoPP3RVyBA==\n" + + expect(instance.otp_secret_key).to eq('JBSWY3DPEHPK3PXP') + end end describe '#send_two_factor_authentication_code' do