feat: add per-webhook secret with backfill migration (#13573)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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.
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user