feat: add per-webhook secret with backfill migration (#13573)

This commit is contained in:
Shivam Mishra
2026-02-26 17:26:12 +05:30
committed by GitHub
parent 7c60ad9e28
commit c218eff5ec
19 changed files with 319 additions and 78 deletions

View File

@@ -31,6 +31,14 @@
"WEBHOOK": { "WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events", "SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks", "LEARN_MORE": "Learn more about webhooks",
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
"DONE": "Done"
},
"COUNT": "{n} webhook | {n} webhooks", "COUNT": "{n} webhook | {n} webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...", "SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search", "NO_RESULTS": "No webhooks found matching your search",

View File

@@ -58,6 +58,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get'); this.$store.dispatch('webhooks/get');
}, },
methods: { methods: {

View File

@@ -1,60 +1,98 @@
<script> <script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding'; import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import WebhookForm from './WebhookForm.vue'; import WebhookForm from './WebhookForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { const props = defineProps({
components: { WebhookForm }, onClose: {
props: { type: Function,
onClose: { required: true,
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
},
}, },
});
const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
const createdWebhook = ref(null);
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
const onSubmit = async webhook => {
try {
const result = await store.dispatch('webhooks/create', { webhook });
createdWebhook.value = result;
} catch (error) {
const message =
error.response.data.message ||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
};
const handleCopySecret = async () => {
await copyTextToClipboard(createdWebhook.value.secret);
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
}; };
</script> </script>
<template> <template>
<div class="h-auto overflow-auto flex flex-col"> <div class="h-auto overflow-auto flex flex-col">
<woot-modal-header <template v-if="createdWebhook">
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')" <woot-modal-header
:header-content=" :header-title="
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC')) t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
" "
/> />
<WebhookForm <div class="px-8 pb-6">
:is-submitting="uiFlags.creatingItem" <p class="text-sm text-n-slate-11 mb-4">
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')" {{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
@submit="onSubmit" </p>
@cancel="onClose" <label>
/> {{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="createdWebhook.secret"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
icon="i-lucide-copy"
slate
faded
@click="handleCopySecret"
/>
</div>
</label>
<div class="flex justify-end mt-4">
<NextButton
blue
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
@click="props.onClose()"
/>
</div>
</div>
</template>
<template v-else>
<woot-modal-header
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="props.onClose()"
/>
</template>
</div> </div>
</template> </template>

View File

@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, url, minLength } from '@vuelidate/validators'; import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants; const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -57,10 +59,14 @@ export default {
url: this.value.url || '', url: this.value.url || '',
name: this.value.name || '', name: this.value.name || '',
subscriptions: this.value.subscriptions || [], subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS, supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
}; };
}, },
computed: { computed: {
hasSecret() {
return !!this.value.secret;
},
webhookURLInputPlaceholder() { webhookURLInputPlaceholder() {
return this.$t( return this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER', 'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
@@ -81,6 +87,10 @@ export default {
subscriptions: this.subscriptions, subscriptions: this.subscriptions,
}); });
}, },
async copySecret() {
await copyTextToClipboard(this.value.secret);
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
},
getI18nKey, getI18nKey,
}, },
}; };
@@ -111,6 +121,35 @@ export default {
:placeholder="webhookNameInputPlaceholder" :placeholder="webhookNameInputPlaceholder"
/> />
</label> </label>
<label v-if="hasSecret" class="mb-4">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
type="button"
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
slate
faded
@click="secretVisible = !secretVisible"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
type="button"
icon="i-lucide-copy"
slate
faded
@click="copySecret"
/>
</div>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2"> <label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }} {{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label> </label>

View File

@@ -42,6 +42,7 @@ export const actions = {
} = response.data; } = response.data;
commit(types.default.ADD_WEBHOOK, webhook); commit(types.default.ADD_WEBHOOK, webhook);
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
return webhook;
} catch (error) { } catch (error) {
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
throw error; throw error;

View File

@@ -1,7 +1,7 @@
class WebhookJob < ApplicationJob class WebhookJob < ApplicationJob
queue_as :medium queue_as :medium
# There are 3 types of webhooks, account, inbox and agent_bot # There are 3 types of webhooks, account, inbox and agent_bot
def perform(url, payload, webhook_type = :account_webhook) def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
Webhooks::Trigger.execute(url, payload, webhook_type) Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
end end
end end

View File

@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
account.webhooks.account_type.each do |webhook| account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event]) next unless webhook.subscriptions.include?(payload[:event])
WebhookJob.perform_later(webhook.url, payload) WebhookJob.perform_later(webhook.url, payload, :account_webhook,
secret: webhook.secret,
delivery_id: SecureRandom.uuid)
end end
end end
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
return unless inbox.channel_type == 'Channel::Api' return unless inbox.channel_type == 'Channel::Api'
return if inbox.channel.webhook_url.blank? return if inbox.channel.webhook_url.blank?
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook) WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
delivery_id: SecureRandom.uuid)
end end
def deliver_webhook_payloads(payload, inbox) def deliver_webhook_payloads(payload, inbox)

