Skip to content

Commit ff6615a

Browse files
committed
extract out turntile verifier and update tests
1 parent 21ee612 commit ff6615a

4 files changed

Lines changed: 159 additions & 103 deletions

File tree

app/controllers/api/subscriptions_controller.rb

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ module Api
44
class SubscriptionsController < ApiController
55
before_action :check_cloudflare_turnstile, only: :create
66

7-
API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
8-
97
def create
108
# turnstile token is only used for bot check so strip it out before validation and submission
119
payload = subscription_params.except(:turnstile_token).to_h
@@ -47,7 +45,14 @@ def create
4745
def check_cloudflare_turnstile
4846
return unless Rails.configuration.x.cloudflare_turnstile.enabled
4947
return if params[:subscription].blank?
50-
return if valid_turnstile_token?
48+
49+
turnstile_check = Subscriptions::TurnstileVerifier.new(
50+
token: params.dig(:subscription, :turnstile_token),
51+
remote_ip: request.remote_ip,
52+
secret_key: Rails.configuration.x.cloudflare_turnstile.secret_key
53+
)
54+
55+
return if turnstile_check.passed?
5156

5257
Rails.logger.warn('[subscriptions#create] outcome=failure error_code=turnstile_verification_failed')
5358
render json: {
@@ -57,40 +62,6 @@ def check_cloudflare_turnstile
5762
}, status: :unprocessable_content
5863
end
5964

60-
def valid_turnstile_token?
61-
token = params.dig(:subscription, :turnstile_token)
62-
return false if token.blank?
63-
64-
response = turnstile_connection.post(
65-
API_URL,
66-
{
67-
secret: Rails.configuration.x.cloudflare_turnstile.secret_key,
68-
response: token,
69-
remoteip: request.remote_ip
70-
}
71-
)
72-
unless response.success?
73-
Rails.logger.warn("[subscriptions#create] turnstile verification skipped: HTTP #{response.status}")
74-
return true # fail open
75-
end
76-
77-
JSON.parse(response.body)['success'] == true
78-
rescue Faraday::Error, JSON::ParserError => e
79-
Sentry.capture_exception(e)
80-
Rails.logger.warn("[subscriptions#create] turnstile verification error: #{e.message}")
81-
# Fail open to allow the request through if verification is unavailable
82-
# due to network issues, Cloudflare downtime or malformed responses etc.
83-
true
84-
end
85-
86-
def turnstile_connection
87-
Faraday.new do |f|
88-
f.request :url_encoded
89-
f.options.timeout = 5
90-
f.options.open_timeout = 2
91-
end
92-
end
93-
9465
def subscription_params
9566
params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy, :turnstile_token)
9667
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module Subscriptions
4+
class TurnstileVerifier
5+
API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
6+
7+
def initialize(token:, remote_ip:, secret_key:)
8+
@token = token
9+
@remote_ip = remote_ip
10+
@secret_key = secret_key
11+
end
12+
13+
def passed?
14+
return false if @token.blank?
15+
16+
response = faraday.post(
17+
API_URL,
18+
{
19+
secret: secret_key,
20+
response: token,
21+
remoteip: remote_ip
22+
}
23+
)
24+
unless response.success?
25+
Rails.logger.warn("[subscriptions#create] turnstile verification skipped: HTTP #{response.status}")
26+
return true # fail open
27+
end
28+
29+
JSON.parse(response.body)['success'] == true
30+
rescue Faraday::Error, JSON::ParserError => e
31+
Sentry.capture_exception(e)
32+
Rails.logger.warn("[subscriptions#create] turnstile verification error: #{e.message}")
33+
# Fail open to allow the request through if verification is unavailable
34+
# due to network issues, Cloudflare downtime or malformed responses etc.
35+
true
36+
end
37+
38+
private
39+
40+
attr_reader :secret_key, :remote_ip, :token
41+
42+
def faraday
43+
@faraday ||= Faraday.new do |f|
44+
f.request :url_encoded
45+
f.options.timeout = 5
46+
f.options.open_timeout = 2
47+
end
48+
end
49+
end
50+
end

spec/requests/api/subscriptions_spec.rb

Lines changed: 15 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -115,27 +115,27 @@
115115
end
116116

117117
describe 'Cloudflare Turnstile integration' do
118-
let(:request_url) { Api::SubscriptionsController::API_URL }
119-
let(:turnstile_request_body) { { 'secret' => 'test-secret', 'response' => 'test-token', 'remoteip' => '127.0.0.1' } }
120-
let(:post_params) { payload }
118+
let(:turnstile_check) { instance_double(Subscriptions::TurnstileVerifier, passed?: true) }
121119

122120
before do
123121
allow(Rails.configuration.x.cloudflare_turnstile).to receive_messages(
124122
enabled: true,
125123
secret_key: 'test-secret'
126124
)
125+
allow(Subscriptions::TurnstileVerifier).to receive(:new).and_return(turnstile_check)
127126
end
128127

129-
shared_examples 'turnstile verification failure' do
130-
it 'returns 422 with turnstile_verification_failed error code' do
131-
post(path, params: post_params, as: :json)
128+
it 'passes the token, remote IP and secret key to the verifier' do
129+
post(path, params: payload, as: :json)
132130

133-
expect(response).to have_http_status(:unprocessable_content)
134-
expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed')
135-
end
131+
expect(Subscriptions::TurnstileVerifier).to have_received(:new).with(
132+
token: 'test-token',
133+
remote_ip: '127.0.0.1',
134+
secret_key: 'test-secret'
135+
)
136136
end
137137

