Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions app/forms/new_phone_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,9 @@ def validate_not_premium_rate
end

def validate_recaptcha_token
return if !validate_recaptcha_token? || recaptcha_validator.valid?(recaptcha_token)
errors.add(
:recaptcha_token,
I18n.t('errors.messages.invalid_recaptcha_token'),
type: :invalid_recaptcha_token,
)
return if !validate_recaptcha_token?
recaptcha_validator.submit(recaptcha_token)
errors.merge!(recaptcha_validator)
Comment on lines +134 to +135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does errors.merge! work with an entire validator? I'd expect more like

Suggested change
recaptcha_validator.submit(recaptcha_token)
errors.merge!(recaptcha_validator)
recaptcha_validator.submit(recaptcha_token)
errors.merge!(recaptcha_validator.errors)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I initially expected as well, but it error'd because ActiveModel::Errors doesn't have an errors method; it's expecting the model, not the errors.

end

def recaptcha_validator
Expand Down
2 changes: 1 addition & 1 deletion app/services/phone_recaptcha_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class PhoneRecaptchaValidator

attr_reader :parsed_phone, :validator_class, :validator_args

delegate :valid?, :exempt?, to: :validator
delegate :submit, :errors, to: :validator

def initialize(parsed_phone:, validator_class: RecaptchaValidator, **validator_args)
@parsed_phone = parsed_phone
Expand Down
2 changes: 1 addition & 1 deletion app/services/recaptcha_enterprise_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def assessment_url
)
end

