diff --git a/.env.example b/.env.example index a079cf34d..2166cd2e3 100644 --- a/.env.example +++ b/.env.example @@ -68,13 +68,5 @@ SALESFORCE_CONNECT_USER=postgres SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/ SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ -# Pardot Form Handler endpoint for subscription forwarding -PARDOT_SUBSCRIPTION_URL= - -# Cloudflare Turnstile bot protection. This is a test key that always passes. -# Others are available for testing purposes at -# https://developers.cloudflare.com/turnstile/troubleshooting/testing/. -CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA - HOSTNAME=localhost LEARNER_EXPERIENCE_TESTS_DISPATCH_TOKEN=changeme diff --git a/app/controllers/api/schools_controller.rb b/app/controllers/api/schools_controller.rb index 674addc11..a125a4d97 100644 --- a/app/controllers/api/schools_controller.rb +++ b/app/controllers/api/schools_controller.rb @@ -114,10 +114,14 @@ def create_params ) end + # School settings that owners can change (e.g. Feature controls in Classroom). + # To add one, add a column to `schools`, permit it here, expose it in `api/schools/_school.json.jbuilder` + # and add an entry to `featureControls.ts` in editor-standalone. def update_params - params.expect( - school: %i[scratch_enabled] - ) + school_params = params[:school] + return {} unless school_params.is_a?(ActionController::Parameters) + + school_params.permit end end end diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb deleted file mode 100644 index 4ae968d08..000000000 --- a/app/controllers/api/subscriptions_controller.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -module Api - class SubscriptionsController < ApiController - before_action :check_cloudflare_turnstile, only: :create - # Public subscription form endpoint; bot checks and validation are not CanCan resources. - skip_authorization_check only: :create - - def create - # turnstile token is only used for bot check so strip it out before validation and submission - payload = subscription_params.except(:turnstile_token).to_h - errors = validation_errors_for(payload) - - if errors.empty? - submit_result = subscriptions_submitter.call(form_payload: payload) - if submit_result.success? - Rails.logger.info('[subscriptions#create] outcome=success') - render json: { - ok: true, - message: 'Subscription accepted', - subscription: payload - }, status: :ok - else - Rails.logger.warn( - "[subscriptions#create] outcome=failure error_code=#{submit_result.error_code}" - ) - render json: { - ok: false, - error_code: submit_result.error_code, - message: submit_result.message - }, status: submit_result.status - end - else - Rails.logger.warn('[subscriptions#create] outcome=failure error_code=subscription_validation_failed') - render json: { - ok: false, - error_code: 'subscription_validation_failed', - message: 'Subscription rejected due to invalid input', - errors:, - subscription: payload - }, status: :unprocessable_content - end - end - - private - - def check_cloudflare_turnstile - return unless Rails.configuration.x.cloudflare_turnstile.enabled - return if params[:subscription].blank? - - turnstile_check = Subscriptions::TurnstileVerifier.new( - token: params.dig(:subscription, :turnstile_token), - remote_ip: request.remote_ip, - secret_key: Rails.configuration.x.cloudflare_turnstile.secret_key - ) - - return if turnstile_check.passed? - - Rails.logger.warn('[subscriptions#create] outcome=failure error_code=turnstile_verification_failed') - render json: { - ok: false, - error_code: 'turnstile_verification_failed', - message: 'Bot protection check failed. Please try again.' - }, status: :unprocessable_content - end - - def subscription_params - params.expect(subscription: %i[email test_opt_in privacy_policy turnstile_token]) - end - - def subscriptions_submitter - @subscriptions_submitter ||= Subscriptions::PardotFormHandlerSubmitter.new( - endpoint_url: Rails.configuration.x.subscriptions.pardot_form_handler_url - ) - end - - def validation_errors_for(payload) - errors = [] - errors << 'email is required' if payload['email'].blank? - errors << 'email is invalid' if payload['email'].present? && !valid_email?(payload['email']) - errors << 'privacy_policy must be true' unless payload['privacy_policy'] == true - errors - end - - def valid_email?(email) - # Keep codebase-consistent validator and also require a dot in the domain. - email.match?(EmailValidator.regexp) && email.split('@').last&.include?('.') - end - end -end diff --git a/app/models/school.rb b/app/models/school.rb index c58aed310..f6c42491a 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -2,6 +2,8 @@ class School < ApplicationRecord self.ignored_columns += [:creator_id] + # This is a temporary measure to allow the scratch_enabled column to be removed from the database without breaking the application. + self.ignored_columns += [:scratch_enabled] has_many :classes, class_name: :SchoolClass, inverse_of: :school, dependent: :destroy has_many :lessons, dependent: :nullify diff --git a/app/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb deleted file mode 100644 index c3842c2eb..000000000 --- a/app/services/subscriptions/pardot_form_handler_submitter.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -module Subscriptions - class PardotFormHandlerSubmitter - Result = Struct.new(:success?, :status, :error_code, :message, keyword_init: true) - - REQUEST_TIMEOUT_SECONDS = 10 - OPEN_TIMEOUT_SECONDS = 5 - SUCCESS_STATUS_CODE = 200 - ERROR_BODY_PATTERNS = ['error page'].freeze - SUCCESS_BODY_PATTERNS = ['success page'].freeze - - def initialize(endpoint_url:) - @endpoint_url = endpoint_url - end - - def call(form_payload:) - return missing_configuration_result if endpoint_url.blank? - - response = faraday.post(endpoint_url, provider_payload(form_payload)) - Rails.logger.info( - "[subscriptions#provider] status=#{response.status} " \ - "location=#{redirect_location(response)} " \ - "classification=#{classification_for(response)}" - ) - classify_response(response) - rescue Faraday::Error => e - Sentry.capture_exception(e) - Result.new( - success?: false, - status: :service_unavailable, - error_code: 'subscription_provider_unavailable', - message: 'Subscription provider is currently unavailable.' - ) - end - - private - - attr_reader :endpoint_url - - def faraday - @faraday ||= HttpClient.new do |f| - f.request :url_encoded - f.options.timeout = REQUEST_TIMEOUT_SECONDS - f.options.open_timeout = OPEN_TIMEOUT_SECONDS - end - end - - def missing_configuration_result - Result.new( - success?: false, - status: :service_unavailable, - error_code: 'subscription_provider_not_configured', - message: 'Subscription provider endpoint is not configured.' - ) - end - - def provider_payload(form_payload) - # Map internal API contract to Pardot Form Handler external field names. - { - 'email' => form_payload['email'], - 'Tester' => form_payload['test_opt_in'] - }.compact - end - - def classify_response(response) - body = response_body(response) - - return reject_result if error_body?(body) - return reject_result unless response.status == SUCCESS_STATUS_CODE - return Result.new(success?: true) if success_body?(body) - - ambiguous_result - end - - def reject_result - Result.new( - success?: false, - status: :bad_gateway, - error_code: 'subscription_provider_rejected', - message: 'Subscription provider rejected the request.' - ) - end - - def ambiguous_result - Result.new( - success?: false, - status: :bad_gateway, - error_code: 'subscription_provider_ambiguous', - message: 'Subscription provider response was ambiguous.' - ) - end - - def error_body?(body) - ERROR_BODY_PATTERNS.any? { |pattern| body.include?(pattern) } - end - - def success_body?(body) - SUCCESS_BODY_PATTERNS.any? { |pattern| body.include?(pattern) } - end - - def response_body(response) - response.body.to_s.downcase - end - - def redirect_location(response) - response.headers.fetch('location', '').to_s.downcase - end - - def classification_for(response) - body = response_body(response) - - return 'rejected_error_body' if error_body?(body) - return 'rejected_status' unless response.status == SUCCESS_STATUS_CODE - return 'accepted_success_body' if success_body?(body) - - 'ambiguous_response' - end - end -end diff --git a/app/services/subscriptions/turnstile_verifier.rb b/app/services/subscriptions/turnstile_verifier.rb deleted file mode 100644 index 2e933b96c..000000000 --- a/app/services/subscriptions/turnstile_verifier.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Subscriptions - class TurnstileVerifier - API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' - - def initialize(token:, remote_ip:, secret_key:) - @token = token - @remote_ip = remote_ip - @secret_key = secret_key - end - - def passed? - return false if @token.blank? - - response = faraday.post( - API_URL, - { - secret: secret_key, - response: token, - remoteip: remote_ip - } - ) - unless response.success? - Rails.logger.warn("[subscriptions#create] turnstile verification skipped: HTTP #{response.status}") - return true # fail open - end - - JSON.parse(response.body)['success'] == true - rescue Faraday::Error, JSON::ParserError => e - Sentry.capture_exception(e) - Rails.logger.warn("[subscriptions#create] turnstile verification error: #{e.message}") - # Fail open to allow the request through if verification is unavailable - # due to network issues, Cloudflare downtime or malformed responses etc. - true - end - - private - - attr_reader :secret_key, :remote_ip, :token - - def faraday - @faraday ||= HttpClient.new do |f| - f.request :url_encoded - f.options.timeout = 5 - f.options.open_timeout = 2 - end - end - end -end diff --git a/app/views/api/schools/_school.json.jbuilder b/app/views/api/schools/_school.json.jbuilder index 8139342d4..5aee45f78 100644 --- a/app/views/api/schools/_school.json.jbuilder +++ b/app/views/api/schools/_school.json.jbuilder @@ -17,8 +17,7 @@ json.call( :country_code, :verified_at, :created_at, - :updated_at, - :scratch_enabled + :updated_at ) include_roles = local_assigns.fetch(:roles, false) diff --git a/config/application.rb b/config/application.rb index 838921ca0..731adf25f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -71,10 +71,6 @@ class Application < Rails::Application ].compact config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') - config.x.subscriptions.pardot_form_handler_url = ENV.fetch('PARDOT_SUBSCRIPTION_URL', '') - - config.x.cloudflare_turnstile.secret_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SECRET_KEY', nil) - config.x.cloudflare_turnstile.enabled = ENV['CLOUDFLARE_TURNSTILE_SECRET_KEY'].present? config.x.experience_cs.service_api_key = ENV.fetch('EXPERIENCE_CS_API_KEY', nil) if ENV['RAILS_LOG_TO_STDOUT'].present? diff --git a/config/routes.rb b/config/routes.rb index 4da976888..6b6577149 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,7 +116,6 @@ resources :features, only: %i[index] resources :profile_auth_check, only: %i[index] - resources :subscriptions, only: %i[create] get '/join/:join_code', to: 'join#show' post '/join/:join_code', to: 'join#create' diff --git a/lib/tasks/for_education.rake b/lib/tasks/for_education.rake index 5c1edb138..1b5d9a8c3 100644 --- a/lib/tasks/for_education.rake +++ b/lib/tasks/for_education.rake @@ -91,7 +91,6 @@ namespace :for_education do school = create_school(owner_id, TEST_SCHOOL) verify_school(school) - school.update!(scratch_enabled: true) assign_a_teacher(teacher_id, school) school_class = create_school_class(owner_id, school) diff --git a/lib/tasks/test_seeds.rake b/lib/tasks/test_seeds.rake index 642969516..c310c0de5 100644 --- a/lib/tasks/test_seeds.rake +++ b/lib/tasks/test_seeds.rake @@ -86,7 +86,6 @@ namespace :test_seeds do school = create_school(creator_id, TEST_SCHOOL) verify_school(school) - school.update!(scratch_enabled: true) school.school_email_domains.build(domain: 'example.com').save! diff --git a/spec/features/my_school/showing_my_school_spec.rb b/spec/features/my_school/showing_my_school_spec.rb index 568396146..8191e4223 100644 --- a/spec/features/my_school/showing_my_school_spec.rb +++ b/spec/features/my_school/showing_my_school_spec.rb @@ -20,7 +20,7 @@ school_json = school.to_json(only: %i[ id name website reference address_line_1 address_line_2 municipality administrative_area postal_code country_code code verified_at created_at updated_at district_name district_nces_id school_roll_number ]) - expected_data = JSON.parse(school_json, symbolize_names: true).merge(roles: ['owner'], import_in_progress: school.import_in_progress?, auto_join_enabled: school.auto_join_enabled?, scratch_enabled: false) + expected_data = JSON.parse(school_json, symbolize_names: true).merge(roles: ['owner'], import_in_progress: school.import_in_progress?, auto_join_enabled: school.auto_join_enabled?) get('/api/school', headers:) data = JSON.parse(response.body, symbolize_names: true) diff --git a/spec/features/school/updating_a_school_spec.rb b/spec/features/school/updating_a_school_spec.rb index ddedd7697..346696077 100644 --- a/spec/features/school/updating_a_school_spec.rb +++ b/spec/features/school/updating_a_school_spec.rb @@ -7,14 +7,14 @@ authenticated_in_hydra_as(owner) end - let!(:school) { create(:school, scratch_enabled: false) } + let!(:school) { create(:school, name: 'Original name') } let(:headers) { { Authorization: UserProfileMock::TOKEN } } let(:owner) { create(:owner, school:) } let(:params) do { school: { - scratch_enabled: true + name: 'New name' } } end @@ -28,17 +28,27 @@ put("/api/schools/#{school.id}", headers:, params:) data = JSON.parse(response.body, symbolize_names: true) - expect(data[:scratch_enabled]).to be(true) + expect(data[:id]).to eq(school.id) end - it 'responds 404 Not Found when no school exists' do - put('/api/schools/not-a-real-id', headers:) - expect(response).to have_http_status(:not_found) + it 'does not update attributes that are not school settings' do + put("/api/schools/#{school.id}", headers:, params:) + expect(school.reload.name).to eq('Original name') end - it 'responds 400 Bad Request when params are missing' do + it 'responds 200 OK when params are missing' do put("/api/schools/#{school.id}", headers:) - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the school param is not a hash' do + put("/api/schools/#{school.id}", headers:, params: { school: 'not-a-hash' }) + expect(response).to have_http_status(:ok) + end + + it 'responds 404 Not Found when no school exists' do + put('/api/schools/not-a-real-id', headers:) + expect(response).to have_http_status(:not_found) end it 'responds 401 Unauthorized when no token is given' do diff --git a/spec/lib/test_seeds_spec.rb b/spec/lib/test_seeds_spec.rb index 773fdc054..981c0e61b 100644 --- a/spec/lib/test_seeds_spec.rb +++ b/spec/lib/test_seeds_spec.rb @@ -228,11 +228,6 @@ expect(ClassStudent.where(student_id: student_2, school_class_id:)).to exist end - it 'enables scratch for the school' do - school = School.find(school_id) - expect(school.scratch_enabled?).to be true - end - context 'when the seeded school is in the US' do let(:seed_country_code) { 'US' } diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb deleted file mode 100644 index c562db889..000000000 --- a/spec/requests/api/subscriptions_spec.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Subscriptions API' do - describe 'POST /api/subscriptions' do - let(:path) { '/api/subscriptions' } - let(:payload) do - { - subscription: { - email: 'teacher@example.com', - test_opt_in: true, - privacy_policy: true, - turnstile_token: 'test-token' - } - } - end - - let(:submitter_result_success) do - Subscriptions::PardotFormHandlerSubmitter::Result.new(success?: true) - end - let(:submitter_result_failure) do - Subscriptions::PardotFormHandlerSubmitter::Result.new( - success?: false, - status: :service_unavailable, - error_code: 'subscription_provider_unavailable', - message: 'Subscription provider is currently unavailable.' - ) - end - let(:submitter_result_rejected) do - Subscriptions::PardotFormHandlerSubmitter::Result.new( - success?: false, - status: :bad_gateway, - error_code: 'subscription_provider_rejected', - message: 'Subscription provider rejected the request.' - ) - end - let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) } - - before do - allow(Rails.configuration.x.cloudflare_turnstile).to receive(:enabled).and_return(false) - allow(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter) - allow(submitter).to receive(:call).and_return(submitter_result_success) - allow(Sentry).to receive(:capture_exception) - end - - it 'returns success for a valid payload' do - post(path, params: payload, as: :json) - - expect(response).to have_http_status(:ok) - expect(response.parsed_body).to include( - 'ok' => true, - 'message' => 'Subscription accepted' - ) - expect(submitter).to have_received(:call).with( - form_payload: { - 'email' => 'teacher@example.com', - 'test_opt_in' => true, - 'privacy_policy' => true - } - ) - end - - it 'returns 422 when email is missing' do - post(path, params: payload.deep_merge(subscription: { email: '' }), as: :json) - - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['errors']).to include('email is required') - end - - it 'returns 422 when email is malformed' do - post(path, params: payload.deep_merge(subscription: { email: 'invalid-email' }), as: :json) - - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['errors']).to include('email is invalid') - end - - it 'returns 422 when privacy_policy is not true' do - post(path, params: payload.deep_merge(subscription: { privacy_policy: false }), as: :json) - - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['errors']).to include('privacy_policy must be true') - end - - it 'returns 400 when subscription params are missing' do - post(path, params: {}, as: :json) - - expect(response).to have_http_status(:bad_request) - end - - it 'returns provider error status/message when provider submission fails' do - allow(submitter).to receive(:call).and_return(submitter_result_failure) - - post(path, params: payload, as: :json) - - expect(response).to have_http_status(:service_unavailable) - expect(response.parsed_body).to include( - 'ok' => false, - 'error_code' => 'subscription_provider_unavailable', - 'message' => 'Subscription provider is currently unavailable.' - ) - end - - it 'returns provider rejection shape when provider rejects request' do - allow(submitter).to receive(:call).and_return(submitter_result_rejected) - - post(path, params: payload, as: :json) - - expect(response).to have_http_status(:bad_gateway) - expect(response.parsed_body).to include( - 'ok' => false, - 'error_code' => 'subscription_provider_rejected', - 'message' => 'Subscription provider rejected the request.' - ) - end - - describe 'Cloudflare Turnstile integration' do - let(:turnstile_check) { instance_double(Subscriptions::TurnstileVerifier, passed?: true) } - - before do - allow(Rails.configuration.x.cloudflare_turnstile).to receive_messages( - enabled: true, - secret_key: 'test-secret' - ) - allow(Subscriptions::TurnstileVerifier).to receive(:new).and_return(turnstile_check) - end - - it 'passes the token, remote IP and secret key to the verifier' do - post(path, params: payload, as: :json) - - expect(Subscriptions::TurnstileVerifier).to have_received(:new).with( - token: 'test-token', - remote_ip: '127.0.0.1', - secret_key: 'test-secret' - ) - end - - context 'when the turnstile check passes' do - it 'allows the request through' do - post(path, params: payload, as: :json) - - expect(response).to have_http_status(:ok) - expect(response.parsed_body['ok']).to be(true) - end - end - - context 'when the turnstile check fails' do - before { allow(turnstile_check).to receive(:passed?).and_return(false) } - - it 'returns 422 with turnstile_verification_failed error code' do - post(path, params: payload, as: :json) - - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') - end - end - end - end -end diff --git a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb deleted file mode 100644 index 72efaa829..000000000 --- a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Subscriptions::PardotFormHandlerSubmitter do - let(:endpoint_url) { 'https://example.test/form-handler' } - let(:submitter) { described_class.new(endpoint_url:) } - let(:connection) { instance_double(Faraday::Connection) } - - let(:payload) do - { - 'email' => 'teacher@example.com', - 'test_opt_in' => true, - 'privacy_policy' => true - } - end - - let(:headers) { {} } - let(:response_body) { '' } - let(:response_status) { 200 } - let(:response) { instance_double(Faraday::Response, status: response_status, body: response_body, headers:) } - - before do - allow(submitter).to receive(:faraday).and_return(connection) - allow(connection).to receive(:post).and_return(response) - allow(Sentry).to receive(:capture_exception) - end - - describe '#call' do - it 'returns success when status 200 and body contains success marker' do - allow(response).to receive(:body).and_return('Cannot find success page to redirect to.') - - result = submitter.call(form_payload: payload) - - expect(result.success?).to be(true) - end - - it 'returns rejected when body contains error marker even with status 200' do - allow(response).to receive(:body).and_return('Cannot find error page to redirect to.') - - result = submitter.call(form_payload: payload) - - expect(result.success?).to be(false) - expect(result.status).to eq(:bad_gateway) - expect(result.error_code).to eq('subscription_provider_rejected') - end - - it 'returns rejected when status is not 200' do - allow(response).to receive(:status).and_return(302) - - result = submitter.call(form_payload: payload) - - expect(result.success?).to be(false) - expect(result.status).to eq(:bad_gateway) - expect(result.error_code).to eq('subscription_provider_rejected') - end - - it 'returns ambiguous when status is 200 and body has no markers' do - allow(response).to receive(:body).and_return('ok') - - result = submitter.call(form_payload: payload) - - expect(result.success?).to be(false) - expect(result.status).to eq(:bad_gateway) - expect(result.error_code).to eq('subscription_provider_ambiguous') - end - - it 'returns unavailable on Faraday::Error' do - allow(connection).to receive(:post).and_raise(Faraday::Error, 'connection failed') - - result = submitter.call(form_payload: payload) - - expect(result.success?).to be(false) - expect(result.status).to eq(:service_unavailable) - expect(result.error_code).to eq('subscription_provider_unavailable') - expect(Sentry).to have_received(:capture_exception) - end - - it 'returns not configured when endpoint_url is blank' do - blank_submitter = described_class.new(endpoint_url: '') - - result = blank_submitter.call(form_payload: payload) - - expect(result.success?).to be(false) - expect(result.status).to eq(:service_unavailable) - expect(result.error_code).to eq('subscription_provider_not_configured') - end - - it 'posts payload mapped to email and Tester only' do - submitter.call(form_payload: payload) - - expect(connection).to have_received(:post).with( - endpoint_url, - { - 'email' => 'teacher@example.com', - 'Tester' => true - } - ) - end - end -end diff --git a/spec/services/subscriptions/turnstile_verifier_spec.rb b/spec/services/subscriptions/turnstile_verifier_spec.rb deleted file mode 100644 index 872d05e60..000000000 --- a/spec/services/subscriptions/turnstile_verifier_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Subscriptions::TurnstileVerifier do - let(:secret_key) { 'test-secret' } - let(:remote_ip) { '127.0.0.1' } - let(:token) { 'test-token' } - let(:verifier) { described_class.new(token:, remote_ip:, secret_key:) } - let(:connection) { instance_double(Faraday::Connection) } - let(:response) do - instance_double(Faraday::Response, success?: true, status: 200, body: { success: true }.to_json) - end - - before do - allow(verifier).to receive(:faraday).and_return(connection) - allow(connection).to receive(:post).and_return(response) - allow(Sentry).to receive(:capture_exception) - end - - describe '#passed?' do - it 'posts to Cloudflare siteverify with the correct params' do - verifier.passed? - - expect(connection).to have_received(:post).with( - described_class::API_URL, - { secret: secret_key, response: token, remoteip: remote_ip } - ) - end - - context 'when turnstile token is missing' do - let(:token) { '' } - - it 'returns false without calling Cloudflare' do - expect(verifier.passed?).to be(false) - expect(connection).not_to have_received(:post) - end - end - - context 'when turnstile token is valid' do - it { expect(verifier.passed?).to be(true) } - end - - context 'when Cloudflare rejects the token' do - let(:response) do - instance_double(Faraday::Response, success?: true, status: 200, body: { success: false }.to_json) - end - - it { expect(verifier.passed?).to be(false) } - end - - context 'when Cloudflare returns a server error' do - let(:response) do - instance_double(Faraday::Response, success?: false, status: 500, body: 'Internal Server Error') - end - - it { expect(verifier.passed?).to be(true) } - end - - context 'when Cloudflare returns malformed JSON' do - let(:response) do - instance_double(Faraday::Response, success?: true, status: 200, body: 'not-json') - end - - it { expect(verifier.passed?).to be(true) } - - it 'reports the error to Sentry' do - verifier.passed? - expect(Sentry).to have_received(:capture_exception).with(be_a(JSON::ParserError)) - end - end - - context 'when the Cloudflare connection fails' do - before do - allow(connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('connection failed')) - end - - it { expect(verifier.passed?).to be(true) } - - it 'reports the error to Sentry' do - verifier.passed? - expect(Sentry).to have_received(:capture_exception).with(be_a(Faraday::Error)) - end - end - end -end