diff --git a/app/controllers/concerns/ab_testing_concern.rb b/app/controllers/concerns/ab_testing_concern.rb index 16eb6bb2769..6353360e4d5 100644 --- a/app/controllers/concerns/ab_testing_concern.rb +++ b/app/controllers/concerns/ab_testing_concern.rb @@ -4,7 +4,7 @@ module AbTestingConcern # @param [Symbol] test Name of the test, which should correspond to an A/B test defined in # # config/initializer/ab_tests.rb. # @return [Symbol,nil] Bucket to use for the given test, or nil if the test is not active. - def ab_test_bucket(test_name) + def ab_test_bucket(test_name, user: current_user) test = AbTests.all[test_name] raise "Unknown A/B test: #{test_name}" unless test @@ -12,7 +12,7 @@ def ab_test_bucket(test_name) request:, service_provider: current_sp&.issuer, session:, - user: current_user, + user:, user_session:, ) end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index ee9205870ae..f88d1424409 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -9,6 +9,7 @@ class SessionsController < Devise::SessionsController include Api::CsrfTokenConcern include ForcedReauthenticationConcern include NewDeviceConcern + include AbTestingConcern rescue_from ActionController::InvalidAuthenticityToken, with: :redirect_to_signin @@ -41,7 +42,7 @@ def create handle_valid_authentication ensure handle_invalid_authentication if rate_limit_password_failure && !current_user - track_authentication_attempt(auth_params[:email]) + track_authentication_attempt end def destroy @@ -97,13 +98,24 @@ def locked_out_time_remaining def valid_captcha_result? return @valid_captcha_result if defined?(@valid_captcha_result) - @valid_captcha_result = SignInRecaptchaForm.new(**recaptcha_form_args).submit( - email: auth_params[:email], + @valid_captcha_result = recaptcha_form.submit( recaptcha_token: params.require(:user)[:recaptcha_token], - device_cookie: cookies[:device], ).success? end + def recaptcha_form + @recaptcha_form ||= SignInRecaptchaForm.new( + email: auth_params[:email], + device_cookie: cookies[:device], + ab_test_bucket: ab_test_bucket(:RECAPTCHA_SIGN_IN, user: user_from_params), + **recaptcha_form_args, + ) + end + + def captcha_validation_performed? + !recaptcha_form.exempt? + end + def process_failed_captcha sign_out(:user) warden.lock! @@ -140,6 +152,11 @@ def check_user_needs_redirect end end + def user_from_params + return @user_from_params if defined?(@user_from_params) + @user_from_params = User.find_with_email(auth_params[:email]) + end + def auth_params params.require(:user).permit(:email, :password) end @@ -169,14 +186,15 @@ def handle_valid_authentication user_id: current_user.id, email: auth_params[:email], ) + user_session[:captcha_validation_performed_at_sign_in] = captcha_validation_performed? user_session[:platform_authenticator_available] = params[:platform_authenticator_available] == 'true' check_password_compromised redirect_to next_url_after_valid_authentication end - def track_authentication_attempt(email) - user = User.find_with_email(email) || AnonymousUser.new + def track_authentication_attempt + user = user_from_params || AnonymousUser.new success = current_user.present? && !user_locked_out?(user) && valid_captcha_result? analytics.email_and_password_auth( @@ -184,6 +202,7 @@ def track_authentication_attempt(email) user_id: user.uuid, user_locked_out: user_locked_out?(user), rate_limited: rate_limited?, + captcha_validation_performed: captcha_validation_performed?, valid_captcha_result: valid_captcha_result?, bad_password_count: session[:bad_password_count].to_i, sp_request_url_present: sp_session[:request_url].present?, @@ -202,7 +221,7 @@ def rate_limited? def rate_limiter return @rate_limiter if defined?(@rate_limiter) - user = User.find_with_email(auth_params[:email]) + user = user_from_params return @rate_limiter = nil unless user @rate_limiter = RateLimiter.new( rate_limit_type: :sign_in_user_id_per_ip, diff --git a/app/forms/sign_in_recaptcha_form.rb b/app/forms/sign_in_recaptcha_form.rb index 206e742ea70..5fe5605074f 100644 --- a/app/forms/sign_in_recaptcha_form.rb +++ b/app/forms/sign_in_recaptcha_form.rb @@ -5,24 +5,37 @@ class SignInRecaptchaForm RECAPTCHA_ACTION = 'sign_in' - attr_reader :form_class, :form_args, :email, :recaptcha_token, :device_cookie + attr_reader :form_class, :form_args, :email, :recaptcha_token, :device_cookie, :ab_test_bucket validate :validate_recaptcha_result - def initialize(form_class: RecaptchaForm, **form_args) + def initialize( + email:, + device_cookie:, + ab_test_bucket:, + form_class: RecaptchaForm, + **form_args + ) + @email = email + @device_cookie = device_cookie + @ab_test_bucket = ab_test_bucket @form_class = form_class @form_args = form_args end - def submit(email:, recaptcha_token:, device_cookie:) - @email = email + def submit(recaptcha_token:) @recaptcha_token = recaptcha_token - @device_cookie = device_cookie success = valid? FormResponse.new(success:, errors:, serialize_error_details_only: true) end + def exempt? + IdentityConfig.store.sign_in_recaptcha_score_threshold.zero? || + ab_test_bucket != :sign_in_recaptcha || + device.present? + end + private def validate_recaptcha_result @@ -35,7 +48,7 @@ def device end def score_threshold - if IdentityConfig.store.sign_in_recaptcha_score_threshold.zero? || device.present? + if exempt? 0.0 else IdentityConfig.store.sign_in_recaptcha_score_threshold diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d1a2b002959..fe682f678fe 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -430,7 +430,8 @@ def edit_password_visit(required_password_change: false, **extra) # @param [String] user_id # @param [Boolean] user_locked_out if the user is currently locked out of their second factor # @param [Boolean] rate_limited Whether the user has exceeded user IP rate limiting - # @param [Boolean] valid_captcha_result Whether user passed the reCAPTCHA check + # @param [Boolean] valid_captcha_result Whether user passed the reCAPTCHA check or was exempt + # @param [Boolean] captcha_validation_performed Whether a reCAPTCHA check was performed # @param [String] bad_password_count represents number of prior login failures # @param [Boolean] sp_request_url_present if was an SP request URL in the session # @param [Boolean] remember_device if the remember device cookie was present @@ -443,6 +444,7 @@ def email_and_password_auth( user_locked_out:, rate_limited:, valid_captcha_result:, + captcha_validation_performed:, bad_password_count:, sp_request_url_present:, remember_device:, @@ -456,6 +458,7 @@ def email_and_password_auth( user_locked_out:, rate_limited:, valid_captcha_result:, + captcha_validation_performed:, bad_password_count:, sp_request_url_present:, remember_device:, diff --git a/config/application.yml.default b/config/application.yml.default index 039fd0d3300..f42688da342 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -332,6 +332,7 @@ short_term_phone_otp_max_attempt_window_in_seconds: 10 short_term_phone_otp_max_attempts: 2 show_unsupported_passkey_platform_authentication_setup: false show_user_attribute_deprecation_warnings: false +sign_in_recaptcha_percent_tested: 0 sign_in_recaptcha_score_threshold: 0.0 sign_in_user_id_per_ip_attempt_window_exponential_factor: 1.1 sign_in_user_id_per_ip_attempt_window_in_minutes: 720 @@ -428,6 +429,7 @@ development: secret_key_base: development_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 show_unsupported_passkey_platform_authentication_setup: true + sign_in_recaptcha_percent_tested: 100 sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' socure_webhook_secret_key: 'secret-key' diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 70fdddd95c2..7150e1d1e14 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -62,4 +62,21 @@ def self.all ) do |service_provider:, session:, user:, user_session:, **| document_capture_session_uuid_discriminator(service_provider:, session:, user:, user_session:) end.freeze + + RECAPTCHA_SIGN_IN = AbTest.new( + experiment_name: 'reCAPTCHA at Sign-In', + should_log: [ + 'Email and Password Authentication', + 'IdV: doc auth verify proofing results', + 'reCAPTCHA verify result received', + :idv_enter_password_submitted, + ].to_set, + buckets: { sign_in_recaptcha: IdentityConfig.store.sign_in_recaptcha_percent_tested }, + ) do |user:, user_session:, **| + if user_session&.[](:captcha_validation_performed_at_sign_in) == false + nil + else + user&.uuid + end + end.freeze end diff --git a/lib/ab_test.rb b/lib/ab_test.rb index 840cd1cc828..1cc266c215f 100644 --- a/lib/ab_test.rb +++ b/lib/ab_test.rb @@ -60,6 +60,8 @@ def bucket(request:, service_provider:, session:, user:, user_session:) def include_in_analytics_event?(event_name) if should_log.is_a?(Regexp) should_log.match?(event_name) + elsif should_log.respond_to?(:include?) + should_log.include?(event_name) elsif !should_log.nil? raise 'Unexpected value used for should_log' else diff --git a/lib/identity_config.rb b/lib/identity_config.rb index cfa409bbd2f..eacbfd662c3 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -382,6 +382,7 @@ def self.store config.add(:sign_in_user_id_per_ip_attempt_window_in_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_attempt_window_max_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_max_attempts, type: :integer) + config.add(:sign_in_recaptcha_percent_tested, type: :integer) config.add(:sign_in_recaptcha_score_threshold, type: :float) config.add(:skip_encryption_allowed_list, type: :json) config.add(:socure_webhook_enabled, type: :boolean) diff --git a/spec/config/initializers/ab_tests_spec.rb b/spec/config/initializers/ab_tests_spec.rb index f858344346d..975e12b1c37 100644 --- a/spec/config/initializers/ab_tests_spec.rb +++ b/spec/config/initializers/ab_tests_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe AbTests do + include AbTestsHelper + describe '#all' do it 'returns all registered A/B tests' do expect(AbTests.all.values).to all(be_kind_of(AbTest)) @@ -157,10 +159,64 @@ it_behaves_like 'an A/B test that uses document_capture_session_uuid as a discriminator' end - def reload_ab_tests - AbTests.all.each do |(name, _)| - AbTests.send(:remove_const, name) + describe 'RECAPTCHA_SIGN_IN' do + let(:user) { nil } + let(:user_session) { {} } + + subject(:bucket) do + AbTests::RECAPTCHA_SIGN_IN.bucket( + request: nil, + service_provider: nil, + session: nil, + user:, + user_session:, + ) + end + + context 'when A/B test is disabled' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(0) + reload_ab_tests + end + + context 'when it would otherwise assign a bucket' do + let(:user) { build(:user) } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + end + + context 'when A/B test is enabled' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(100) + reload_ab_tests + end + + context 'with no associated user' do + let(:user) { nil } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + + context 'with an associated user' do + let(:user) { build(:user) } + + it 'returns a bucket' do + expect(bucket).not_to be_nil + end + + context 'with user session indicating recaptcha was not performed at sign-in' do + let(:user_session) { { captcha_validation_performed_at_sign_in: false } } + + it 'does not return a bucket' do + expect(bucket).to be_nil + end + end + end end - load('config/initializers/ab_tests.rb') end end diff --git a/spec/controllers/concerns/ab_testing_concern_spec.rb b/spec/controllers/concerns/ab_testing_concern_spec.rb index 94898cd827e..cb5e546a064 100644 --- a/spec/controllers/concerns/ab_testing_concern_spec.rb +++ b/spec/controllers/concerns/ab_testing_concern_spec.rb @@ -68,5 +68,21 @@ end.to raise_error RuntimeError, 'Unknown A/B test: NOT_A_REAL_TEST' end end + + context 'with user keyword argument' do + let(:other_user) { build(:user) } + + it 'returns bucket determined using given user' do + expect(ab_test).to receive(:bucket).with( + user: other_user, + request:, + service_provider: service_provider.issuer, + session:, + user_session:, + ).and_call_original + + subject.ab_test_bucket(:TEST_TEST, user: other_user) + end + end end end diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 387df9187b0..9d9a42732f2 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Users::SessionsController, devise: true do include ActionView::Helpers::DateHelper include ActionView::Helpers::UrlHelper + include AbTestsHelper let(:mock_valid_site) { 'http://example.com' } @@ -55,6 +56,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -128,6 +130,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -211,6 +214,7 @@ user_locked_out: false, rate_limited: true, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 8, sp_request_url_present: false, remember_device: false, @@ -233,6 +237,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: false, remember_device: false, @@ -253,6 +258,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: false, remember_device: false, @@ -277,45 +283,50 @@ user_locked_out: true, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, ) end - it 'tracks unsuccessful authentication for failed reCAPTCHA' do - user = create(:user, :fully_registered) + context 'with reCAPTCHA validation enabled' do + before do + allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) + allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + allow(controller).to receive(:ab_test_bucket).with(:RECAPTCHA_SIGN_IN, kind_of(Hash)). + and_return(:sign_in_recaptcha) + end - allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) - allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) - allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) - stub_analytics + it 'tracks unsuccessful authentication for failed reCAPTCHA' do + user = create(:user, :fully_registered) - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + stub_analytics - expect(@analytics).to have_logged_event( - 'Email and Password Authentication', - success: false, - user_id: user.uuid, - user_locked_out: false, - rate_limited: false, - valid_captcha_result: false, - bad_password_count: 0, - remember_device: false, - sp_request_url_present: false, - ) - end + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } - it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do - user = create(:user, :fully_registered) + expect(@analytics).to have_logged_event( + 'Email and Password Authentication', + success: false, + user_id: user.uuid, + user_locked_out: false, + rate_limited: false, + valid_captcha_result: false, + captcha_validation_performed: true, + bad_password_count: 0, + remember_device: false, + sp_request_url_present: false, + ) + end - allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) - allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) - allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do + user = create(:user, :fully_registered) - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } - expect(response).to redirect_to sign_in_security_check_failed_url + expect(response).to redirect_to sign_in_security_check_failed_url + end end it 'tracks count of multiple unsuccessful authentication attempts' do @@ -335,6 +346,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 2, sp_request_url_present: false, remember_device: false, @@ -354,6 +366,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 1, sp_request_url_present: true, remember_device: false, @@ -525,6 +538,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: false, @@ -650,6 +664,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: true, @@ -677,6 +692,7 @@ user_locked_out: false, rate_limited: false, valid_captcha_result: true, + captcha_validation_performed: false, bad_password_count: 0, sp_request_url_present: false, remember_device: true, diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 215c19fe083..0cd487c1a84 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -9,6 +9,7 @@ include SpAuthHelper include IdvHelper include DocAuthHelper + include AbTestsHelper context 'service provider is on the ialmax allow list' do before do @@ -885,6 +886,12 @@ allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(100) + reload_ab_tests + end + + after do + reload_ab_tests end it 'redirects user to security check failed page' do diff --git a/spec/forms/sign_in_recaptcha_form_spec.rb b/spec/forms/sign_in_recaptcha_form_spec.rb index 65c7e0328f4..6887ab3b187 100644 --- a/spec/forms/sign_in_recaptcha_form_spec.rb +++ b/spec/forms/sign_in_recaptcha_form_spec.rb @@ -1,15 +1,24 @@ require 'rails_helper' +require 'query_tracker' RSpec.describe SignInRecaptchaForm do let(:user) { create(:user, :with_authenticated_device) } let(:score_threshold_config) { 0.2 } let(:analytics) { FakeAnalytics.new } let(:email) { user.email } + let(:ab_test_bucket) { :sign_in_recaptcha } let(:recaptcha_token) { 'token' } let(:device_cookie) { Random.hex } let(:score) { 1.0 } subject(:form) do - described_class.new(form_class: RecaptchaMockForm, analytics:, score:) + described_class.new( + email:, + device_cookie:, + ab_test_bucket:, + form_class: RecaptchaMockForm, + analytics:, + score:, + ) end before do allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold). @@ -30,12 +39,15 @@ ). and_return(recaptcha_form) - form.submit(email:, recaptcha_token:, device_cookie:) + form.submit(recaptcha_token:) end context 'with custom recaptcha form class' do subject(:form) do described_class.new( + email:, + device_cookie:, + ab_test_bucket:, analytics:, form_class: RecaptchaForm, ) @@ -49,13 +61,48 @@ expect(RecaptchaForm).to receive(:new).and_return(recaptcha_form) expect(recaptcha_form).to receive(:submit) - form.submit(email:, recaptcha_token:, device_cookie:) + form.submit(recaptcha_token:) + end + end + + describe '#exempt?' do + subject(:exempt?) { form.exempt? } + + it { is_expected.to eq(false) } + + context 'when not part of a/b test' do + let(:ab_test_bucket) { nil } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(false) } + end + + context 'score threshold configured at zero' do + let(:score_threshold_config) { 0.0 } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(false) } + end + + context 'existing device for user' do + let(:device_cookie) { user.devices.first.cookie_uuid } + + it { is_expected.to eq(true) } + + it { expect(queries_database?).to eq(true) } + end + + def queries_database? + user + QueryTracker.track { exempt? }.present? end end describe '#submit' do let(:recaptcha_form_success) { false } - subject(:response) { form.submit(email:, recaptcha_token:, device_cookie:) } + subject(:response) { form.submit(recaptcha_token:) } context 'recaptcha form validates as unsuccessful' do let(:score) { 0.0 } @@ -68,6 +115,14 @@ end end + context 'when not part of a/b test' do + let(:ab_test_bucket) { nil } + + it 'is successful' do + expect(response.to_h).to eq(success: true) + end + end + context 'new device for user' do it 'is unsuccessful with errors from recaptcha validation' do expect(response.to_h).to eq( diff --git a/spec/lib/ab_test_spec.rb b/spec/lib/ab_test_spec.rb index 7226db1b171..b6df14a9009 100644 --- a/spec/lib/ab_test_spec.rb +++ b/spec/lib/ab_test_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe AbTest do - subject do + subject(:ab_test) do AbTest.new( experiment_name: 'test', buckets:, @@ -33,7 +33,7 @@ let(:user_session) { {} } let(:bucket) do - subject.bucket( + ab_test.bucket( request:, service_provider:, session:, @@ -46,7 +46,7 @@ it 'divides random uuids into the buckets with no automatic default' do results = {} 1000.times do - b = subject.bucket( + b = ab_test.bucket( request:, service_provider:, session:, @@ -86,7 +86,7 @@ build(:user, uuid: 'some-random-uuid') end it 'uses uuid as discriminator' do - expect(subject).to receive(:percent).with('some-random-uuid').once.and_call_original + expect(ab_test).to receive(:percent).with('some-random-uuid').once.and_call_original expect(bucket).to eql(:foo) end end @@ -141,7 +141,7 @@ let(:buckets) { { foo: 'foo', bar: 'bar' } } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + expect { ab_test }.to raise_error(RuntimeError, 'invalid bucket data structure') end end @@ -149,7 +149,7 @@ let(:buckets) { { foo: 60, bar: 60 } } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'bucket percentages exceed 100') + expect { ab_test }.to raise_error(RuntimeError, 'bucket percentages exceed 100') end end @@ -157,7 +157,7 @@ let(:buckets) { [[:foo, 10], [:bar, 20]] } it 'raises a RuntimeError' do - expect { subject }.to raise_error(RuntimeError, 'invalid bucket data structure') + expect { ab_test }.to raise_error(RuntimeError, 'invalid bucket data structure') end end end @@ -165,7 +165,7 @@ describe '#include_in_analytics_event?' do let(:event_name) { 'My cool event' } - let(:return_value) { subject.include_in_analytics_event?(event_name) } + subject(:return_value) { ab_test.include_in_analytics_event?(event_name) } context 'when should_log is nil' do it 'returns true' do @@ -188,6 +188,32 @@ end end + context 'when object responding to `include?` is used' do + context 'and it matches' do + let(:should_log) do + Class.new do + def include?(event_name) + event_name == 'My cool event' + end + end.new + end + + it { is_expected.to eq(true) } + end + + context 'and it does not match' do + let(:should_log) do + Class.new do + def include?(event_name) + event_name == 'My not cool event' + end + end.new + end + + it { is_expected.to eq(false) } + end + end + context 'when true is used' do let(:should_log) { true } it 'raises' do diff --git a/spec/support/ab_tests_helper.rb b/spec/support/ab_tests_helper.rb new file mode 100644 index 00000000000..8f8f4742a14 --- /dev/null +++ b/spec/support/ab_tests_helper.rb @@ -0,0 +1,8 @@ +module AbTestsHelper + def reload_ab_tests + AbTests.all.each do |(name, _)| + AbTests.send(:remove_const, name) + end + load('config/initializers/ab_tests.rb') + end +end