Fixes #290, Closes #3185 - Webhooks.

This commit is contained in:
Thorsten Eckel 2020-11-10 11:31:13 +01:00
parent d7ba83681e
commit ca88877bc8
18 changed files with 441 additions and 7 deletions

View file

@ -23,6 +23,7 @@ class App.UiElement.ticket_perform_action
if groupKey is 'notification'
elements["#{groupKey}.email"] = { name: 'email', display: 'Email' }
elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' }
elements["#{groupKey}.webhook"] = { name: 'webhook', display: 'Webhook' }
else if groupKey is 'article'
elements["#{groupKey}.note"] = { name: 'note', display: 'Note' }
else
@ -395,12 +396,17 @@ class App.UiElement.ticket_perform_action
selectionRecipient = columnSelectRecipient.element()
notificationElement = $( App.view('generic/ticket_perform_action/notification')(
elementTemplate = 'notification'
if notificationType is 'webhook'
elementTemplate = 'webhook'
notificationElement = $( App.view("generic/ticket_perform_action/#{elementTemplate}")(
attribute: attribute
name: name
notificationType: notificationType
meta: meta || {}
))
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
visibilitySelection = App.UiElement.select.render(

View file

@ -9,7 +9,7 @@
<label><%- @T('Subject') %></label>
</div>
<div class="controls js-subject">
<input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" style="width: 100%;" placeholder="<%- @T('Subject') %>">
<input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" class="form-control" style="width: 100%;" placeholder="<%- @T('Subject') %>">
</div>
</div>
<div class="form-group">

View file

@ -17,7 +17,7 @@
<div class="formGroup-label">
<label><%- @T('Subject') %></label>
</div>
<div class="controls js-subject"><input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" style="width: 100%;" placeholder="<%- @T('Subject') %>"></div>
<div class="controls js-subject"><input type="text" name="<%= @name %>::subject" value="<%= @meta.subject %>" style="width: 100%;" placeholder="<%- @T('Subject') %>" class="form-control"></div>
</div>
<% end %>
<div class="form-group">

View file

@ -0,0 +1,24 @@
<div class="form-group">
<div class="formGroup-label">
<label><%- @T('Endpoint') %></label>
</div>
<div class="controls">
<input type="url" name="<%= @name %>::endpoint" value="<%= @meta.endpoint %>" class="form-control" style="width: 100%;" placeholder="https://target.example.com/webhook">
</div>
</div>
<div class="form-group">
<div class="formGroup-label">
<label><%- @T('%s Signature Token', 'HMAC-SHA1')%></label>
</div>
<div class="controls">
<input type="text" name="<%= @name %>::token" value="<%= @meta.token %>" class="form-control" style="width: 100%;" placeholder="<%- @T('some token') %>">
</div>
</div>
<div class="form-group">
<div class="formGroup-label">
<label><%- @T('Verify SSL')%></label>
</div>
<div class="controls">
<input type="checkbox" name="<%= @name %>::verify_ssl" <% if @meta.verify_ssl: %>checked<% end %>>
</div>
</div>

View file

@ -0,0 +1,73 @@
class TriggerWebhookJob < ApplicationJob
USER_ATTRIBUTE_BLACKLIST = %w[
last_login
login_failed
password
preferences
group_ids
groups
authorization_ids
authorizations
].freeze
attr_reader :ticket, :trigger, :article
retry_on TriggerWebhookJob::RequestError, attempts: 5, wait: lambda { |executions|
executions * 10.seconds
}
def perform(trigger, ticket, article)
@trigger = trigger
@ticket = ticket
@article = article
return if request.success?
raise TriggerWebhookJob::RequestError
end
private
def request
UserAgent.post(
config['endpoint'],
payload,
{
json: true,
jsonParseDisable: true,
open_timeout: 4,
read_timeout: 30,
total_timeout: 60,
headers: headers,
signature_token: config['token'],
verify_ssl: verify_ssl?,
log: {
facility: 'webhook',
},
},
)
end
def config
@config ||= trigger.perform['notification.webhook']
end
def verify_ssl?
config.fetch('verify_ssl', false).present?
end
def headers
{
'X-Zammad-Trigger' => trigger.name,
'X-Zammad-Delivery' => job_id
}
end
def payload
{
ticket: TriggerWebhookJob::RecordPayload.generate(ticket),
article: TriggerWebhookJob::RecordPayload.generate(article),
}
end
end

View file

@ -0,0 +1,10 @@
class TriggerWebhookJob::RecordPayload
def self.generate(record)
return {} if record.blank?
backend = "TriggerWebhookJob::RecordPayload::#{record.class.name}".constantize
generator = backend.new(record)
generator.generate
end
end

View file

@ -0,0 +1,56 @@
class TriggerWebhookJob::RecordPayload::Base
USER_ATTRIBUTE_BLACKLIST = %w[
last_login
login_failed
password
preferences
group_ids
groups
authorization_ids
authorizations
].freeze
attr_reader :record
def initialize(record)
@record = record
end
def generate
reflect_on_associations.each_with_object(record_attributes) do |association, result|
result[association.name.to_s] = resolved_association(association)
end
end
def resolved_association(association)
id = record_attributes["#{association.name}_id"]
return {} if id.blank?
associated_record = association.klass.lookup(id: id)
associated_record_attributes(associated_record)
end
def record_attributes
@record_attributes ||= attributes_with_association_names(record)
end
def reflect_on_associations
record.class.reflect_on_all_associations.select do |association|
self.class.const_get(:ASSOCIATIONS).include?(association.name)
end
end
def associated_record_attributes(record)
return {} if record.blank?
attributes = attributes_with_association_names(record)
return attributes if !record.instance_of?(::User)
attributes.except(*USER_ATTRIBUTE_BLACKLIST)
end
def attributes_with_association_names(record)
record.attributes_with_association_names.sort.to_h
end
end

View file

@ -0,0 +1,3 @@
class TriggerWebhookJob::RecordPayload::Ticket < TriggerWebhookJob::RecordPayload::Base
ASSOCIATIONS = %i[owner customer created_by updated_by organization priority group].freeze
end

View file

@ -0,0 +1,28 @@
class TriggerWebhookJob::RecordPayload::Ticket::Article < TriggerWebhookJob::RecordPayload::Base
ASSOCIATIONS = %i[created_by updated_by].freeze
def generate
result = add_attachments_url(super)
add_accounted_time(result)
end
def add_accounted_time(result)
result['accounted_time'] = record.ticket_time_accounting&.time_unit.to_i
result
end
def add_attachments_url(result)
return result if result['attachments'].blank?
result['attachments'].each do |attachment|
attachment['url'] = format(attachment_url_template, result['ticket_id'], result['id'], attachment['id'])
end
result
end
def attachment_url_template
@attachment_url_template ||= "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/ticket_attachment/%s/%s/%s"
end
end

View file

@ -0,0 +1,2 @@
class TriggerWebhookJob::RequestError < StandardError
end

View file

@ -15,6 +15,7 @@ module ChecksPerformValidation
'article.note' => %w[body subject internal],
'notification.email' => %w[body recipient subject],
'notification.sms' => %w[body recipient],
'notification.webhook' => %w[endpoint],
}
check_present.each do |key, values|

View file

@ -1051,6 +1051,8 @@ perform changes on ticket
next
when 'notification.email'
send_email_notification(value, article, perform_origin)
when 'notification.webhook'
TriggerWebhookJob.perform_later(performable, self, article)
end
end
@ -1786,6 +1788,5 @@ result
updated_by_id: 1,
created_by_id: 1,
)
end
end

View file

@ -0,0 +1,31 @@
RSpec.shared_examples 'TriggerWebhookJob::RecordPayload backend' do |factory|
describe 'const USER_ATTRIBUTE_BLACKLIST' do
subject(:blacklist) { described_class.const_get(:USER_ATTRIBUTE_BLACKLIST) }
it 'contains sensitive attributes' do
expect(blacklist).to include('password')
end
end
describe '#generate' do
subject(:generate) { described_class.new(record).generate }
let(:resolved_associations) { described_class.const_get(:ASSOCIATIONS).map(&:to_s) }
let(:record) { build(factory) }
it 'includes attributes with association names' do
expect(generate).to include(record.attributes_with_association_names.except(*resolved_associations))
end
it 'resolves defined associations' do
resolved_associations.each do |association|
expect(generate[association]).to be_a(Hash)
end
end
it 'does not contain blacklisted User attributes' do
expect(generate['created_by']).not_to have_key('password')
end
end
end

View file

@ -0,0 +1,45 @@
require 'rails_helper'
require 'jobs/trigger_webhook_job/record_payload/base_example'
RSpec.describe TriggerWebhookJob::RecordPayload::Ticket::Article do
it_behaves_like 'TriggerWebhookJob::RecordPayload backend', :'ticket/article'
describe '#generate' do
subject(:generate) { described_class.new(record).generate }
let(:resolved_associations) { described_class.const_get(:ASSOCIATIONS).map(&:to_s) }
let(:record) { create(:'ticket/article') }
it "adds 'accounted_time' key" do
expect(generate['accounted_time']).to be_zero
end
context 'when time accounting entry is present' do
let!(:entry) { create(:ticket_time_accounting, ticket_id: record.ticket.id, ticket_article_id: record.id) }
it "stores value as 'accounted_time' key" do
expect(generate['accounted_time']).to eq(entry.time_unit)
end
end
context 'when Article has stored attachments' do
before do
Store.add(
object: record.class.name,
o_id: record.id,
data: 'some content',
filename: 'some_file.txt',
preferences: {
'Content-Type' => 'text/plain',
},
created_by_id: 1,
)
end
it 'adds URLs to attachments' do
expect(generate['attachments'].first['url']).to include(Setting.get('fqdn'))
end
end
end
end

View file

@ -0,0 +1,6 @@
require 'rails_helper'
require 'jobs/trigger_webhook_job/record_payload/base_example'
RSpec.describe TriggerWebhookJob::RecordPayload::Ticket do
it_behaves_like 'TriggerWebhookJob::RecordPayload backend', :ticket
end

View file

@ -0,0 +1,43 @@
require 'rails_helper'
RSpec.describe TriggerWebhookJob::RecordPayload do
describe '.generate' do
subject(:generate) { described_class.generate(record) }
context 'when generator backend exists' do
let(:record) { build(:ticket) }
let(:backend) { TriggerWebhookJob::RecordPayload::Ticket }
it 'initializes backend instance and sends generate' do
instance = double()
allow(instance).to receive(:generate)
allow(backend).to receive(:new).and_return(instance)
generate
expect(instance).to have_received(:generate)
end
end
context 'when given record is nil' do
let(:record) { nil }
it 'returns an empty hash' do
expect(generate).to eq({})
end
end
context 'when given record is not supported' do
let(:record) { build(:sla) }
it 'raises an exception' do
expect { generate }.to raise_exception(NameError)
end
end
end
end

View file

@ -0,0 +1,89 @@
require 'rails_helper'
RSpec.describe TriggerWebhookJob, type: :job do
describe '#perform' do
subject(:perform) { described_class.perform_now(trigger, ticket, article) }
let(:payload_ticket) { TriggerWebhookJob::RecordPayload.generate(ticket) }
let(:payload_article) { TriggerWebhookJob::RecordPayload.generate(article) }
let!(:ticket) { create(:ticket) }
let!(:article) { create(:'ticket/article') }
let(:trigger) do
create(:trigger,
perform: {
'notification.webhook' => {
endpoint: endpoint,
token: token
}
})
end
let(:endpoint) { 'http://api.example.com/webhook' }
let(:token) { 's3cr3t-t0k3n' }
let(:response_status) { 200 }
let(:payload) do
{
ticket: payload_ticket,
article: payload_article,
}
end
let(:headers) do
{
'Content-Type' => 'application/json',
'User-Agent' => 'Zammad User Agent',
'X-Zammad-Trigger' => trigger.name,
}
end
let(:response_body) do
{}.to_json
end
before do
stub_request(:post, endpoint).to_return(status: response_status, body: response_body)
perform
end
context 'with trigger token configured' do
it 'includes X-Hub-Signature header' do
expect(WebMock).to have_requested(:post, endpoint)
.with( body: payload, headers: headers )
.with { |req| req.headers['X-Zammad-Delivery'].is_a?(String) }
.with { |req| req.headers['X-Hub-Signature'].is_a?(String) }
end
end
context 'without trigger token configured' do
let(:token) { nil }
it "doesn't include X-Hub-Signature header" do
expect(WebMock).to have_requested(:post, endpoint)
.with( body: payload, headers: headers )
.with { |req| req.headers['X-Zammad-Delivery'].is_a?(String) }
.with { |req| !req.headers.key?('X-Hub-Signature') }
end
end
context 'when response is not JSON' do
let(:response_body) { 'Thanks!' }
it 'succeeds anyway' do
expect(described_class).not_to have_been_enqueued
end
end
context "when request doesn't succeed" do
let(:response_status) { 404 }
it 'enqueues job again' do
expect(described_class).to have_been_enqueued
end
end
end
end

View file

@ -498,6 +498,22 @@ RSpec.describe Ticket, type: :model do
include_examples 'verify log visibility status'
end
end
context 'with a "notification.webhook" trigger', performs_jobs: true do
let(:trigger) do
create(:trigger,
perform: {
'notification.webhook' => {
endpoint: 'http://api.example.com/webhook',
token: '53CR3t'
}
})
end
it 'schedules the webhooks notification job' do
expect { ticket.perform_changes(trigger, 'trigger', {}, 1) }.to have_enqueued_job(TriggerWebhookJob).with(trigger, ticket, nil)
end
end
end
describe '#subject_build' do