feat: add per-webhook secret with backfill migration (#13573)
This commit is contained in:
@@ -31,6 +31,14 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"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",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -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 { useBranding } from 'shared/composables/useBranding';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
props: {
|
||||
onClose: {
|
||||
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 props = defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<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="onClose"
|
||||
/>
|
||||
<template v-if="createdWebhook">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
"
|
||||
/>
|
||||
<div class="px-8 pb-6">
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
|
||||
</p>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
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';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -57,10 +59,14 @@ export default {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasSecret() {
|
||||
return !!this.value.secret;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@@ -81,6 +87,10 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
async copySecret() {
|
||||
await copyTextToClipboard(this.value.secret);
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@@ -111,6 +121,35 @@ export default {
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</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">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
|
||||
@@ -42,6 +42,7 @@ export const actions = {
|
||||
} = response.data;
|
||||
commit(types.default.ADD_WEBHOOK, webhook);
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
throw error;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class WebhookJob < ApplicationJob
|
||||
queue_as :medium
|
||||
# There are 3 types of webhooks, account, inbox and agent_bot
|
||||
def perform(url, payload, webhook_type = :account_webhook)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type)
|
||||
def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
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
|
||||
|
||||
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
|
||||
return unless inbox.channel_type == 'Channel::Api'
|
||||
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
|
||||
|
||||
def deliver_webhook_payloads(payload, inbox)
|
||||
|
||||
@@ -21,6 +21,9 @@ class Webhook < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
has_secure_token :secret
|
||||
encrypts :secret if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
||||
validate :validate_webhook_subscriptions
|
||||
|
||||
@@ -3,6 +3,7 @@ json.name webhook.name
|
||||
json.url webhook.url
|
||||
json.account_id webhook.account_id
|
||||
json.subscriptions webhook.subscriptions
|
||||
json.secret webhook.secret
|
||||
if webhook.inbox
|
||||
json.inbox do
|
||||
json.id webhook.inbox.id
|
||||
|
||||
5
db/migrate/20260218075101_add_secret_to_webhooks.rb
Normal file
5
db/migrate/20260218075101_add_secret_to_webhooks.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddSecretToWebhooks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :webhooks, :secret, :string
|
||||
end
|
||||
end
|
||||
11
db/migrate/20260226084618_backfill_webhook_secrets.rb
Normal file
11
db/migrate/20260226084618_backfill_webhook_secrets.rb
Normal 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
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# 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
|
||||
enable_extension "pg_stat_statements"
|
||||
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.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 "secret"
|
||||
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
audited associated_with: :account, except: [:secret]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
class Webhooks::Trigger
|
||||
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
|
||||
@payload = payload
|
||||
@webhook_type = webhook_type
|
||||
@secret = secret
|
||||
@delivery_id = delivery_id
|
||||
end
|
||||
|
||||
def self.execute(url, payload, webhook_type)
|
||||
new(url, payload, webhook_type).execute
|
||||
def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute
|
||||
end
|
||||
|
||||
def execute
|
||||
@@ -21,15 +23,27 @@ class Webhooks::Trigger
|
||||
private
|
||||
|
||||
def perform_request
|
||||
body = @payload.to_json
|
||||
RestClient::Request.execute(
|
||||
method: :post,
|
||||
url: @url,
|
||||
payload: @payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
payload: body,
|
||||
headers: request_headers(body),
|
||||
timeout: webhook_timeout
|
||||
)
|
||||
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)
|
||||
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||
return unless message
|
||||
@@ -72,7 +86,11 @@ class Webhooks::Trigger
|
||||
def message
|
||||
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
|
||||
|
||||
def message_id
|
||||
|
||||
@@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do
|
||||
end
|
||||
|
||||
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 }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ RSpec.describe WebhookJob do
|
||||
end
|
||||
|
||||
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 }
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ RSpec.describe WebhookJob do
|
||||
let(:webhook_type) { :api_inbox_webhook }
|
||||
|
||||
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 }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -168,6 +168,71 @@ describe Webhooks::Trigger do
|
||||
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
|
||||
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured and event is subscribed' do
|
||||
it 'triggers the webhook event' do
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -54,8 +57,10 @@ describe WebhookListener do
|
||||
conversation: api_conversation
|
||||
)
|
||||
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'),
|
||||
:api_inbox_webhook).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -90,7 +95,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured' do
|
||||
it 'triggers webhook' do
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -101,9 +109,11 @@ describe WebhookListener do
|
||||
api_inbox = channel_api.inbox
|
||||
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation)
|
||||
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url,
|
||||
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
||||
:api_inbox_webhook).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url,
|
||||
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
||||
:api_inbox_webhook, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_created(api_event)
|
||||
end
|
||||
|
||||
@@ -156,7 +166,9 @@ describe WebhookListener do
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
),
|
||||
:account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.conversation_updated(conversation_updated_event)
|
||||
@@ -177,7 +189,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured' do
|
||||
it 'triggers webhook' do
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -213,7 +228,9 @@ describe WebhookListener do
|
||||
contact.webhook_data.merge(
|
||||
event: 'contact_updated',
|
||||
changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }]
|
||||
)
|
||||
),
|
||||
:account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.contact_updated(contact_updated_event)
|
||||
end
|
||||
@@ -235,7 +252,10 @@ describe WebhookListener do
|
||||
it 'triggers webhook' do
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -272,7 +292,9 @@ describe WebhookListener do
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
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
|
||||
|
||||
listener.inbox_updated(inbox_updated_event)
|
||||
@@ -302,7 +324,10 @@ describe WebhookListener do
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -321,7 +346,10 @@ describe WebhookListener do
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -349,7 +377,10 @@ describe WebhookListener do
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,4 +8,20 @@ RSpec.describe Webhook do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user