View File

@@ -21,6 +21,9 @@ class Webhook < ApplicationRecord
belongs_to :account belongs_to :account
belongs_to :inbox, optional: true belongs_to :inbox, optional: true
has_secure_token :secret
encrypts :secret if Chatwoot.encryption_configured?
validates :account_id, presence: true validates :account_id, presence: true
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
validate :validate_webhook_subscriptions validate :validate_webhook_subscriptions

View File

@@ -3,6 +3,7 @@ json.name webhook.name
json.url webhook.url json.url webhook.url
json.account_id webhook.account_id json.account_id webhook.account_id
json.subscriptions webhook.subscriptions json.subscriptions webhook.subscriptions
json.secret webhook.secret
if webhook.inbox if webhook.inbox
json.inbox do json.inbox do
json.id webhook.inbox.id json.id webhook.inbox.id

View File

@@ -0,0 +1,5 @@
class AddSecretToWebhooks < ActiveRecord::Migration[7.1]
def change
add_column :webhooks, :secret, :string
end
end

View File

@@ -0,0 +1,11 @@
class BackfillWebhookSecrets < ActiveRecord::Migration[7.1]
def up
Webhook.find_each do |webhook|
webhook.update!(secret: SecureRandom.urlsafe_base64(24))
end
end
def down
# no-op: removing the column in the previous migration handles cleanup
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -1250,6 +1250,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
t.integer "webhook_type", default: 0 t.integer "webhook_type", default: 0
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"] t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"]
t.string "name" t.string "name"
t.string "secret"
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end end

View File

@@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
audited associated_with: :account audited associated_with: :account, except: [:secret]
end end
end end

View File

@@ -1,14 +1,16 @@
class Webhooks::Trigger class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
def initialize(url, payload, webhook_type) def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
@url = url @url = url
@payload = payload @payload = payload
@webhook_type = webhook_type @webhook_type = webhook_type
@secret = secret
@delivery_id = delivery_id
end end
def self.execute(url, payload, webhook_type) def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil)
new(url, payload, webhook_type).execute new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute
end end
def execute def execute
@@ -21,15 +23,27 @@ class Webhooks::Trigger
private private
def perform_request def perform_request
body = @payload.to_json
RestClient::Request.execute( RestClient::Request.execute(
method: :post, method: :post,
url: @url, url: @url,
payload: @payload.to_json, payload: body,
headers: { content_type: :json, accept: :json }, headers: request_headers(body),
timeout: webhook_timeout timeout: webhook_timeout
) )
end end
def request_headers(body)
headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
if @secret.present?
ts = Time.now.to_i.to_s
headers['X-Chatwoot-Timestamp'] = ts
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
end
headers
end
def handle_error(error) def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message return unless message
@@ -72,7 +86,11 @@ class Webhooks::Trigger
def message def message
return if message_id.blank? return if message_id.blank?
@message ||= Message.find_by(id: message_id) if defined?(@message)
@message
else
@message = Message.find_by(id: message_id)
end
end end
def message_id def message_id

View File

@@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do
end end
it 'executes perform' do it 'executes perform' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job } perform_enqueued_jobs { job }
end end
end end

View File

@@ -16,7 +16,7 @@ RSpec.describe WebhookJob do
end end
it 'executes perform with default webhook type' do it 'executes perform with default webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job } perform_enqueued_jobs { job }
end end
@@ -24,7 +24,7 @@ RSpec.describe WebhookJob do
let(:webhook_type) { :api_inbox_webhook } let(:webhook_type) { :api_inbox_webhook }
it 'executes perform with inbox webhook type' do it 'executes perform with inbox webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job } perform_enqueued_jobs { job }
end end
end end

View File

@@ -168,6 +168,71 @@ describe Webhooks::Trigger do
end end
end end
describe 'request headers' do
let(:payload) { { event: 'message_created' } }
let(:body) { payload.to_json }
context 'without secret or delivery_id' do
it 'sends only content-type and accept headers' do
expect(RestClient::Request).to receive(:execute).with(
hash_including(headers: { content_type: :json, accept: :json })
)
trigger.execute(url, payload, webhook_type)
end
end
context 'with delivery_id' do
it 'adds X-Chatwoot-Delivery header' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid')
expect(args[:headers]).not_to have_key('X-Chatwoot-Signature')
expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp')
end
trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid')
end
end
context 'with secret' do
let(:secret) { 'test-secret' }
it 'adds X-Chatwoot-Timestamp header' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'adds X-Chatwoot-Signature header with correct HMAC' do
expect(RestClient::Request).to receive(:execute) do |args|
ts = args[:headers]['X-Chatwoot-Timestamp']
expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}"
expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'signs timestamp.body not just body' do
expect(RestClient::Request).to receive(:execute) do |args|
args[:headers]['X-Chatwoot-Timestamp']
wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
end
context 'with both secret and delivery_id' do
it 'includes all three security headers' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123')
expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present
expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=')
end
trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123')
end
end
end
it 'does not update message status if webhook fails for other events' do it 'does not update message status if webhook fails for other events' do
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id } payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }

