diff --git a/Gemfile b/Gemfile index 3d8e485e56e..608c8703629 100644 --- a/Gemfile +++ b/Gemfile @@ -136,6 +136,9 @@ gem 'view_component_reflex', '3.1.14.pre9' gem 'mini_portile2', '~> 2.8' +gem "faraday" +gem "private_address_check" + group :production, :staging do gem 'ddtrace' gem 'rack-timeout' diff --git a/Gemfile.lock b/Gemfile.lock index 9a1b5b2bb91..caa5a208cb5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -474,6 +474,7 @@ GEM ttfunk pg (1.2.3) power_assert (2.0.2) + private_address_check (0.5.0) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) @@ -801,6 +802,7 @@ DEPENDENCIES digest dotenv-rails factory_bot_rails (= 6.2.0) + faraday ffaker flipper flipper-active_record @@ -843,6 +845,7 @@ DEPENDENCIES paypal-sdk-merchant (= 1.117.2) pdf-reader pg (~> 1.2.3) + private_address_check pry (~> 0.13.0) puma rack-mini-profiler (< 3.0.0) diff --git a/app/controllers/webhook_endpoints_controller.rb b/app/controllers/webhook_endpoints_controller.rb new file mode 100644 index 00000000000..c9493f42bb4 --- /dev/null +++ b/app/controllers/webhook_endpoints_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class WebhookEndpointsController < ::BaseController + before_action :load_resource, only: :destroy + + def create + webhook_endpoint = spree_current_user.webhook_endpoints.new(webhook_endpoint_params) + + if webhook_endpoint.save + flash[:success] = t('.success') + else + flash[:error] = t('.error') + end + + redirect_to redirect_path + end + + def destroy + if @webhook_endpoint.destroy + flash[:success] = t('.success') + else + flash[:error] = t('.error') + end + + redirect_to redirect_path + end + + def load_resource + @webhook_endpoint = spree_current_user.webhook_endpoints.find(params[:id]) + end + + def webhook_endpoint_params + params.require(:webhook_endpoint).permit(:url) + end + + def redirect_path + if request.referer.blank? || request.referer.include?(spree.account_path) + developer_settings_path + else + request.referer + end + end + + def developer_settings_path + "#{spree.account_path}#/developer_settings" + end +end diff --git a/app/jobs/order_cycle_opened_job.rb b/app/jobs/order_cycle_opened_job.rb new file mode 100644 index 00000000000..9d84027fe30 --- /dev/null +++ b/app/jobs/order_cycle_opened_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Trigger jobs for any order cycles that recently opened +class OrderCycleOpenedJob < ApplicationJob + def perform + ActiveRecord::Base.transaction do + recently_opened_order_cycles.find_each do |order_cycle| + OrderCycleWebhookService.create_webhook_job(order_cycle, 'order_cycle.opened') + end + mark_as_opened(recently_opened_order_cycles) + end + end + + private + + def recently_opened_order_cycles + @recently_opened_order_cycles ||= OrderCycle + .where(opened_at: nil) + .where(orders_open_at: 1.hour.ago..Time.zone.now) + .lock.order(:id) + end + + def mark_as_opened(order_cycles) + now = Time.zone.now + order_cycles.update_all(opened_at: now, updated_at: now) + end +end diff --git a/app/jobs/webhook_delivery_job.rb b/app/jobs/webhook_delivery_job.rb new file mode 100644 index 00000000000..7eb0a44b832 --- /dev/null +++ b/app/jobs/webhook_delivery_job.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "faraday" +require "private_address_check" +require "private_address_check/tcpsocket_ext" + +# Deliver a webhook payload +# As a delayed job, it can run asynchronously and handle retries. +class WebhookDeliveryJob < ApplicationJob + # General failed request error that we're going to use to signal + # the job runner to retry our webhook worker. + class FailedWebhookRequestError < StandardError; end + + queue_as :default + + def perform(url, event, payload) + body = { + id: job_id, + at: Time.zone.now.to_s, + event: event, + data: payload, + } + + # Request user-submitted url, preventing any private connections being made + # (SSRF). + # This method may allow the socket to open, but is necessary in order to + # protect from TOC/TOU. + # Note that private_address_check provides some methods for pre-validating, + # but they're not as comprehensive and so unnecessary here. Simply + # momentarily opening sockets probably can't cause DoS or other damage. + PrivateAddressCheck.only_public_connections do + notify_endpoint(url, body) + end + end + + def notify_endpoint(url, body) + connection = Faraday.new( + request: { timeout: 30 }, + headers: { + 'User-Agent' => 'openfoodnetwork_webhook/1.0', + 'Content-Type' => 'application/json', + } + ) + response = connection.post(url, body.to_json) + + # Raise a failed request error and let job runner handle retrying. + # In theory, only 5xx errors should be retried, but who knows. + raise FailedWebhookRequestError, response.status.to_s unless response.success? + end +end diff --git a/app/models/order_cycle.rb b/app/models/order_cycle.rb index ce1846cf540..4eb3bc90608 100644 --- a/app/models/order_cycle.rb +++ b/app/models/order_cycle.rb @@ -34,6 +34,7 @@ class OrderCycle < ApplicationRecord attr_accessor :incoming_exchanges, :outgoing_exchanges + before_update :reset_opened_at, if: :will_save_change_to_orders_open_at? before_update :reset_processed_at, if: :will_save_change_to_orders_close_at? after_save :sync_subscriptions, if: :opening? @@ -333,6 +334,14 @@ def orders_close_at_after_orders_open_at? errors.add(:orders_close_at, :after_orders_open_at) end + def reset_opened_at + # Reset only if order cycle is opening again at a later date + return unless orders_open_at.present? && orders_open_at_was.present? + return unless orders_open_at > orders_open_at_was + + self.opened_at = nil + end + def reset_processed_at return unless orders_close_at.present? && orders_close_at_was.present? return unless orders_close_at > orders_close_at_was diff --git a/app/models/spree/user.rb b/app/models/spree/user.rb index e95df0fcfd5..879825e9baf 100644 --- a/app/models/spree/user.rb +++ b/app/models/spree/user.rb @@ -38,7 +38,10 @@ class User < ApplicationRecord has_many :customers has_many :credit_cards has_many :report_rendering_options, class_name: "::ReportRenderingOptions", dependent: :destroy + has_many :webhook_endpoints, dependent: :destroy + accepts_nested_attributes_for :enterprise_roles, allow_destroy: true + accepts_nested_attributes_for :webhook_endpoints accepts_nested_attributes_for :bill_address accepts_nested_attributes_for :ship_address diff --git a/app/models/webhook_endpoint.rb b/app/models/webhook_endpoint.rb new file mode 100644 index 00000000000..8626e11b934 --- /dev/null +++ b/app/models/webhook_endpoint.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Records a webhook url to send notifications to +class WebhookEndpoint < ApplicationRecord + validates :url, presence: true +end diff --git a/app/services/order_cycle_webhook_service.rb b/app/services/order_cycle_webhook_service.rb new file mode 100644 index 00000000000..f2d4df152ae --- /dev/null +++ b/app/services/order_cycle_webhook_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Create a webhook payload for an order cycle event. +# The payload will be delivered asynchronously. +class OrderCycleWebhookService + def self.create_webhook_job(order_cycle, event) + webhook_payload = order_cycle + .slice(:id, :name, :orders_open_at, :orders_close_at, :coordinator_id) + .merge(coordinator_name: order_cycle.coordinator.name) + + # Endpoints for coordinator owner + webhook_endpoints = order_cycle.coordinator.owner.webhook_endpoints + + # Plus unique endpoints for distributor owners (ignore duplicates) + webhook_endpoints |= order_cycle.distributors.map(&:owner).flat_map(&:webhook_endpoints) + + webhook_endpoints.each do |endpoint| + WebhookDeliveryJob.perform_later(endpoint.url, event, webhook_payload) + end + end +end diff --git a/app/services/permitted_attributes/user.rb b/app/services/permitted_attributes/user.rb index 0b511bab218..4bb29bbcc48 100644 --- a/app/services/permitted_attributes/user.rb +++ b/app/services/permitted_attributes/user.rb @@ -15,7 +15,10 @@ def call(extra_permitted_attributes = []) private def permitted_attributes - [:email, :password, :password_confirmation, :disabled] + [ + :email, :password, :password_confirmation, :disabled, + { webhook_endpoints_attributes: [:id, :url] }, + ] end end end diff --git a/app/views/spree/users/_developer_settings.html.haml b/app/views/spree/users/_developer_settings.html.haml index b98ed527d51..fe6f6c5d046 100644 --- a/app/views/spree/users/_developer_settings.html.haml +++ b/app/views/spree/users/_developer_settings.html.haml @@ -1,3 +1,4 @@ %script{ type: "text/ng-template", id: "account/developer_settings.html" } %h3= t('.title') = render partial: 'api_keys' + = render partial: 'webhook_endpoints' diff --git a/app/views/spree/users/_webhook_endpoints.html.haml b/app/views/spree/users/_webhook_endpoints.html.haml new file mode 100644 index 00000000000..8bd60e2f8b4 --- /dev/null +++ b/app/views/spree/users/_webhook_endpoints.html.haml @@ -0,0 +1,33 @@ +%section{ id: "webhook_endpoints" } + %hr + %h3= t('.title') + %p= t('.description') + + %table{width: "100%"} + %thead + %tr + %th= t('.event_type.header') + %th= t('.url.header') + %th.actions + %tbody + -# Existing endpoints + - @user.webhook_endpoints.each do |webhook_endpoint| + %tr + %td= t('.event_types.order_cycle_opened') # For now, we only support one type. + %td= webhook_endpoint.url + %td.actions + - if webhook_endpoint.persisted? + = button_to account_webhook_endpoint_path(webhook_endpoint), method: :delete, + class: "tiny alert no-margin", + data: { confirm: I18n.t(:are_you_sure)} do + = I18n.t(:delete) + + -# Create new + - if @user.webhook_endpoints.empty? # Only one allowed for now. + %tr + %td= t('.event_types.order_cycle_opened') # For now, we only support one type. + %td + = form_for(@user.webhook_endpoints.build, url: account_webhook_endpoints_path, id: 'new_webhook_endpoint') do |f| + = f.url_field :url, placeholder: t('.url.create_placeholder'), required: true, size: 64 + %td.actions + = button_tag t(:create), class: 'button primary tiny no-margin', form: 'new_webhook_endpoint' diff --git a/config/locales/en.yml b/config/locales/en.yml index 761411574e9..81e2ace90de 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3562,6 +3562,14 @@ See the %{link} to find out more about %{sitename}'s features and to start using previous: "Previous" last: "Last" + webhook_endpoints: + create: + success: Webhook endpoint successfully created + error: Webhook endpoint failed to create + destroy: + success: Webhook endpoint successfully deleted + error: Webhook endpoint failed to delete + spree: order_updated: "Order Updated" add_country: "Add country" @@ -4357,6 +4365,16 @@ See the %{link} to find out more about %{sitename}'s features and to start using api_keys: regenerate_key: "Regenerate Key" title: API key + webhook_endpoints: + title: Webhook Endpoints + description: Events in the system may trigger webhooks to external systems. + event_types: + order_cycle_opened: Order Cycle Opened + event_type: + header: Event type + url: + header: Endpoint URL + create_placeholder: Enter the URL of the remote webhook endpoint developer_settings: title: Developer Settings form: diff --git a/config/routes/spree.rb b/config/routes/spree.rb index c658cbdc214..9844b800e50 100644 --- a/config/routes/spree.rb +++ b/config/routes/spree.rb @@ -32,7 +32,9 @@ put '/password/change' => 'user_passwords#update', :as => :update_password end - resource :account, :controller => 'users' + resource :account, :controller => 'users' do + resources :webhook_endpoints, only: [:create, :destroy], controller: '/webhook_endpoints' + end match '/admin/orders/bulk_management' => 'admin/orders#bulk_management', :as => "admin_bulk_order_management", via: :get match '/admin/payment_methods/show_provider_preferences' => 'admin/payment_methods#show_provider_preferences', :via => :get diff --git a/config/sidekiq.yml b/config/sidekiq.yml index c58aca0333c..eab2b061dbc 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -15,5 +15,7 @@ every: "5m" SubscriptionConfirmJob: every: "5m" + OrderCycleOpenedJob: + every: "5m" OrderCycleClosingJob: every: "5m" diff --git a/db/migrate/20220919063831_add_opened_at_to_order_cycle.rb b/db/migrate/20220919063831_add_opened_at_to_order_cycle.rb new file mode 100644 index 00000000000..802ce193557 --- /dev/null +++ b/db/migrate/20220919063831_add_opened_at_to_order_cycle.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOpenedAtToOrderCycle < ActiveRecord::Migration[6.1] + def change + add_column :order_cycles, :opened_at, :timestamp + end +end diff --git a/db/migrate/20221028051650_create_webhook_endpoints.rb b/db/migrate/20221028051650_create_webhook_endpoints.rb new file mode 100644 index 00000000000..034d1141d2f --- /dev/null +++ b/db/migrate/20221028051650_create_webhook_endpoints.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateWebhookEndpoints < ActiveRecord::Migration[6.1] + def change + create_table :webhook_endpoints do |t| + t.string :url, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20221028053214_add_spree_user_reference_to_webhook_endpoint.rb b/db/migrate/20221028053214_add_spree_user_reference_to_webhook_endpoint.rb new file mode 100644 index 00000000000..462e978b9c1 --- /dev/null +++ b/db/migrate/20221028053214_add_spree_user_reference_to_webhook_endpoint.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSpreeUserReferenceToWebhookEndpoint < ActiveRecord::Migration[6.1] + def change + add_column :webhook_endpoints, :user_id, :bigint, default: 0, null: false + add_index :webhook_endpoints, :user_id + add_foreign_key :webhook_endpoints, :spree_users, column: :user_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 77975612f5d..8e6da5fe19f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -310,6 +310,7 @@ t.datetime "processed_at" t.boolean "automatic_notifications", default: false t.boolean "mails_sent", default: false + t.datetime "opened_at" end create_table "order_cycles_distributor_payment_methods", id: false, force: :cascade do |t| @@ -1189,6 +1190,14 @@ t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end + create_table "webhook_endpoints", force: :cascade do |t| + t.string "url", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "user_id", default: 0, null: false + t.index ["user_id"], name: "index_webhook_endpoints_on_user_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "adjustment_metadata", "enterprises", name: "adjustment_metadata_enterprise_id_fk" @@ -1293,4 +1302,5 @@ add_foreign_key "subscriptions", "spree_shipping_methods", column: "shipping_method_id", name: "subscriptions_shipping_method_id_fk" add_foreign_key "variant_overrides", "enterprises", column: "hub_id", name: "variant_overrides_hub_id_fk" add_foreign_key "variant_overrides", "spree_variants", column: "variant_id", name: "variant_overrides_variant_id_fk" + add_foreign_key "webhook_endpoints", "spree_users", column: "user_id" end diff --git a/spec/controllers/webhook_endpoints_controller_spec.rb b/spec/controllers/webhook_endpoints_controller_spec.rb new file mode 100644 index 00000000000..c36720edb8f --- /dev/null +++ b/spec/controllers/webhook_endpoints_controller_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: false + +require 'spec_helper' +require 'open_food_network/order_cycle_permissions' + +describe WebhookEndpointsController, type: :controller do + let(:user) { create(:admin_user) } + + before { allow(controller).to receive(:spree_current_user) { user } } + + describe "#create" do + it "creates a webhook_endpoint" do + expect { + spree_post :create, { url: "https://url" } + }.to change { + user.webhook_endpoints.count + }.by(1) + + expect(flash[:success]).to be_present + expect(flash[:error]).to be_blank + expect(user.webhook_endpoints.first.url).to eq "https://url" + end + + it "shows error if parameters not specified" do + expect { + spree_post :create, { url: "" } + }.to_not change { + user.webhook_endpoints.count + } + + expect(flash[:success]).to be_blank + expect(flash[:error]).to be_present + end + + it "redirects back to referrer" do + spree_post :create, { url: "https://url" } + + expect(response).to redirect_to "/account#/developer_settings" + end + end + + describe "#destroy" do + let!(:webhook_endpoint) { user.webhook_endpoints.create(url: "https://url") } + + it "destroys a webhook_endpoint" do + webhook_endpoint2 = user.webhook_endpoints.create!(url: "https://url2") + + expect { + spree_delete :destroy, { id: webhook_endpoint.id } + }.to change { + user.webhook_endpoints.count + }.by(-1) + + expect(flash[:success]).to be_present + expect(flash[:error]).to be_blank + + expect{ webhook_endpoint.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(webhook_endpoint2.reload).to be_present + end + + it "redirects back to developer settings tab" do + spree_delete :destroy, id: webhook_endpoint.id + + expect(response).to redirect_to "/account#/developer_settings" + end + end +end diff --git a/spec/jobs/order_cycle_opened_job_spec.rb b/spec/jobs/order_cycle_opened_job_spec.rb new file mode 100644 index 00000000000..142cc01b0e8 --- /dev/null +++ b/spec/jobs/order_cycle_opened_job_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe OrderCycleOpenedJob do + let(:oc_opened_before) { + create(:order_cycle, orders_open_at: Time.zone.now - 1.hour) + } + let(:oc_opened_now) { + create(:order_cycle, orders_open_at: Time.zone.now) + } + let(:oc_opening_soon) { + create(:order_cycle, orders_open_at: Time.zone.now + 1.minute) + } + + it "enqueues jobs for recently opened order cycles only" do + expect(OrderCycleWebhookService) + .to receive(:create_webhook_job).with(oc_opened_now, 'order_cycle.opened') + + expect(OrderCycleWebhookService) + .to_not receive(:create_webhook_job).with(oc_opened_before, 'order_cycle.opened') + + expect(OrderCycleWebhookService) + .to_not receive(:create_webhook_job).with(oc_opening_soon, 'order_cycle.opened') + + OrderCycleOpenedJob.perform_now + end + + describe "concurrency", concurrency: true do + let(:breakpoint) { Mutex.new } + + it "doesn't place duplicate job when run concurrently" do + oc_opened_now + + # Pause jobs when placing new job: + breakpoint.lock + allow(OrderCycleOpenedJob).to( + receive(:new).and_wrap_original do |method, *args| + breakpoint.synchronize {} + method.call(*args) + end + ) + + expect(OrderCycleWebhookService) + .to receive(:create_webhook_job).with(oc_opened_now, 'order_cycle.opened').once + + # Start two jobs in parallel: + threads = [ + Thread.new { OrderCycleOpenedJob.perform_now }, + Thread.new { OrderCycleOpenedJob.perform_now }, + ] + + # Wait for both to jobs to pause. + # This can reveal a race condition. + sleep 0.1 + + # Resume and complete both jobs: + breakpoint.unlock + threads.each(&:join) + end + end +end diff --git a/spec/jobs/webhook_delivery_job_spec.rb b/spec/jobs/webhook_delivery_job_spec.rb new file mode 100644 index 00000000000..7add4ca4e1e --- /dev/null +++ b/spec/jobs/webhook_delivery_job_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe WebhookDeliveryJob do + subject { WebhookDeliveryJob.new(url, event, data) } + let(:url) { 'https://test/endpoint' } + let(:event) { 'order_cycle.opened' } + let(:data) { + { + order_cycle_id: 123, name: "Order cycle 1", open_at: 1.minute.ago.to_s, tags: ["tag1", "tag2"] + } + } + + before do + stub_request(:post, url) + end + + it "sends a request to specified url" do + subject.perform_now + expect(a_request(:post, url)).to have_been_made.once + end + + it "delivers a payload" do + Timecop.freeze do + expected_body = { + id: /.+/, + at: Time.zone.now.to_s, + event: event, + data: data, + } + + subject.perform_now + expect(a_request(:post, url).with(body: expected_body)). + to have_been_made.once + end + end + + # Ensure responses from a local network aren't allowed, to prevent a user + # seeing a private response or initiating an unauthorised action (SSRF). + # Currently, we're not doing anything with responses. When we do, we should + # update this to confirm the response isn't exposed. + describe "server side request forgery" do + describe "private addresses" do + private_addresses = [ + "http://127.0.0.1/all_the_secrets", + "http://localhost/all_the_secrets", + ] + + private_addresses.each do |url| + it "rejects private address #{url}" do + # Github Actions doesn't allow local connections. + pending if ENV["CI"] + expect { + WebhookDeliveryJob.perform_now(url, event, data) + }.to raise_error(PrivateAddressCheck::PrivateConnectionAttemptedError) + end + end + end + + describe "redirects" do + it "doesn't follow a redirect" do + other_url = 'http://localhost/all_the_secrets' + + stub_request(:post, url). + to_return(status: 302, headers: { 'Location' => other_url }) + stub_request(:any, other_url) + + expect { + subject.perform_now + }.to raise_error(StandardError, "302") + + expect(a_request(:any, other_url)).not_to have_been_made + end + end + end + + # Exceptions are considered a job failure, which the job runner + # (Sidekiq) and/or ActiveJob will handle and retry later. + describe "failure" do + it "raises error on server error" do + stub_request(:post, url).to_return(status: [500, "Internal Server Error"]) + + expect{ subject.perform_now }.to raise_error(StandardError, "500") + end + end +end diff --git a/spec/models/order_cycle_spec.rb b/spec/models/order_cycle_spec.rb index 818b900e196..fe80bd3d789 100644 --- a/spec/models/order_cycle_spec.rb +++ b/spec/models/order_cycle_spec.rb @@ -551,6 +551,27 @@ end end + describe "opened_at " do + let!(:oc) { + create(:simple_order_cycle, orders_open_at: 2.days.ago, orders_close_at: 1.day.ago, opened_at: 1.week.ago) + } + + it "reset opened_at if open date change in future" do + expect{ oc.update!(orders_open_at: 1.week.from_now, orders_close_at: 2.weeks.from_now) } + .to change { oc.opened_at }.to be_nil + end + + it "it does not reset opened_at if open date is changed to be earlier" do + expect{ oc.update!(orders_open_at: 3.days.ago) } + .to_not change { oc.opened_at } + end + + it "it does not reset opened_at if open date does not change" do + expect{ oc.update!(orders_close_at: 1.day.from_now) } + .to_not change { oc.opened_at } + end + end + describe "processed_at " do let!(:oc) { create(:simple_order_cycle, orders_open_at: 1.week.ago, orders_close_at: 1.day.ago, processed_at: 1.hour.ago) @@ -562,13 +583,13 @@ expect(oc.processed_at).to be_nil end - it "it does not reset processed_at if close date change in the past" do + it "it does not reset processed_at if close date is changed to be earlier" do expect(oc.processed_at).to_not be_nil oc.update!(orders_close_at: 2.days.ago) expect(oc.processed_at).to_not be_nil end - it "it does not reset processed_at if close date do not change" do + it "it does not reset processed_at if close date does not change" do expect(oc.processed_at).to_not be_nil oc.update!(orders_open_at: 2.weeks.ago) expect(oc.processed_at).to_not be_nil diff --git a/spec/models/spree/user_spec.rb b/spec/models/spree/user_spec.rb index a99a18941dc..9dac88ad760 100644 --- a/spec/models/spree/user_spec.rb +++ b/spec/models/spree/user_spec.rb @@ -7,6 +7,7 @@ describe "associations" do it { is_expected.to have_many(:owned_enterprises) } + it { is_expected.to have_many(:webhook_endpoints).dependent(:destroy) } describe "addresses" do let(:user) { create(:user, bill_address: create(:address)) } diff --git a/spec/models/webhook_endpoint_spec.rb b/spec/models/webhook_endpoint_spec.rb new file mode 100644 index 00000000000..5f1d630f099 --- /dev/null +++ b/spec/models/webhook_endpoint_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe WebhookEndpoint, type: :model do + describe "validations" do + it { is_expected.to validate_presence_of(:url) } + end +end diff --git a/spec/services/order_cycle_webhook_service_spec.rb b/spec/services/order_cycle_webhook_service_spec.rb new file mode 100644 index 00000000000..e5c1285e2be --- /dev/null +++ b/spec/services/order_cycle_webhook_service_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe OrderCycleWebhookService do + let(:order_cycle) { + create( + :simple_order_cycle, + name: "Order cycle 1", + orders_open_at: "2022-09-19 09:00:00".to_time, + orders_close_at: "2022-09-19 17:00:00".to_time, + coordinator: coordinator, + ) + } + let(:coordinator) { create :distributor_enterprise, name: "Starship Enterprise" } + + describe "creating payloads" do + it "doesn't create webhook payload for enterprise users" do + # The co-ordinating enterprise has a non-owner user with an endpoint. + # They shouldn't receive a notification. + coordinator_user = create(:user, enterprises: [coordinator]) + coordinator_user.webhook_endpoints.create!(url: "http://coordinator_user_url") + + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to_not enqueue_job(WebhookDeliveryJob).with("http://coordinator_user_url", any_args) + end + + context "coordinator owner has endpoint configured" do + before do + coordinator.owner.webhook_endpoints.create! url: "http://coordinator_owner_url" + end + + it "creates webhook payload for order cycle coordinator" do + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to enqueue_job(WebhookDeliveryJob).with("http://coordinator_owner_url", any_args) + end + + it "creates webhook payload with details for the specified order cycle only" do + # The coordinating enterprise has another OC. It should be ignored. + order_cycle.dup.save + + data = { + id: order_cycle.id, + name: "Order cycle 1", + orders_open_at: "2022-09-19 09:00:00".to_time, + orders_close_at: "2022-09-19 17:00:00".to_time, + coordinator_id: coordinator.id, + coordinator_name: "Starship Enterprise", + } + + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to enqueue_job(WebhookDeliveryJob).exactly(1).times + .with("http://coordinator_owner_url", "order_cycle.opened", hash_including(data)) + end + end + + context "coordinator owner doesn't have endpoint configured" do + it "doesn't create webhook payload" do + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .not_to enqueue_job(WebhookDeliveryJob) + end + end + + describe "distributors" do + context "multiple distributors have owners with endpoint configured" do + let(:order_cycle) { + create( + :simple_order_cycle, + coordinator: coordinator, + distributors: two_distributors, + ) + } + let(:two_distributors) { + (1..2).map do |i| + user = create(:user) + user.webhook_endpoints.create!(url: "http://distributor#{i}_owner_url") + create(:distributor_enterprise, owner: user) + end + } + + it "creates webhook payload for each order cycle distributor" do + data = { + coordinator_id: order_cycle.coordinator_id, + coordinator_name: "Starship Enterprise", + } + + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to enqueue_job(WebhookDeliveryJob).with("http://distributor1_owner_url", + "order_cycle.opened", hash_including(data)) + .and enqueue_job(WebhookDeliveryJob).with("http://distributor2_owner_url", + "order_cycle.opened", hash_including(data)) + end + end + + context "distributor owner is same user as coordinator owner" do + let(:user) { coordinator.owner } + let(:order_cycle) { + create( + :simple_order_cycle, + coordinator: coordinator, + distributors: [create(:distributor_enterprise, owner: user)], + ) + } + + it "creates only one webhook payload for the user's endpoint" do + user.webhook_endpoints.create! url: "http://coordinator_owner_url" + + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to enqueue_job(WebhookDeliveryJob).with("http://coordinator_owner_url", any_args) + end + end + end + + describe "suppliers" do + context "supplier has owner with endpoint configured" do + let(:order_cycle) { + create( + :simple_order_cycle, + coordinator: coordinator, + suppliers: [supplier], + ) + } + let(:supplier) { + user = create(:user) + user.webhook_endpoints.create!(url: "http://supplier_owner_url") + create(:supplier_enterprise, owner: user) + } + + it "doesn't create a webhook payload for supplier owner" do + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .to_not enqueue_job(WebhookDeliveryJob).with("http://supplier_owner_url", any_args) + end + end + end + end + + context "without webhook subscribed to enterprise" do + it "doesn't create webhook payload" do + expect{ OrderCycleWebhookService.create_webhook_job(order_cycle, "order_cycle.opened") } + .not_to enqueue_job(WebhookDeliveryJob) + end + end +end diff --git a/spec/system/consumer/account/developer_settings_spec.rb b/spec/system/consumer/account/developer_settings_spec.rb index 4385a4aff4b..40bf4a59d63 100644 --- a/spec/system/consumer/account/developer_settings_spec.rb +++ b/spec/system/consumer/account/developer_settings_spec.rb @@ -35,6 +35,22 @@ expect(page).to have_content "Key generated" expect(page).to have_input "api_key", with: user.reload.spree_api_key end + + describe "Webhook Endpoints" do + it "creates a new webhook endpoint and deletes it" do + within "#webhook_endpoints" do + fill_in "webhook_endpoint_url", with: "https://url" + + click_button I18n.t(:create) + expect(page.document).to have_content I18n.t('webhook_endpoints.create.success') + expect(page).to have_content "https://url" + + click_button I18n.t(:delete) + expect(page.document).to have_content I18n.t('webhook_endpoints.destroy.success') + expect(page).to_not have_content "https://url" + end + end + end end end