diff --git a/.env.example b/.env.example index bcf9830dc..0b94165c0 100644 --- a/.env.example +++ b/.env.example @@ -64,4 +64,12 @@ 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 Account Engagement API integration for subscriptions +PARDOT_API_SUBSCRIPTION_URL= +PARDOT_BUSINESS_UNIT_ID= +PARDOT_AUTH_URL= +PARDOT_CLIENT_ID= +PARDOT_CLIENT_SECRET= +PARDOT_AUTH_SCOPE= \ 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..37e3e1ef3 --- /dev/null +++ b/app/controllers/api/subscriptions_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Api + class SubscriptionsController < ApiController + def create + payload = subscription_params.to_h + errors = validation_errors_for(payload) + + Rails.logger.warn( + "[subscriptions#create] payload=#{payload.inspect} result=#{errors.empty? ? 'success' : 'failure'}" + ) + + if errors.empty? + submit_result = subscriptions_submitter.call(subscription_payload: payload) + if submit_result.success? + render json: { + ok: true, + message: 'Subscription accepted', + subscription: payload + }, status: :ok + else + render json: { + ok: false, + error_code: submit_result.error_code, + message: submit_result.message + }, status: submit_result.status + end + else + 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::PardotApiSubmitter.new( + subscription_url: Rails.configuration.x.subscriptions.pardot_api_subscription_url, + business_unit_id: Rails.configuration.x.subscriptions.pardot_business_unit_id, + token_provider: subscriptions_token_provider + ) + end + + def subscriptions_token_provider + @subscriptions_token_provider ||= Subscriptions::PardotTokenProvider.new( + auth_url: Rails.configuration.x.subscriptions.pardot_auth_url, + client_id: Rails.configuration.x.subscriptions.pardot_client_id, + client_secret: Rails.configuration.x.subscriptions.pardot_client_secret, + scope: Rails.configuration.x.subscriptions.pardot_auth_scope + ) + 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_api_submitter.rb b/app/services/subscriptions/pardot_api_submitter.rb new file mode 100644 index 000000000..02b38fa16 --- /dev/null +++ b/app/services/subscriptions/pardot_api_submitter.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Subscriptions + class PardotApiSubmitter + Result = Struct.new(:success?, :status, :error_code, :message, keyword_init: true) + + REQUEST_TIMEOUT_SECONDS = 10 + OPEN_TIMEOUT_SECONDS = 5 + + def initialize(subscription_url:, business_unit_id:, token_provider:) + @subscription_url = subscription_url + @business_unit_id = business_unit_id + @token_provider = token_provider + end + + def call(subscription_payload:) + return missing_configuration_result if subscription_url.blank? + + response = post_with_token(subscription_payload, force_refresh: false) + response = post_with_token(subscription_payload, force_refresh: true) if response.status == 401 + + return Result.new(success?: true) if response.success? + + Result.new( + success?: false, + status: :bad_gateway, + error_code: 'subscription_provider_rejected', + message: 'Subscription provider rejected the request.' + ) + rescue Faraday::Error + Result.new( + success?: false, + status: :service_unavailable, + error_code: 'subscription_provider_unavailable', + message: 'Subscription provider is currently unavailable.' + ) + end + + private + + attr_reader :subscription_url, :business_unit_id, :token_provider + + def faraday + @faraday ||= Faraday.new do |f| + 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 configuration is incomplete.' + ) + end + + def post_with_token(subscription_payload, force_refresh:) + token = token_provider.access_token(force_refresh:) + raise Faraday::UnauthorizedError.new('Pardot access token unavailable') if token.blank? + + faraday.post(subscription_url) do |request| + request.headers['Authorization'] = "Bearer #{token}" + request.headers['Pardot-Business-Unit-Id'] = business_unit_id if business_unit_id.present? + request.headers['Content-Type'] = 'application/json' + request.body = subscription_payload.to_json + end + end + end +end diff --git a/app/services/subscriptions/pardot_token_provider.rb b/app/services/subscriptions/pardot_token_provider.rb new file mode 100644 index 000000000..8953cf358 --- /dev/null +++ b/app/services/subscriptions/pardot_token_provider.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Subscriptions + class PardotTokenProvider + CACHE_KEY = 'subscriptions:pardot:access_token' + EXPIRY_SKEW_SECONDS = 60 + REQUEST_TIMEOUT_SECONDS = 10 + OPEN_TIMEOUT_SECONDS = 5 + + def initialize(auth_url:, client_id:, client_secret:, scope: nil) + @auth_url = auth_url + @client_id = client_id + @client_secret = client_secret + @scope = scope + end + + def access_token(force_refresh: false) + return nil if missing_configuration? + + Rails.cache.delete(CACHE_KEY) if force_refresh + + cached_token = Rails.cache.read(CACHE_KEY) + return cached_token if cached_token.present? + + token, expires_in = fetch_token! + ttl = [expires_in.to_i - EXPIRY_SKEW_SECONDS, 1].max + Rails.cache.write(CACHE_KEY, token, expires_in: ttl) + token + rescue Faraday::Error, KeyError, JSON::ParserError + nil + end + + private + + attr_reader :auth_url, :client_id, :client_secret, :scope + + def missing_configuration? + auth_url.blank? || client_id.blank? || client_secret.blank? + end + + def fetch_token! + response = faraday.post(auth_url) do |request| + request.headers['Content-Type'] = 'application/x-www-form-urlencoded' + request.body = { + grant_type: 'client_credentials', + client_id:, + client_secret:, + scope: + }.compact + end + + raise Faraday::BadRequestError.new('Token fetch failed', response:) unless response.success? + + body = JSON.parse(response.body) + [body.fetch('access_token'), body.fetch('expires_in', 3600)] + end + + 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 + end +end diff --git a/config/application.rb b/config/application.rb index e8b482e8c..bdd6282b9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -69,5 +69,12 @@ 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_api_subscription_url = ENV.fetch('PARDOT_API_SUBSCRIPTION_URL', '') + config.x.subscriptions.pardot_business_unit_id = ENV.fetch('PARDOT_BUSINESS_UNIT_ID', '') + config.x.subscriptions.pardot_auth_url = ENV.fetch('PARDOT_AUTH_URL', '') + config.x.subscriptions.pardot_client_id = ENV.fetch('PARDOT_CLIENT_ID', '') + config.x.subscriptions.pardot_client_secret = ENV.fetch('PARDOT_CLIENT_SECRET', '') + config.x.subscriptions.pardot_auth_scope = ENV.fetch('PARDOT_AUTH_SCOPE', '') 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..df82fb529 --- /dev/null +++ b/spec/requests/api/subscriptions_spec.rb @@ -0,0 +1,93 @@ +# 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::PardotApiSubmitter::Result.new(success?: true) + end + let(:submitter_result_failure) do + Subscriptions::PardotApiSubmitter::Result.new( + success?: false, + status: :service_unavailable, + error_code: 'subscription_provider_unavailable', + message: 'Subscription provider is currently unavailable.' + ) + end + let(:submitter) { instance_double(Subscriptions::PardotApiSubmitter) } + + before do + allow(Subscriptions::PardotApiSubmitter).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( + subscription_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 + end +end