Skip to content

Commit

Permalink
Fixes #290, Closes #3185 - Webhooks.
Browse files Browse the repository at this point in the history
  • Loading branch information
thorsteneckel committed Nov 10, 2020
1 parent cd763cc commit 14b5db3
Show file tree
Hide file tree
Showing 18 changed files with 441 additions and 7 deletions.
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Expand Up @@ -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">
Expand Down
Expand Up @@ -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">
Expand Down
@@ -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>
73 changes: 73 additions & 0 deletions app/jobs/trigger_webhook_job.rb
@@ -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
10 changes: 10 additions & 0 deletions app/jobs/trigger_webhook_job/record_payload.rb
@@ -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
56 changes: 56 additions & 0 deletions app/jobs/trigger_webhook_job/record_payload/base.rb
@@ -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
3 changes: 3 additions & 0 deletions app/jobs/trigger_webhook_job/record_payload/ticket.rb
@@ -0,0 +1,3 @@
class TriggerWebhookJob::RecordPayload::Ticket < TriggerWebhookJob::RecordPayload::Base
ASSOCIATIONS = %i[owner customer created_by updated_by organization priority group].freeze
end
28 changes: 28 additions & 0 deletions app/jobs/trigger_webhook_job/record_payload/ticket/article.rb
@@ -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
2 changes: 2 additions & 0 deletions app/jobs/trigger_webhook_job/request_error.rb
@@ -0,0 +1,2 @@
class TriggerWebhookJob::RequestError < StandardError
end
7 changes: 4 additions & 3 deletions app/models/concerns/checks_perform_validation.rb
Expand Up @@ -12,9 +12,10 @@ def validate_perform
validate_perform = Marshal.load(Marshal.dump(perform))

check_present = {
'article.note' => %w[body subject internal],
'notification.email' => %w[body recipient subject],
'notification.sms' => %w[body recipient],
'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|
Expand Down
3 changes: 2 additions & 1 deletion app/models/ticket.rb
Expand Up @@ -1040,6 +1040,8 @@ def perform_changes(performable, perform_origin, item = nil, current_user_id = n
next
when 'notification.email'
send_email_notification(value, article, perform_origin)
when 'notification.webhook'
TriggerWebhookJob.perform_later(performable, self, article)
end
end

Expand Down Expand Up @@ -1775,6 +1777,5 @@ def send_sms_notification(value, article, perform_origin)
updated_by_id: 1,
created_by_id: 1,
)

end
end
31 changes: 31 additions & 0 deletions spec/jobs/trigger_webhook_job/record_payload/base_example.rb
@@ -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
@@ -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
6 changes: 6 additions & 0 deletions spec/jobs/trigger_webhook_job/record_payload/ticket_spec.rb
@@ -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

0 comments on commit 14b5db3

Please sign in to comment.