diff --git a/.env.example b/.env.example index bcf9830dc..3e52404cc 100644 --- a/.env.example +++ b/.env.example @@ -64,4 +64,7 @@ SALESFORCE_CONNECT_PASSWORD=password SALESFORCE_CONNECT_USER=postgres SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/ -SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ \ No newline at end of file +SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ + +# Pardot Form Handler endpoint for subscription forwarding +PARDOT_SUBSCRIPTION_URL= \ No newline at end of file diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb new file mode 100644 index 000000000..9047ec66d --- /dev/null +++ b/app/controllers/api/subscriptions_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Api + class SubscriptionsController < ApiController + def create + payload = subscription_params.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 subscription_params + params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy) + 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/services/subscriptions/pardot_form_handler_submitter.rb b/app/services/subscriptions/pardot_form_handler_submitter.rb new file mode 100644 index 000000000..39ae502f8 --- /dev/null +++ b/app/services/subscriptions/pardot_form_handler_submitter.rb @@ -0,0 +1,120 @@ +# 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 ||= Faraday.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/config/application.rb b/config/application.rb index 6eec39bd9..e749e6d49 100644 --- a/config/application.rb +++ b/config/application.rb @@ -68,5 +68,7 @@ class Application < Rails::Application config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') 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', '') end end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 3dc4dd85c..36007fe3e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -8,7 +8,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do # localhost and test domain origins - origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test? + origins(%r{https?://([a-z0-9-]+\.)?localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test? standard_cors_options end diff --git a/config/routes.rb b/config/routes.rb index 24aaca4bf..c64e4d46d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,7 @@ resources :features, only: %i[index] resources :profile_auth_check, only: %i[index] + resources :subscriptions, only: %i[create] end resource :github_webhooks, only: :create, defaults: { formats: :json } diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb new file mode 100644 index 000000000..6b8c05c2c --- /dev/null +++ b/spec/requests/api/subscriptions_spec.rb @@ -0,0 +1,114 @@ +# 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 + } + } + 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(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter) + allow(submitter).to receive(:call).and_return(submitter_result_success) + 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 + end +end diff --git a/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb new file mode 100644 index 000000000..72efaa829 --- /dev/null +++ b/spec/services/subscriptions/pardot_form_handler_submitter_spec.rb @@ -0,0 +1,101 @@ +# 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