def recaptcha_result(recaptcha_token)
def recaptcha_result
response = faraday.post(
assessment_url,
{
Expand Down
2 changes: 1 addition & 1 deletion app/services/recaptcha_mock_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def initialize(score:, **kwargs)

private

def recaptcha_result(_recaptcha_token)
def recaptcha_result
RecaptchaResult.new(success: true, score:)
end
end
33 changes: 25 additions & 8 deletions app/services/recaptcha_validator.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
# frozen_string_literal: true

class RecaptchaValidator
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we rename to Form and move to app/forms as well?

Copy link
Contributor Author

@aduth aduth May 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about that. We probably should, wasn't sure if the amount of noise it might add to this pull request might be worth doing it incrementally (i.e. separate pull request), especially since there's subclasses that might also need to be moved.

I did find that we have a couple other examples of form validators in services (e.g. SamlRequestValidator), though I think those probably ought to be moved as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the app/validator objects don't have the same interface as forms, but yes SamlRequestValidator seems to implement the Form interface

include ActiveModel::Model
include ActionView::Helpers::TranslationHelper

VERIFICATION_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify'
RESULT_ERRORS = ['missing-input-secret', 'invalid-input-secret'].freeze

attr_reader :recaptcha_action,
:recaptcha_token,
:score_threshold,
:analytics,
:extra_analytics_properties

validate :validate_token_exists
validate :validate_recaptcha_result

RecaptchaResult = Struct.new(:success, :score, :errors, :reasons, keyword_init: true) do
alias_method :success?, :success

Expand All @@ -33,20 +40,30 @@ def exempt?
!score_threshold.positive?
end

def valid?(recaptcha_token)
return true if exempt?
return false if recaptcha_token.blank?
result = recaptcha_result(recaptcha_token)
log_analytics(result:)
recaptcha_result_valid?(result)
def submit(recaptcha_token)
@recaptcha_token = recaptcha_token
@recaptcha_result = recaptcha_result if !exempt? && recaptcha_token.present?

log_analytics(result: @recaptcha_result) if @recaptcha_result
FormResponse.new(success: valid?, errors:, serialize_error_details_only: true)
rescue Faraday::Error => error
log_analytics(error:)
true
FormResponse.new(success: true, serialize_error_details_only: true)
end

private

def recaptcha_result(recaptcha_token)
def validate_token_exists
return if exempt? || recaptcha_token.present?
errors.add(:recaptcha_token, :blank, message: t('errors.messages.invalid_recaptcha_token'))
end

def validate_recaptcha_result
return if @recaptcha_result.blank? || recaptcha_result_valid?(@recaptcha_result)
errors.add(:recaptcha_token, :invalid, message: t('errors.messages.invalid_recaptcha_token'))
end

def recaptcha_result
response = faraday.post(
VERIFICATION_ENDPOINT,
URI.encode_www_form(secret: recaptcha_secret_key, response: recaptcha_token),
Expand Down
11 changes: 6 additions & 5 deletions spec/forms/new_phone_form_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
end

context 'with recaptcha enabled' do
let(:valid) { nil }
let(:errors) { ActiveModel::Errors.new(RecaptchaValidator.new) }
let(:validator) { PhoneRecaptchaValidator.new(parsed_phone: nil) }
let(:recaptcha_token) { 'token' }
let(:phone) { '3065550100' }
Expand All @@ -385,13 +385,12 @@

before do
allow(FeatureManagement).to receive(:phone_recaptcha_enabled?).and_return(true)
allow(validator).to receive(:valid?).with(recaptcha_token).and_return(valid)
allow(validator).to receive(:submit).with(recaptcha_token)
allow(validator).to receive(:errors).and_return(errors)
allow(form).to receive(:recaptcha_validator).and_return(validator)
end

context 'with valid recaptcha result' do
let(:valid) { true }

it 'is valid' do
expect(result.success?).to eq(true)
expect(result.errors).to be_blank
Expand All @@ -417,7 +416,9 @@
end

context 'with invalid recaptcha result' do
let(:valid) { false }
before do
errors.add(:recaptcha_token, :fail, message: 'fail')
end

it 'is invalid' do
expect(result.success?).to eq(false)
Expand Down
35 changes: 22 additions & 13 deletions spec/services/phone_recaptcha_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
end

it 'passes instance variables to validator' do
recaptcha_validator = instance_double(RecaptchaValidator, valid?: true)
recaptcha_validator = instance_double(
RecaptchaValidator,
submit: FormResponse.new(success: true),
)
expect(RecaptchaValidator).to receive(:new).
with(
score_threshold: score_threshold_config,
Expand All @@ -26,7 +29,7 @@
).
and_return(recaptcha_validator)

validator.valid?('token')
validator.submit('token')
end

context 'with custom recaptcha validator class' do
Expand All @@ -39,31 +42,37 @@
end

it 'delegates to validator instance of the given class' do
recaptcha_validator = instance_double(RecaptchaMockValidator, valid?: true)
recaptcha_validator = instance_double(
RecaptchaValidator,
submit: FormResponse.new(success: true),
)
expect(RecaptchaMockValidator).to receive(:new).and_return(recaptcha_validator)
expect(recaptcha_validator).to receive(:valid?)
expect(recaptcha_validator).to receive(:submit)

validator.valid?('token')
validator.submit('token')
end
end

describe '#valid?' do
describe '#submit' do
it 'is delegated to recaptcha validator' do
recaptcha_validator = instance_double(RecaptchaValidator, valid?: true)
recaptcha_validator = instance_double(
RecaptchaValidator,
submit: FormResponse.new(success: true),
)
expect(validator).to receive(:validator).and_return(recaptcha_validator)
expect(recaptcha_validator).to receive(:valid?)
expect(recaptcha_validator).to receive(:submit)

validator.valid?('token')
validator.submit('token')
end
end

describe '#exempt?' do
describe '#errors' do
it 'is delegated to recaptcha validator' do
recaptcha_validator = instance_double(RecaptchaValidator, exempt?: true)
recaptcha_validator = instance_double(RecaptchaValidator, errors: ActiveModel::Errors.new({}))
expect(validator).to receive(:validator).and_return(recaptcha_validator)
expect(recaptcha_validator).to receive(:exempt?)
expect(recaptcha_validator).to receive(:errors)

validator.exempt?
validator.errors
end
end

Expand Down
81 changes: 58 additions & 23 deletions spec/services/recaptcha_enterprise_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,21 @@
end
end

describe '#valid?' do
describe '#submit' do
let(:token) { nil }
subject(:valid) { validator.valid?(token) }
subject(:response) { validator.submit(token) }

context 'with exemption' do
before do
allow(validator).to receive(:exempt?).and_return(true)
end

it { expect(valid).to eq(true) }
it 'is successful' do
expect(response.to_h).to eq(success: true)
end

it 'does not log analytics' do
valid
response

expect(analytics).not_to have_logged_event('reCAPTCHA verify result received')
end
Expand All @@ -72,10 +74,15 @@
context 'with missing token' do
let(:token) { nil }

it { expect(valid).to eq(false) }
it 'is unsuccessful with error for blank token' do
expect(response.to_h).to eq(
success: false,
error_details: { recaptcha_token: { blank: true } },
)
end

it 'does not log analytics' do
valid
response

expect(analytics).not_to have_logged_event('reCAPTCHA verify result received')
end
Expand All @@ -84,10 +91,15 @@
context 'with blank token' do
let(:token) { '' }

it { expect(valid).to eq(false) }
it 'is unsuccessful with error for blank token' do
expect(response.to_h).to eq(
success: false,
error_details: { recaptcha_token: { blank: true } },
)
end

it 'does not log analytics' do
valid
response

expect(analytics).not_to have_logged_event('reCAPTCHA verify result received')
end
Expand All @@ -107,10 +119,15 @@
)
end

it { expect(valid).to eq(false) }
it 'is unsuccessful with error for invalid token' do
expect(response.to_h).to eq(
success: false,
error_details: { recaptcha_token: { invalid: true } },
)
end

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand Down Expand Up @@ -140,10 +157,12 @@
)
end

it { expect(valid).to eq(true) }
it 'is successful' do
expect(response.to_h).to eq(success: true)
end

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand All @@ -167,10 +186,12 @@
stub_request(:post, assessment_url).to_timeout
end

it { expect(valid).to eq(true) }
it 'is successful' do
expect(response.to_h).to eq(success: true)
end

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand Down Expand Up @@ -198,10 +219,15 @@
)
end