View File

@@ -28,7 +28,10 @@ describe WebhookListener do
context 'when webhook is configured and event is subscribed' do context 'when webhook is configured and event is subscribed' do
it 'triggers the webhook event' do it 'triggers the webhook event' do
webhook = create(:webhook, inbox: inbox, account: account) webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.message_created(message_created_event) listener.message_created(message_created_event)
end end
end end
@@ -54,8 +57,10 @@ describe WebhookListener do
conversation: api_conversation conversation: api_conversation
) )
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), expect(WebhookJob).to receive(:perform_later).with(
:api_inbox_webhook).once channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
).once
listener.message_created(api_event) listener.message_created(api_event)
end end
@@ -90,7 +95,10 @@ describe WebhookListener do
context 'when webhook is configured' do context 'when webhook is configured' do
it 'triggers webhook' do it 'triggers webhook' do
webhook = create(:webhook, inbox: inbox, account: account) webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_created(conversation_created_event) listener.conversation_created(conversation_created_event)
end end
end end
@@ -101,9 +109,11 @@ describe WebhookListener do
api_inbox = channel_api.inbox api_inbox = channel_api.inbox
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user) api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation) api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation)
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, expect(WebhookJob).to receive(:perform_later).with(
api_conversation.webhook_data.merge(event: 'conversation_created'), channel_api.webhook_url,
:api_inbox_webhook).once api_conversation.webhook_data.merge(event: 'conversation_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
).once
listener.conversation_created(api_event) listener.conversation_created(api_event)
end end
@@ -156,7 +166,9 @@ describe WebhookListener do
} }
} }
] ]
) ),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once ).once
listener.conversation_updated(conversation_updated_event) listener.conversation_updated(conversation_updated_event)
@@ -177,7 +189,10 @@ describe WebhookListener do
context 'when webhook is configured' do context 'when webhook is configured' do
it 'triggers webhook' do it 'triggers webhook' do
webhook = create(:webhook, account: account) webhook = create(:webhook, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.contact_created(contact_event) listener.contact_created(contact_event)
end end
end end
@@ -213,7 +228,9 @@ describe WebhookListener do
contact.webhook_data.merge( contact.webhook_data.merge(
event: 'contact_updated', event: 'contact_updated',
changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }] changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }]
) ),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once ).once
listener.contact_updated(contact_updated_event) listener.contact_updated(contact_updated_event)
end end
@@ -235,7 +252,10 @@ describe WebhookListener do
it 'triggers webhook' do it 'triggers webhook' do
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
webhook = create(:webhook, account: account, subscriptions: ['inbox_created']) webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.inbox_created(inbox_created_event) listener.inbox_created(inbox_created_event)
end end
end end
@@ -272,7 +292,9 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with( expect(WebhookJob).to receive(:perform_later).with(
webhook.url, webhook.url,
inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data) inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once ).once
listener.inbox_updated(inbox_updated_event) listener.inbox_updated(inbox_updated_event)
@@ -302,7 +324,10 @@ describe WebhookListener do
is_private: false is_private: false
} }
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, payload, :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_typing_on(typing_event) listener.conversation_typing_on(typing_event)
end end
end end
@@ -321,7 +346,10 @@ describe WebhookListener do
is_private: false is_private: false
} }
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, payload, :api_inbox_webhook,
delivery_id: instance_of(String)
).once
listener.conversation_typing_on(api_event) listener.conversation_typing_on(api_event)
end end
end end
@@ -349,7 +377,10 @@ describe WebhookListener do
is_private: false is_private: false
} }
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once expect(WebhookJob).to receive(:perform_later).with(
webhook.url, payload, :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_typing_off(typing_event) listener.conversation_typing_off(typing_event)
end end
end end

View File

@@ -8,4 +8,20 @@ RSpec.describe Webhook do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:account) } it { is_expected.to belong_to(:account) }
end end
describe 'secret token' do
let!(:account) { create(:account) }
it 'auto-generates a secret on create' do
webhook = create(:webhook, account: account)
expect(webhook.secret).to be_present
end
it 'does not regenerate the secret on update' do
webhook = create(:webhook, account: account)
original_secret = webhook.secret
webhook.update!(url: "#{webhook.url}?updated=1")
expect(webhook.reload.secret).to eq(original_secret)
end
end
end end