diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index bda564c0..c1f95d31 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -67,8 +67,11 @@ module Api submissions = create_submissions(@template, params) - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id }) + @template.account.webhook_urls.with_event('submission.created').each do |webhook_url| + submissions.each do |submission| + SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id, + 'webhook_url_id' => webhook_url.id }) + end end Submissions.send_signature_requests(submissions) @@ -93,7 +96,10 @@ module Api else @submission.update!(archived_at: Time.current) - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id) + @submission.account.webhook_urls.with_event('submission.archived').each do |webhook_url| + SendSubmissionArchivedWebhookRequestJob.perform_async({ 'submission_id' => @submission.id, + 'webhook_url_id' => webhook_url.id }) + end end render json: @submission.as_json(only: %i[id archived_at]) diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index b582095a..e24df6b8 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -13,7 +13,10 @@ module Api SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) - SendFormViewedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id }) + submitter.account.webhook_urls.with_event('form.viewed').each do |webhook_url| + SendFormViewedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id }) + end render json: {} end diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index 52c0c30b..922739a4 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -25,7 +25,10 @@ module Api schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template) - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id) + cloned_template.account.webhook_urls.with_event('template.created').each do |webhook_url| + SendTemplateCreatedWebhookRequestJob.perform_async({ 'template_id' => cloned_template.id, + 'webhook_url_id' => webhook_url.id }) + end render json: Templates::SerializeForApi.call(cloned_template, schema_documents) end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index add4bcf8..c8c43e4a 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -65,7 +65,10 @@ module Api @template.update!(template_params) - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) + @template.account.webhook_urls.with_event('template.updated').each do |webhook_url| + SendTemplateUpdatedWebhookRequestJob.perform_async({ 'template_id' => @template.id, + 'webhook_url_id' => webhook_url.id }) + end render json: @template.as_json(only: %i[id updated_at]) end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index c7fc9ae8..63d0bb3b 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -38,7 +38,10 @@ class StartFormController < ApplicationController if @submitter.save if is_new_record - SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => @submitter.submission.id }) + @submitter.account.webhook_urls.with_event('submission.created').each do |webhook_url| + SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => @submitter.submission_id, + 'webhook_url_id' => webhook_url.id }) + end end redirect_to submit_form_path(@submitter.slug) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 6f92a874..2c47dca7 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -50,9 +50,7 @@ class SubmissionsController < ApplicationController params: params.merge('send_completed_email' => true)) end - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id }) - end + enqueue_submission_created_webhooks(@template, submissions) Submissions.send_signature_requests(submissions) @@ -68,7 +66,10 @@ class SubmissionsController < ApplicationController else @submission.update!(archived_at: Time.current) - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id) + @submission.account.webhook_urls.with_event('submission.archived').each do |webhook_url| + SendSubmissionArchivedWebhookRequestJob.perform_async({ 'submission_id' => @submission.id, + 'webhook_url_id' => webhook_url.id }) + end I18n.t('submission_has_been_archived') end @@ -85,6 +86,15 @@ class SubmissionsController < ApplicationController template.save! end + def enqueue_submission_created_webhooks(template, submissions) + template.account.webhook_urls.with_event('submission.created').each do |webhook_url| + submissions.each do |submission| + SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id, + 'webhook_url_id' => webhook_url.id }) + end + end + end + def submissions_params params.permit(submission: { submitters: [%i[uuid email phone name]] }) end diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index a04f5ca2..1c4e0563 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -25,7 +25,10 @@ class SubmitFormDeclineController < ApplicationController SubmitterMailer.declined_email(submitter, user).deliver_later! end - SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id) + submitter.account.webhook_urls.with_event('form.declined').each do |webhook_url| + SendFormDeclinedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id }) + end redirect_to submit_form_path(submitter.slug) end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index d5c06ace..ae63d3d0 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -66,7 +66,7 @@ class TemplatesController < ApplicationController if @template.save Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) + enqueue_template_created_webhooks(@template) maybe_redirect_to_template(@template) else @@ -77,7 +77,7 @@ class TemplatesController < ApplicationController def update @template.update!(template_params) - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) + enqueue_template_updated_webhooks(@template) head :ok end @@ -128,6 +128,20 @@ class TemplatesController < ApplicationController end end + def enqueue_template_created_webhooks(template) + template.account.webhook_urls.with_event('template.updated').each do |webhook_url| + SendTemplateCreatedWebhookRequestJob.perform_async({ 'template_id' => template.id, + 'webhook_url_id' => webhook_url.id }) + end + end + + def enqueue_template_updated_webhooks(template) + template.account.webhook_urls.with_event('template.updated').each do |webhook_url| + SendTemplateUpdatedWebhookRequestJob.perform_async({ 'template_id' => template.id, + 'webhook_url_id' => webhook_url.id }) + end + end + def load_base_template return if params[:base_template_id].blank? diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index daefcc67..069b316f 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController @template.update!(schema:) - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => @template.id) + enqueue_template_created_webhooks(@template) redirect_to edit_template_path(@template) rescue Templates::CreateAttachments::PdfEncrypted @@ -65,4 +65,11 @@ class TemplatesUploadsController < ApplicationController { files: [file] } end + + def enqueue_template_created_webhooks(template) + template.account.webhook_urls.with_event('template.created').each do |webhook_url| + SendTemplateCreatedWebhookRequestJob.perform_async({ 'template_id' => template.id, + 'webhook_url_id' => webhook_url.id }) + end + end end diff --git a/app/controllers/testing_api_settings_controller.rb b/app/controllers/testing_api_settings_controller.rb index 5eade647..6c34fed7 100644 --- a/app/controllers/testing_api_settings_controller.rb +++ b/app/controllers/testing_api_settings_controller.rb @@ -4,9 +4,8 @@ class TestingApiSettingsController < ApplicationController def index authorize!(:manage, current_user.access_token) - @webhook_config = - current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY) + @webhook_url = current_account.webhook_urls.first_or_initialize - authorize!(:manage, @webhook_config) + authorize!(:manage, @webhook_url) end end diff --git a/app/controllers/webhook_preferences_controller.rb b/app/controllers/webhook_preferences_controller.rb deleted file mode 100644 index d584fdf9..00000000 --- a/app/controllers/webhook_preferences_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class WebhookPreferencesController < ApplicationController - EVENTS = %w[ - form.viewed - form.started - form.completed - form.declined - template.created - template.updated - submission.created - submission.archived - ].freeze - - before_action :load_account_config - authorize_resource :account_config, parent: false - - def create - @account_config.value[account_config_params[:event]] = account_config_params[:value] == '1' - - @account_config.save! - - head :ok - end - - private - - def load_account_config - @account_config = - current_account.account_configs.find_or_initialize_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY) - @account_config.value ||= {} - end - - def account_config_params - params.permit(:event, :value) - end -end diff --git a/app/controllers/webhook_secret_controller.rb b/app/controllers/webhook_secret_controller.rb index cd5bbefa..27f35b26 100644 --- a/app/controllers/webhook_secret_controller.rb +++ b/app/controllers/webhook_secret_controller.rb @@ -1,29 +1,21 @@ # frozen_string_literal: true class WebhookSecretController < ApplicationController - before_action :load_encrypted_config - authorize_resource :encrypted_config, parent: false + load_and_authorize_resource :webhook_url, parent: false - def index; end + def show; end - def create - @encrypted_config.assign_attributes(value: { - encrypted_config_params[:key] => encrypted_config_params[:value] + def update + @webhook_url.update!(secret: { + webhook_secret_params[:key] => webhook_secret_params[:value] }.compact_blank) - @encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete - redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_secret_has_been_saved')) end private - def load_encrypted_config - @encrypted_config = - current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_SECRET_KEY) - end - - def encrypted_config_params - params.require(:encrypted_config).permit(value: %i[key value]).fetch(:value, {}) + def webhook_secret_params + params.require(:webhook_url).permit(secret: %i[key value]).fetch(:secret, {}) end end diff --git a/app/controllers/webhook_settings_controller.rb b/app/controllers/webhook_settings_controller.rb index 475e830f..5482b478 100644 --- a/app/controllers/webhook_settings_controller.rb +++ b/app/controllers/webhook_settings_controller.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true class WebhookSettingsController < ApplicationController - before_action :load_encrypted_config - authorize_resource :encrypted_config, parent: false + before_action :load_webhook_url + authorize_resource :webhook_url, parent: false def show; end def create - @encrypted_config.assign_attributes(encrypted_config_params) + @webhook_url.assign_attributes(webhook_params) - @encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete + @webhook_url.url.present? ? @webhook_url.save! : @webhook_url.delete redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')) end @@ -18,19 +18,18 @@ class WebhookSettingsController < ApplicationController submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, - 'encrypted_config_id' => @encrypted_config.id }) + 'webhook_url_id' => @webhook_url.id }) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent')) end private - def load_encrypted_config - @encrypted_config = - current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY) + def load_webhook_url + @webhook_url = current_account.webhook_urls.first_or_initialize end - def encrypted_config_params - params.require(:encrypted_config).permit(:value) + def webhook_params + params.require(:webhook_url).permit(:url, events: []) end end diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 19c278d6..0b8c3e0b 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -63,24 +63,7 @@ class ProcessSubmitterCompletionJob end def enqueue_completed_webhooks(submitter, is_all_completed: false) - webhook_config = Accounts.load_webhook_config(submitter.account) - - if webhook_config - SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, - 'encrypted_config_id' => webhook_config.id }) - end - - webhook_urls = submitter.account.webhook_urls - - webhook_urls = webhook_urls.where( - Arel::Table.new(:webhook_urls)[:events].matches('%"form.completed"%') - ).or( - webhook_urls.where( - Arel::Table.new(:webhook_urls)[:events].matches('%"submission.completed"%') - ) - ) - - webhook_urls.each do |webhook| + submitter.account.webhook_urls.with_events(%w[form.completed submission.completed]).each do |webhook| if webhook.events.include?('form.completed') SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, 'webhook_url_id' => webhook.id }) diff --git a/app/jobs/send_form_completed_webhook_request_job.rb b/app/jobs/send_form_completed_webhook_request_job.rb index 62c6e1d5..9d6c4c29 100644 --- a/app/jobs/send_form_completed_webhook_request_job.rb +++ b/app/jobs/send_form_completed_webhook_request_job.rb @@ -14,22 +14,23 @@ class SendFormCompletedWebhookRequestJob attempt = params['attempt'].to_i - url, secret = load_url_and_secret(submitter, params) + webhook_url = submitter.account.webhook_urls.find_by(id: params['webhook_url_id']) - return if url.blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('form.completed') Submissions::EnsureResultGenerated.call(submitter) ActiveStorage::Current.url_options = Docuseal.default_url_options resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'form.completed', timestamp: Time.current, data: Submitters::SerializeForWebhook.call(submitter) }.to_json, - **secret.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -45,27 +46,4 @@ class SendFormCompletedWebhookRequestJob }) end end - - def load_url_and_secret(submitter, params) - if params['encrypted_config_id'] - config = EncryptedConfig.find(params['encrypted_config_id']) - - url = config.value - - return if url.blank? - - preferences = Accounts.load_webhook_preferences(submitter.submission.account) - - return if preferences['form.completed'] == false - - secret = EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h - - [url, secret] - elsif params['webhook_url_id'] - webhook_url = submitter.account.webhook_urls.find(params['webhook_url_id']) - - webhook_url.url if webhook_url.events.include?('form.completed') - end - end end diff --git a/app/jobs/send_form_declined_webhook_request_job.rb b/app/jobs/send_form_declined_webhook_request_job.rb index b4f158b6..58ee8640 100644 --- a/app/jobs/send_form_declined_webhook_request_job.rb +++ b/app/jobs/send_form_declined_webhook_request_job.rb @@ -13,26 +13,22 @@ class SendFormDeclinedWebhookRequestJob submitter = Submitter.find(params['submitter_id']) attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(submitter.submission.account) - url = config&.value.presence - return if url.blank? + webhook_url = submitter.submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - preferences = Accounts.load_webhook_preferences(submitter.submission.account) - - return if preferences['form.declined'] == false + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('form.declined') ActiveStorage::Current.url_options = Docuseal.default_url_options resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'form.declined', timestamp: Time.current, data: Submitters::SerializeForWebhook.call(submitter) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -43,6 +39,7 @@ class SendFormDeclinedWebhookRequestJob (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_form_started_webhook_request_job.rb b/app/jobs/send_form_started_webhook_request_job.rb index 747e9338..5a7a6d38 100644 --- a/app/jobs/send_form_started_webhook_request_job.rb +++ b/app/jobs/send_form_started_webhook_request_job.rb @@ -13,26 +13,22 @@ class SendFormStartedWebhookRequestJob submitter = Submitter.find(params['submitter_id']) attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(submitter.submission.account) - url = config&.value.presence - return if url.blank? + webhook_url = submitter.submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - preferences = Accounts.load_webhook_preferences(submitter.submission.account) - - return if preferences['form.started'] == false + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('form.started') ActiveStorage::Current.url_options = Docuseal.default_url_options resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'form.started', timestamp: Time.current, data: Submitters::SerializeForWebhook.call(submitter) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -43,6 +39,7 @@ class SendFormStartedWebhookRequestJob (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_form_viewed_webhook_request_job.rb b/app/jobs/send_form_viewed_webhook_request_job.rb index 391064e3..a47c57b7 100644 --- a/app/jobs/send_form_viewed_webhook_request_job.rb +++ b/app/jobs/send_form_viewed_webhook_request_job.rb @@ -13,26 +13,22 @@ class SendFormViewedWebhookRequestJob submitter = Submitter.find(params['submitter_id']) attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(submitter.submission.account) - url = config&.value.presence - return if url.blank? + webhook_url = submitter.submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - preferences = Accounts.load_webhook_preferences(submitter.submission.account) - - return if preferences['form.viewed'] == false + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('form.viewed') ActiveStorage::Current.url_options = Docuseal.default_url_options resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'form.viewed', timestamp: Time.current, data: Submitters::SerializeForWebhook.call(submitter) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -43,6 +39,7 @@ class SendFormViewedWebhookRequestJob (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_submission_archived_webhook_request_job.rb b/app/jobs/send_submission_archived_webhook_request_job.rb index ff62419e..a0141f50 100644 --- a/app/jobs/send_submission_archived_webhook_request_job.rb +++ b/app/jobs/send_submission_archived_webhook_request_job.rb @@ -14,24 +14,19 @@ class SendSubmissionArchivedWebhookRequestJob attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(submission.account) - url = config&.value.presence + webhook_url = submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - return if url.blank? - - preferences = Accounts.load_webhook_preferences(submission.account) - - return if preferences['submission.archived'].blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived') resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'submission.archived', timestamp: Time.current, data: submission.as_json(only: %i[id archived_at]) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -42,6 +37,7 @@ class SendSubmissionArchivedWebhookRequestJob (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { 'submission_id' => submission.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_submission_completed_webhook_request_job.rb b/app/jobs/send_submission_completed_webhook_request_job.rb index 5a58c514..06ab92c9 100644 --- a/app/jobs/send_submission_completed_webhook_request_job.rb +++ b/app/jobs/send_submission_completed_webhook_request_job.rb @@ -14,21 +14,19 @@ class SendSubmissionCompletedWebhookRequestJob attempt = params['attempt'].to_i - webhook_url = submission.account.webhook_urls.find(params['webhook_url_id']) + webhook_url = submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - url = webhook_url.url if webhook_url.events.include?('submission.completed') - - return if url.blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed') resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'submission.completed', timestamp: Time.current, data: Submissions::SerializeForApi.call(submission) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: submission.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error diff --git a/app/jobs/send_submission_created_webhook_request_job.rb b/app/jobs/send_submission_created_webhook_request_job.rb index aeb3f18f..e43ce493 100644 --- a/app/jobs/send_submission_created_webhook_request_job.rb +++ b/app/jobs/send_submission_created_webhook_request_job.rb @@ -14,24 +14,19 @@ class SendSubmissionCreatedWebhookRequestJob attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(submission.account) - url = config&.value.presence + webhook_url = submission.account.webhook_urls.find_by(id: params['webhook_url_id']) - return if url.blank? - - preferences = Accounts.load_webhook_preferences(submission.account) - - return if preferences['submission.created'].blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created') resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'submission.created', timestamp: Time.current, data: Submissions::SerializeForApi.call(submission) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -42,6 +37,7 @@ class SendSubmissionCreatedWebhookRequestJob (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { 'submission_id' => submission.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_template_created_webhook_request_job.rb b/app/jobs/send_template_created_webhook_request_job.rb index e2d7ac71..4a6cb896 100644 --- a/app/jobs/send_template_created_webhook_request_job.rb +++ b/app/jobs/send_template_created_webhook_request_job.rb @@ -14,24 +14,19 @@ class SendTemplateCreatedWebhookRequestJob attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(template.account) - url = config&.value.presence + webhook_url = template.account.webhook_urls.find_by(id: params['webhook_url_id']) - return if url.blank? - - preferences = Accounts.load_webhook_preferences(template.account) - - return if preferences['template.created'].blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created') resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'template.created', timestamp: Time.current, data: Templates::SerializeForApi.call(template) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -42,6 +37,7 @@ class SendTemplateCreatedWebhookRequestJob (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { 'template_id' => template.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_template_updated_webhook_request_job.rb b/app/jobs/send_template_updated_webhook_request_job.rb index 8d969eb5..e36f53b3 100644 --- a/app/jobs/send_template_updated_webhook_request_job.rb +++ b/app/jobs/send_template_updated_webhook_request_job.rb @@ -14,24 +14,19 @@ class SendTemplateUpdatedWebhookRequestJob attempt = params['attempt'].to_i - config = Accounts.load_webhook_config(template.account) - url = config&.value.presence + webhook_url = template.account.webhook_urls.find_by(id: params['webhook_url_id']) - return if url.blank? - - preferences = Accounts.load_webhook_preferences(template.account) - - return if preferences['template.updated'].blank? + return unless webhook_url + return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated') resp = begin - Faraday.post(url, + Faraday.post(webhook_url.url, { event_type: 'template.updated', timestamp: Time.current, data: Templates::SerializeForApi.call(template) }.to_json, - **EncryptedConfig.find_or_initialize_by(account_id: config.account_id, - key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h, + **webhook_url.secret.to_h, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT) rescue Faraday::Error @@ -42,6 +37,7 @@ class SendTemplateUpdatedWebhookRequestJob (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { 'template_id' => template.id, + 'webhook_url_id' => webhook_url.id, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 1e7e2d61..112ca466 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -35,7 +35,6 @@ class AccountConfig < ApplicationRecord FORM_WITH_CONFETTI_KEY = 'form_with_confetti' FORM_PREFILL_SIGNATURE_KEY = 'form_prefill_signature' ESIGNING_PREFERENCE_KEY = 'esigning_preference' - WEBHOOK_PREFERENCES_KEY = 'webhook_preferences' DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth' FORCE_SSO_AUTH_KEY = 'force_sso_auth' FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf' diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb index 5d2422af..e61923b4 100644 --- a/app/models/encrypted_config.rb +++ b/app/models/encrypted_config.rb @@ -26,9 +26,7 @@ class EncryptedConfig < ApplicationRecord EMAIL_SMTP_KEY = 'action_mailer_smtp', ESIGN_CERTS_KEY = 'esign_certs', TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url', - APP_URL_KEY = 'app_url', - WEBHOOK_URL_KEY = 'webhook_url', - WEBHOOK_SECRET_KEY = 'webhook_secret' + APP_URL_KEY = 'app_url' ].freeze belongs_to :account diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 74d70bb4..18794c82 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -6,6 +6,7 @@ # # id :bigint not null, primary key # events :text not null +# secret :text # sha1 :string not null # url :text not null # created_at :datetime not null @@ -22,15 +23,34 @@ # fk_rails_... (account_id => accounts.id) # class WebhookUrl < ApplicationRecord + EVENTS = %w[ + form.viewed + form.started + form.completed + form.declined + template.created + template.updated + submission.created + submission.archived + ].freeze + belongs_to :account - attribute :events, :string, default: -> { [] } + attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] } serialize :events, coder: JSON + serialize :secret, coder: JSON + + scope :with_event, ->(event) { with_events([event]) } + scope :with_events, lambda { |events| + where(events.map do |event| + Arel::Table.new(:webhook_urls)[:events].matches("%\"#{event}\"%") + end.reduce(:or)) + } before_validation :set_sha1 - encrypts :url + encrypts :url, :secret def set_sha1 self.sha1 = Digest::SHA1.hexdigest(url) diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index f70791c3..15e58efb 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -56,7 +56,7 @@ <%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %> <% end %> - <% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::WEBHOOK_URL_KEY, account: current_account)) %> + <% if can?(:read, WebhookUrl) %>
  • <%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %>
  • diff --git a/app/views/testing_api_settings/index.html.erb b/app/views/testing_api_settings/index.html.erb index c85f2613..b02cad31 100644 --- a/app/views/testing_api_settings/index.html.erb +++ b/app/views/testing_api_settings/index.html.erb @@ -8,10 +8,10 @@ <%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> - <%= form_for @webhook_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> - <%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %> + <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> + <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
    - <%= f.url_field :value, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> + <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
    <% end %> diff --git a/app/views/webhook_secret/index.html.erb b/app/views/webhook_secret/show.erb similarity index 50% rename from app/views/webhook_secret/index.html.erb rename to app/views/webhook_secret/show.erb index 5c7ad9ed..88b996c5 100644 --- a/app/views/webhook_secret/index.html.erb +++ b/app/views/webhook_secret/show.erb @@ -1,13 +1,13 @@ -<%= render 'shared/turbo_modal', title: 'Webhook Secret' do %> - <%= form_for @encrypted_config, url: webhook_secret_index_path, method: :post, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %> +<%= render 'shared/turbo_modal', title: t('webhook_secret') do %> + <%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
    - <%= f.fields_for :value, Struct.new(:key, :value).new(*@encrypted_config.value.to_a.first) do |ff| %> + <%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>
    - <%= ff.label :key, class: 'label' %> + <%= ff.label :key, t('key'), class: 'label' %> <%= ff.text_field :key, class: 'base-input', placeholder: 'X-Example-Header' %>
    - <%= ff.label :value, class: 'label' %> + <%= ff.label :value, t('value'), class: 'label' %> <%= ff.text_field :value, class: 'base-input' %>
    <% end %> diff --git a/app/views/webhook_settings/show.html.erb b/app/views/webhook_settings/show.html.erb index 746d3958..eee89493 100644 --- a/app/views/webhook_settings/show.html.erb +++ b/app/views/webhook_settings/show.html.erb @@ -7,34 +7,31 @@
    - <%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %> - <%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %> + <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %> + <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
    - <%= f.url_field :value, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %> + <%= f.url_field :url, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full md:w-32' %> - - <%= t('add_secret') %> - + <% if @webhook_url.persisted? %> + + <%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %> + + <% end %>
    - <% end %> - <% preference = current_account.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)&.value || {} %> - <% WebhookPreferencesController::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %> -
    - <% events.each do |event| %> - <%= form_for '', url: webhook_preferences_path, method: :post do |f| %> - <%= f.hidden_field :event, value: event %> - <% uuid = SecureRandom.uuid %> + <% WebhookUrl::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %> +
    + <%= f.collection_check_boxes(:events, events, :to_s, :to_s, include_hidden: false) do |b| %>
    -
    <% end %> - <% end %> -
    +
    + <% end %> <% end %>
    @@ -47,7 +44,7 @@ <%= t('submission_example_payload') %> - <% if @encrypted_config.value.present? %> + <% if @webhook_url.url.present? && @webhook_url.events.include?('form.completed') %> <%= button_to button_title(title: 'Test Webhook', disabled_with: t('sending'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), settings_webhooks_path, class: 'btn btn-neutral btn-outline btn-sm', method: :put %> <% end %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 0ac26e8a..e9220590 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -474,6 +474,7 @@ en: &en logo: Logo back: Back add_secret: Add Secret + edit_secret: Edit Secret submission_example_payload: Submission example payload there_are_no_signatures: There are no signatures signed_with_trusted_certificate: Signed with trusted certificate @@ -623,7 +624,9 @@ en: &en archived_users: Archived Users embedding_users: Embedding Users view_embedding_users: View Embedding Users - view_users: View Users + key: Key + value: Value + webhook_secret: Webhook Secret submission_event_names: send_email_to_html: 'Email sent to %{submitter_name}' send_reminder_email_to_html: 'Reminder email sent to %{submitter_name}' @@ -1116,6 +1119,7 @@ es: &es logo: Logotipo back: Atrás add_secret: Agregar secreto + edit_secret: Editar secreto submission_example_payload: Ejemplo de payload de envío there_are_no_signatures: No hay firmas signed_with_trusted_certificate: Firmado con certificado de confianza @@ -1265,6 +1269,9 @@ es: &es archived_users: Usuarios Archivados embedding_users: Usuarios Integrados view_embedding_users: Ver Usuarios Integrado + key: Clave + value: Valor + webhook_secret: Secreto del Webhook submission_event_names: send_email_to_html: 'Correo electrónico enviado a %{submitter_name}' send_reminder_email_to_html: 'Correo de recordatorio enviado a %{submitter_name}' @@ -1757,6 +1764,7 @@ it: &it logo: Logo back: Indietro add_secret: Aggiungi segreto + edit_secret: Modifica segreto submission_example_payload: Esempio di payload di invio there_are_no_signatures: Non ci sono firme signed_with_trusted_certificate: Firmato con certificato affidabile @@ -1906,6 +1914,9 @@ it: &it archived_users: Utenti Archiviati embedding_users: Utenti Incorporati view_embedding_users: Visualizza Utenti Incorporati + key: Chiave + value: Valore + webhook_secret: Segreto del Webhook submission_event_names: send_email_to_html: 'E-mail inviato a %{submitter_name}' send_reminder_email_to_html: 'E-mail di promemoria inviato a %{submitter_name}' @@ -2399,6 +2410,7 @@ fr: &fr logo: Logo back: Retour add_secret: Ajouter un secret + edit_secret: Modifier le Secret submission_example_payload: Exemple de payload de soumission there_are_no_signatures: "Il n'y a pas de signatures" signed_with_trusted_certificate: Signé avec un certificat de confiance @@ -2548,6 +2560,9 @@ fr: &fr archived_users: Utilisateurs Archivés embedding_users: Utilisateurs Intégrés view_embedding_users: Voir les Utilisateurs Intégrés + key: Clé + value: Valeur + webhook_secret: Secret du Webhook submission_event_names: send_email_to_html: 'E-mail envoyé à %{submitter_name}' send_reminder_email_to_html: 'E-mail de rappel envoyé à %{submitter_name}' @@ -3040,6 +3055,7 @@ pt: &pt logo: Logotipo back: Voltar add_secret: Adicionar segredo + edit_secret: Editar Segredo submission_example_payload: Exemplo de payload de submissão there_are_no_signatures: Não há assinaturas signed_with_trusted_certificate: Assinado com certificado confiável @@ -3189,6 +3205,9 @@ pt: &pt archived_users: Usuários Arquivados embedding_users: Usuários Incorporados view_embedding_users: Ver Usuários Incorporados + key: Chave + value: Valor + webhook_secret: Segredo do Webhook submission_event_names: send_email_to_html: 'E-mail enviado para %{submitter_name}' send_reminder_email_to_html: 'E-mail de lembrete enviado para %{submitter_name}' @@ -3681,6 +3700,7 @@ de: &de logo: Logo back: Zurück add_secret: Geheimnis hinzufügen + edit_secret: Geheimnis bearbeiten submission_example_payload: Beispiel-Payload für Einreichung there_are_no_signatures: Es gibt keine Unterschriften signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat @@ -3830,6 +3850,9 @@ de: &de archived_users: Archivierte Benutzer embedding_users: Einbettende Benutzer view_embedding_users: Einbettende Benutzer anzeigen + key: Schlüssel + value: Wert + webhook_secret: Webhook-Geheimnis submission_event_names: send_email_to_html: 'E-Mail gesendet an %{submitter_name}' send_reminder_email_to_html: 'Erinnerungs-E-Mail gesendet an %{submitter_name}' diff --git a/config/routes.rb b/config/routes.rb index 420859fc..c2b6e762 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,8 +80,7 @@ Rails.application.routes.draw do resources :testing_api_settings, only: %i[index] resources :submitters_autocomplete, only: %i[index] resources :template_folders_autocomplete, only: %i[index] - resources :webhook_preferences, only: %i[create] - resources :webhook_secret, only: %i[index create] + resources :webhook_secret, only: %i[show update] resource :templates_upload, only: %i[create] authenticated do resource :templates_upload, only: %i[show], path: 'new' diff --git a/db/migrate/20241028162000_add_secret_to_webhook_urls.rb b/db/migrate/20241028162000_add_secret_to_webhook_urls.rb new file mode 100644 index 00000000..0befc646 --- /dev/null +++ b/db/migrate/20241028162000_add_secret_to_webhook_urls.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSecretToWebhookUrls < ActiveRecord::Migration[7.2] + def change + add_column :webhook_urls, :secret, :text + end +end diff --git a/db/migrate/20241029192232_populate_webhook_urls.rb b/db/migrate/20241029192232_populate_webhook_urls.rb new file mode 100644 index 00000000..36c393db --- /dev/null +++ b/db/migrate/20241029192232_populate_webhook_urls.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class PopulateWebhookUrls < ActiveRecord::Migration[7.2] + disable_ddl_transaction + + class MigrationWebhookUrl < ApplicationRecord + self.table_name = 'webhook_urls' + + serialize :events, coder: JSON + serialize :secret, coder: JSON + encrypts :url, :secret + + before_validation -> { self.sha1 = Digest::SHA1.hexdigest(url) } + end + + class MigrationEncryptedConfig < ApplicationRecord + self.table_name = 'encrypted_configs' + + encrypts :value + serialize :value, coder: JSON + end + + class MigrationAccountConfig < ApplicationRecord + self.table_name = 'account_configs' + + serialize :value, coder: JSON + end + + def up + MigrationEncryptedConfig.joins('INNER JOIN accounts a ON a.id = encrypted_configs.account_id') + .where(key: 'webhook_url') + .find_each do |config| + webhook_url = MigrationWebhookUrl.find_or_initialize_by(account_id: config.account_id, url: config.value) + webhook_url.secret = MigrationEncryptedConfig.find_by(account_id: config.account_id, key: 'webhook_secret')&.value + + preferences = MigrationAccountConfig.find_by(account_id: config.account_id, + key: 'webhook_preferences')&.value.to_h + events = %w[form.viewed form.started form.completed form.declined].reject { |event| preferences[event] == false } + events += preferences.compact_blank.keys + + webhook_url.events = events.uniq + + webhook_url.save! + end + end + + def down + nil + end +end diff --git a/db/schema.rb b/db/schema.rb index 3fd0ab11..6521993d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_26_161207) do +ActiveRecord::Schema[7.2].define(version: 2024_10_29_192232) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -367,6 +367,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_26_161207) do t.string "sha1", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "secret" t.index ["account_id"], name: "index_webhook_urls_on_account_id" t.index ["sha1"], name: "index_webhook_urls_on_sha1" end diff --git a/lib/ability.rb b/lib/ability.rb index b48e866a..960a8beb 100644 --- a/lib/ability.rb +++ b/lib/ability.rb @@ -22,5 +22,6 @@ class Ability can :manage, UserConfig, user_id: user.id can :manage, Account, id: user.account_id can :manage, AccessToken, user_id: user.id + can :manage, WebhookUrl, account_id: user.account_id end end diff --git a/lib/accounts.rb b/lib/accounts.rb index 644032b1..67427cf6 100644 --- a/lib/accounts.rb +++ b/lib/accounts.rb @@ -78,30 +78,6 @@ module Accounts new_template end - def load_webhook_url(account) - load_webhook_config(account)&.value.presence - end - - def load_webhook_config(account) - configs = account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) - - if !configs && !Docuseal.multitenant? && !account.testing? - configs = Account.order(:id).first.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) - end - - configs - end - - def load_webhook_preferences(account) - configs = account.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY) - - unless Docuseal.multitenant? - configs ||= Account.order(:id).first.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY) - end - - configs&.value.presence || {} - end - def load_signing_pkcs(account) cert_data = if Docuseal.multitenant? diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 2a320bc8..be32fb91 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -14,7 +14,10 @@ module Submitters unless submitter.submission_events.exists?(event_type: 'start_form') SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request) - SendFormStartedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id }) + submitter.account.webhook_urls.with_event('form.started').each do |webhook_url| + SendFormStartedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, + 'webhook_url_id' => webhook_url.id }) + end end update_submitter!(submitter, params, request) diff --git a/spec/factories/webhook_urls.rb b/spec/factories/webhook_urls.rb new file mode 100644 index 00000000..45a44a7a --- /dev/null +++ b/spec/factories/webhook_urls.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :webhook_url do + account + url { Faker::Internet.url } + end +end diff --git a/spec/jobs/send_form_completed_webhook_request_job_spec.rb b/spec/jobs/send_form_completed_webhook_request_job_spec.rb new file mode 100644 index 00000000..f9fd7cad --- /dev/null +++ b/spec/jobs/send_form_completed_webhook_request_job_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendFormCompletedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current) + end + let(:webhook_url) { create(:webhook_url, account:, events: ['form.completed']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.completed', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.completed', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submitter doesn't exist" do + expect do + described_class.new.perform('submitter_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['form.declined']) + + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submitter_id']).to eq(submitter.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_form_declined_webhook_request_job_spec.rb b/spec/jobs/send_form_declined_webhook_request_job_spec.rb new file mode 100644 index 00000000..e6900fca --- /dev/null +++ b/spec/jobs/send_form_declined_webhook_request_job_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendFormDeclinedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current) + end + let(:webhook_url) { create(:webhook_url, account:, events: ['form.declined']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.declined', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.declined', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submitter doesn't exist" do + expect do + described_class.new.perform('submitter_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['form.completed']) + + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submitter_id']).to eq(submitter.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_form_started_webhook_request_job_spec.rb b/spec/jobs/send_form_started_webhook_request_job_spec.rb new file mode 100644 index 00000000..258e7274 --- /dev/null +++ b/spec/jobs/send_form_started_webhook_request_job_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendFormStartedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current) + end + let(:webhook_url) { create(:webhook_url, account:, events: ['form.started']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.started', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.started', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submitter doesn't exist" do + expect do + described_class.new.perform('submitter_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['form.declined']) + + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submitter_id']).to eq(submitter.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_form_viewed_webhook_request_job_spec.rb b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb new file mode 100644 index 00000000..cb23dcaa --- /dev/null +++ b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendFormViewedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current) + end + let(:webhook_url) { create(:webhook_url, account:, events: ['form.viewed']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.viewed', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'form.viewed', + 'timestamp' => Time.current, + 'data' => Submitters::SerializeForWebhook.call(submitter.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submitter doesn't exist" do + expect do + described_class.new.perform('submitter_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['form.started']) + + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submitter_id']).to eq(submitter.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_submission_archived_webhook_request_job_spec.rb b/spec/jobs/send_submission_archived_webhook_request_job_spec.rb new file mode 100644 index 00000000..3753ece8 --- /dev/null +++ b/spec/jobs/send_submission_archived_webhook_request_job_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendSubmissionArchivedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['submission.archived']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.archived', + 'timestamp' => Time.current, + 'data' => submission.reload.as_json(only: %i[id archived_at]) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.archived', + 'timestamp' => Time.current, + 'data' => submission.reload.as_json(only: %i[id archived_at]) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submission doesn't exist" do + expect do + described_class.new.perform('submission_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['submission.created']) + + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submission_id']).to eq(submission.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_submission_completed_webhook_request_job_spec.rb b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb new file mode 100644 index 00000000..999ffbb5 --- /dev/null +++ b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendSubmissionCompletedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['submission.completed']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.completed', + 'timestamp' => Time.current, + 'data' => Submissions::SerializeForApi.call(submission.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.completed', + 'timestamp' => Time.current, + 'data' => Submissions::SerializeForApi.call(submission.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submission doesn't exist" do + expect do + described_class.new.perform('submission_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['submission.archived']) + + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submission_id']).to eq(submission.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_submission_created_webhook_request_job_spec.rb b/spec/jobs/send_submission_created_webhook_request_job_spec.rb new file mode 100644 index 00000000..a3f804c1 --- /dev/null +++ b/spec/jobs/send_submission_created_webhook_request_job_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendSubmissionCreatedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['submission.created']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.created', + 'timestamp' => Time.current, + 'data' => Submissions::SerializeForApi.call(submission.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'submission.created', + 'timestamp' => Time.current, + 'data' => Submissions::SerializeForApi.call(submission.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the submission doesn't exist" do + expect do + described_class.new.perform('submission_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['submission.completed']) + + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submission_id']).to eq(submission.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_template_created_webhook_request_job_spec.rb b/spec/jobs/send_template_created_webhook_request_job_spec.rb new file mode 100644 index 00000000..83a36c48 --- /dev/null +++ b/spec/jobs/send_template_created_webhook_request_job_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendTemplateCreatedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['template.created']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'template.created', + 'timestamp' => Time.current, + 'data' => Templates::SerializeForApi.call(template.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'template.created', + 'timestamp' => Time.current, + 'data' => Templates::SerializeForApi.call(template.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the template doesn't exist" do + expect do + described_class.new.perform('template_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['template.updated']) + + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['template_id']).to eq(template.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_template_updated_webhook_request_job_spec.rb b/spec/jobs/send_template_updated_webhook_request_job_spec.rb new file mode 100644 index 00000000..bf463eca --- /dev/null +++ b/spec/jobs/send_template_updated_webhook_request_job_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SendTemplateUpdatedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['template.updated']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'template.updated', + 'timestamp' => Time.current, + 'data' => Templates::SerializeForApi.call(template.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: replace_timestamps({ + 'event_type' => 'template.updated', + 'timestamp' => Time.current, + 'data' => Templates::SerializeForApi.call(template.reload) + }.deep_stringify_keys), + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.co Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the template doesn't exist" do + expect do + described_class.new.perform('template_id' => 100_500, 'webhook_url_id' => webhook_url.id) + end.to raise_error ActiveRecord::RecordNotFound + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['template.created']) + + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it "doesn't send a webhook request if the webhook doesn't exist" do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => 100_500) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['template_id']).to eq(template.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 86a15a9f..94f071be 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -67,3 +67,19 @@ RSpec.configure do |config| Sidekiq::Testing.inline! if example.metadata[:sidekiq] == :inline end end + +def replace_timestamps(data, replace = /.*/) + timestamp_fields = %w[created_at updated_at completed_at sent_at opened_at timestamp] + + data.each do |key, value| + if timestamp_fields.include?(key) && (value.is_a?(String) || value.is_a?(Time)) + data[key] = replace + elsif value.is_a?(Hash) + replace_timestamps(value) + elsif value.is_a?(Array) + value.each { |item| replace_timestamps(item) if item.is_a?(Hash) } + end + end + + data +end diff --git a/spec/system/webhook_settings_spec.rb b/spec/system/webhook_settings_spec.rb index 1e94eb62..a6c1e214 100644 --- a/spec/system/webhook_settings_spec.rb +++ b/spec/system/webhook_settings_spec.rb @@ -8,24 +8,110 @@ RSpec.describe 'Webhook Settings' do before do sign_in(user) - visit settings_webhooks_path end it 'shows webhook settings page' do + visit settings_webhooks_path + expect(page).to have_content('Webhooks') expect(page).to have_field('Webhook URL') expect(page).to have_button('Save') + + WebhookUrl::EVENTS.each do |event| + expect(page).to have_field(event, type: 'checkbox', disabled: true) + end + end + + it 'creates the webhook' do + visit settings_webhooks_path + + fill_in 'Webhook URL', with: 'https://example.com/webhook' + + expect do + click_button 'Save' + end.to change(WebhookUrl, :count).by(1) + + webhook_url = account.webhook_urls.first + + expect(webhook_url.url).to eq('https://example.com/webhook') + end + + it 'updates the webhook' do + webhook_url = create(:webhook_url, account:, url: 'https://example.com/webhook') + + visit settings_webhooks_path + + fill_in 'Webhook URL', with: 'https://example.org/webhook' + click_button 'Save' + + webhook_url.reload + + expect(webhook_url.url).to eq('https://example.org/webhook') end - it 'updates the webhook URL' do - fill_in 'Webhook URL', with: 'https://example.com' + it 'deletes the webhook' do + create(:webhook_url, account:) + + visit settings_webhooks_path + + fill_in 'Webhook URL', with: '' expect do click_button 'Save' - end.to change(EncryptedConfig, :count).by(1) + end.to change(WebhookUrl, :count).by(-1) + end + + it 'updates the webhook events' do + webhook_url = create(:webhook_url, account:) + + visit settings_webhooks_path + + expect(webhook_url.events).not_to include('submission.created') + + check('submission.created') + + webhook_url.reload + + expect(webhook_url.events).to include('submission.created') + end + + it 'adds a secret to the webhook' do + webhook_url = create(:webhook_url, account:) + + visit settings_webhooks_path + + expect(webhook_url.secret).to be_nil + + click_link 'Add Secret' + + within '#modal' do + fill_in 'Key', with: 'X-Signature' + fill_in 'Value', with: 'secret-value' + + click_button 'Submit' + + webhook_url.reload + + expect(webhook_url.secret).to eq({ 'X-Signature' => 'secret-value' }) + end + end + + it 'removes a secret from the webhook' do + webhook_url = create(:webhook_url, account:, secret: { 'X-Signature' => 'secret-value' }) + + visit settings_webhooks_path + + click_link 'Edit Secret' + + within '#modal' do + fill_in 'Key', with: '' + fill_in 'Value', with: '' + + click_button 'Submit' - encrypted_config = EncryptedConfig.find_by(account:, key: EncryptedConfig::WEBHOOK_URL_KEY) + webhook_url.reload - expect(encrypted_config.value).to eq('https://example.com') + expect(webhook_url.secret).to eq({}) + end end end