it { expect(valid).to eq(false) }
it 'is unsuccessful with error for invalid token' do
expect(response.to_h).to eq(
success: false,
error_details: { recaptcha_token: { invalid: true } },
)
end

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand All @@ -222,8 +248,8 @@
let(:token) { 'token' }
let(:score) { score_threshold + 0.1 }

before do
stub_recaptcha_response(
around do |example|
stubbed_request = stub_recaptcha_response(
body: {
tokenProperties: { valid: true, action: },
riskAnalysis: { score:, reasons: ['LOW_CONFIDENCE'] },
Expand All @@ -232,12 +258,16 @@
action:,
token:,
)
example.run
expect(stubbed_request).to have_been_made.once
end

it { expect(valid).to eq(true) }
it 'is successful' do
expect(response.to_h).to eq(success: true)
end

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand Down Expand Up @@ -266,14 +296,19 @@
)
end

it { expect(valid).to eq(false) }
it 'is unsuccessful with error for invalid token' do
expect(response.to_h).to eq(
success: false,
error_details: { recaptcha_token: { invalid: true } },
)
end
end

context 'with extra analytics properties', allowed_extra_analytics: [:extra] do
let(:extra_analytics_properties) { { extra: true } }

it 'logs analytics of the body' do
valid
response

expect(analytics).to have_logged_event(
'reCAPTCHA verify result received',
Expand All @@ -295,7 +330,7 @@
let(:analytics) { nil }

it 'validates gracefully without analytics logging' do
valid
response
end
end
end
Expand Down
Loading