138-
shared_examples 'fail-open turnstile response' do
138+
context 'when the turnstile check passes' do
139139
it 'allows the request through' do
140140
post(path, params: payload, as: :json)
141141

@@ -144,66 +144,15 @@
144144
end
145145
end
146146

147-
context 'when turnstile token is missing' do
148-
let(:post_params) { payload.deep_merge(subscription: { turnstile_token: '' }) }
149-
150-
it_behaves_like 'turnstile verification failure'
151-
end
152-
153-
context 'when turnstile verification fails' do
154-
before do
155-
stub_request(:post, request_url)
156-
.with(body: turnstile_request_body)
157-
.to_return(status: 200, body: { success: false }.to_json)
158-
end
159-
160-
it_behaves_like 'turnstile verification failure'
161-
end
162-
163-
context 'when turnstile verification times out' do
164-
before do
165-
stub_request(:post, request_url)
166-
.with(body: turnstile_request_body)
167-
.to_timeout
168-
end
147+
context 'when the turnstile check fails' do
148+
before { allow(turnstile_check).to receive(:passed?).and_return(false) }
169149

170-
it 'allows the request through and reports to Sentry' do
150+
it 'returns 422 with turnstile_verification_failed error code' do
171151
post(path, params: payload, as: :json)
172152

173-
expect(response).to have_http_status(:ok)
174-
expect(response.parsed_body['ok']).to be(true)
175-
expect(Sentry).to have_received(:capture_exception).with(be_a(Faraday::Error))
176-
end
177-
end
178-
179-
context 'when Cloudflare returns a server error' do
180-
before do
181-
stub_request(:post, request_url)
182-
.with(body: turnstile_request_body)
183-
.to_return(status: 500, body: 'Internal Server Error')
184-
end
185-
186-
it_behaves_like 'fail-open turnstile response'
187-
end
188-
189-
context 'when Cloudflare returns malformed JSON' do
190-
before do
191-
stub_request(:post, request_url)
192-
.with(body: turnstile_request_body)
193-
.to_return(status: 200, body: 'not-json')
194-
end
195-
196-
it_behaves_like 'fail-open turnstile response'
197-
end
198-
199-
context 'when turnstile token is valid' do
200-
before do
201-
stub_request(:post, request_url)
202-
.with(body: turnstile_request_body)
203-
.to_return(status: 200, body: { success: true }.to_json)
153+
expect(response).to have_http_status(:unprocessable_content)
154+
expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed')
204155
end
205-
206-
it_behaves_like 'fail-open turnstile response'
207156
end
208157
end
209158
end
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe Subscriptions::TurnstileVerifier do
6+
let(:secret_key) { 'test-secret' }
7+
let(:remote_ip) { '127.0.0.1' }
8+
let(:token) { 'test-token' }
9+
let(:verifier) { described_class.new(token:, remote_ip:, secret_key:) }
10+
let(:connection) { instance_double(Faraday::Connection) }
11+
let(:response) do
12+
instance_double(Faraday::Response, success?: true, status: 200, body: { success: true }.to_json)
13+
end
14+
15+
before do
16+
allow(verifier).to receive(:faraday).and_return(connection)
17+
allow(connection).to receive(:post).and_return(response)
18+
allow(Sentry).to receive(:capture_exception)
19+
end
20+
21+
describe '#passed?' do
22+
it 'posts to Cloudflare siteverify with the correct params' do
23+
verifier.passed?
24+
25+
expect(connection).to have_received(:post).with(
26+
described_class::API_URL,
27+
{ secret: secret_key, response: token, remoteip: remote_ip }
28+
)
29+
end
30+
31+
context 'when turnstile token is missing' do
32+
let(:token) { '' }
33+
34+
it 'returns false without calling Cloudflare' do
35+
expect(verifier.passed?).to be(false)
36+
expect(connection).not_to have_received(:post)
37+
end
38+
end
39+
40+
context 'when turnstile token is valid' do
41+
it { expect(verifier.passed?).to be(true) }
42+
end
43+
44+
context 'when Cloudflare rejects the token' do
45+
let(:response) do
46+
instance_double(Faraday::Response, success?: true, status: 200, body: { success: false }.to_json)
47+
end
48+
49+
it { expect(verifier.passed?).to be(false) }
50+
end
51+
52+
context 'when Cloudflare returns a server error' do
53+
let(:response) do
54+
instance_double(Faraday::Response, success?: false, status: 500, body: 'Internal Server Error')
55+
end
56+
57+
it { expect(verifier.passed?).to be(true) }
58+
end
59+
60+
context 'when Cloudflare returns malformed JSON' do
61+
let(:response) do
62+
instance_double(Faraday::Response, success?: true, status: 200, body: 'not-json')
63+
end
64+
65+
it { expect(verifier.passed?).to be(true) }
66+
67+
it 'reports the error to Sentry' do
68+
verifier.passed?
69+
expect(Sentry).to have_received(:capture_exception).with(be_a(JSON::ParserError))
70+
end
71+
end
72+
73+
context 'when the Cloudflare connection fails' do
74+
before do
75+
allow(connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('connection failed'))
76+
end
77+
78+
it { expect(verifier.passed?).to be(true) }
79+
80+
it 'reports the error to Sentry' do
81+
verifier.passed?
82+
expect(Sentry).to have_received(:capture_exception).with(be_a(Faraday::Error))
83+
end
84+
end
85+
end
86+
end

0 commit comments

Comments
 (0)