Feature #29664 » 0001-Issue-Webhooks.patch
Gemfile | ||
---|---|---|
36 | 36 |
gem "html-pipeline", "~> 2.13.2" |
37 | 37 |
gem "sanitize", "~> 6.0" |
38 | 38 | |
39 |
# Triggering of Webhooks |
|
40 |
gem "rest-client", "~> 2.1" |
|
41 | ||
39 | 42 |
# Optional gem for LDAP authentication |
40 | 43 |
group :ldap do |
41 | 44 |
gem 'net-ldap', '~> 0.17.0' |
app/assets/images/icons.svg | ||
---|---|---|
501 | 501 |
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/> |
502 | 502 |
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/> |
503 | 503 |
</symbol> |
504 |
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--webhook"> |
|
505 |
<path d="M4.876 13.61a4 4 0 1 0 6.124 3.39h6"/> |
|
506 |
<path d="M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5"/> |
|
507 |
<path d="M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5"/> |
|
508 |
</symbol> |
|
504 | 509 |
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-page"> |
505 | 510 |
<path d="M6 4h11a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-11a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1m3 0v18"/> |
506 | 511 |
<path d="M13 8l2 0"/> |
app/controllers/webhooks_controller.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
class WebhooksController < ApplicationController |
|
4 |
self.main_menu = false |
|
5 | ||
6 |
before_action :require_login |
|
7 |
before_action :find_webhook, only: [:edit, :update, :destroy] |
|
8 | ||
9 |
require_sudo_mode :create, :update, :destroy |
|
10 | ||
11 |
def index |
|
12 |
@webhooks = webhooks.order(:url) |
|
13 |
end |
|
14 | ||
15 |
def new |
|
16 |
@webhook = Webhook.new |
|
17 |
end |
|
18 | ||
19 |
def edit |
|
20 |
end |
|
21 | ||
22 |
def create |
|
23 |
@webhook = webhooks.build(webhook_params) |
|
24 |
if @webhook.save |
|
25 |
redirect_to webhooks_path |
|
26 |
else |
|
27 |
render :new |
|
28 |
end |
|
29 |
end |
|
30 | ||
31 |
def update |
|
32 |
if @webhook.update(webhook_params) |
|
33 |
redirect_to webhooks_path |
|
34 |
else |
|
35 |
render :edit |
|
36 |
end |
|
37 |
end |
|
38 | ||
39 |
def destroy |
|
40 |
@webhook.destroy |
|
41 |
redirect_to webhooks_path |
|
42 |
end |
|
43 | ||
44 |
private |
|
45 | ||
46 |
def webhook_params |
|
47 |
params.require(:webhook).permit(:url, :secret, :active, events: [], project_ids: []) |
|
48 |
end |
|
49 | ||
50 |
def find_webhook |
|
51 |
@webhook = webhooks.find(params[:id]) |
|
52 |
rescue ActiveRecord::RecordNotFound |
|
53 |
render_404 |
|
54 |
end |
|
55 | ||
56 |
def webhooks |
|
57 |
User.current.webhooks |
|
58 |
end |
|
59 |
end |
app/jobs/webhook_job.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
class WebhookJob < ApplicationJob |
|
4 |
def perform(hook_id, payload_json) |
|
5 |
if hook = Webhook.find_by_id(hook_id) |
|
6 |
if hook.user&.active? |
|
7 |
User.current = hook.user |
|
8 |
hook.call payload_json |
|
9 |
else |
|
10 |
Rails.logger.debug { "WebhookJob: user with id=#{hook.user_id} is not active" } |
|
11 |
end |
|
12 |
else |
|
13 |
Rails.logger.debug { "WebhookJob: couldn't find hook with id=#{hook_id}" } |
|
14 |
end |
|
15 |
end |
|
16 |
end |
app/models/issue.rb | ||
---|---|---|
128 | 128 |
after_create_commit :add_auto_watcher |
129 | 129 |
after_commit :create_parent_issue_journal |
130 | 130 | |
131 |
after_create_commit ->{ Webhook.trigger('issue.created', self) } |
|
132 |
after_update_commit ->{ Webhook.trigger('issue.updated', self) } |
|
133 |
after_destroy_commit ->{ Webhook.trigger('issue.deleted', self) } |
|
134 | ||
131 | 135 |
# Returns a SQL conditions string used to find all issues visible by the specified user |
132 | 136 |
def self.visible_condition(user, options={}) |
133 | 137 |
Project.allowed_to_condition(user, :view_issues, options) do |role, user| |
app/models/project.rb | ||
---|---|---|
60 | 60 |
:class_name => 'IssueCustomField', |
61 | 61 |
:join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", |
62 | 62 |
:association_foreign_key => 'custom_field_id' |
63 |
has_and_belongs_to_many :webhooks |
|
64 | ||
63 | 65 |
# Default Custom Query |
64 | 66 |
belongs_to :default_issue_query, :class_name => 'IssueQuery' |
65 | 67 |
app/models/user.rb | ||
---|---|---|
92 | 92 |
has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token' |
93 | 93 |
has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token' |
94 | 94 |
has_many :email_addresses, :dependent => :delete_all |
95 |
has_many :webhooks, dependent: :destroy |
|
96 | ||
95 | 97 |
belongs_to :auth_source |
96 | 98 | |
97 | 99 |
scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")} |
app/models/webhook.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'rest-client' |
|
4 | ||
5 |
class Webhook < ApplicationRecord |
|
6 |
Executor = Struct.new(:url, :payload, :secret) do |
|
7 |
# @return [RestClient::Response] if the POST request was successful |
|
8 |
# @raise [RestClient::Exception, Exception] a `RestClient::Exception` if an |
|
9 |
# unexpected (i.e. non-successful) response status was set; it may contain |
|
10 |
# the server response. For connection errors, we may raise any other |
|
11 |
# exception. |
|
12 |
def call |
|
13 |
# DNS and therefore destination IPs might have changed since the record was saved, so check the URL, again. |
|
14 |
raise URI::BadURIError unless WebhookEndpointValidator.safe_webhook_uri?(url) |
|
15 | ||
16 |
headers = { accept: '*/*', content_type: :json, user_agent: 'Redmine' } |
|
17 |
if secret.present? |
|
18 |
headers['X-Redmine-Signature-256'] = compute_signature |
|
19 |
end |
|
20 |
Rails.logger.debug { "Webhook: POST #{url}" } |
|
21 |
RestClient.post url, payload, headers |
|
22 |
end |
|
23 | ||
24 |
# Computes the HMAC signature for the given payload and secret. |
|
25 |
# https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries |
|
26 |
def compute_signature |
|
27 |
'sha256=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload) |
|
28 |
end |
|
29 |
end |
|
30 | ||
31 |
belongs_to :user |
|
32 |
has_and_belongs_to_many :projects |
|
33 | ||
34 |
validates :url, presence: true, webhook_endpoint: true, length: { maximum: 2000 } |
|
35 |
validates :secret, length: { maximum: 255 }, allow_blank: true |
|
36 |
validate :check_events_array |
|
37 | ||
38 |
serialize :events, coder: YAML, type: Array |
|
39 | ||
40 |
scope :active, -> { where(active: true) } |
|
41 | ||
42 |
before_validation ->(hook){ hook.projects = hook.projects.to_a.select{|p| p.visible?(hook.user) } } |
|
43 | ||
44 |
# Triggers the given event for the given object, scheduling qualifying hooks |
|
45 |
# to be called. |
|
46 |
def self.trigger(event, object) |
|
47 |
hooks_for(event, object).each do |hook| |
|
48 |
payload = hook.payload(event, object) |
|
49 |
WebhookJob.perform_later(hook.id, payload.to_json) |
|
50 |
end |
|
51 |
end |
|
52 | ||
53 |
# Finds hooks for the given event and object. |
|
54 |
# Returns an array of hooks that are active, have the given event in their list |
|
55 |
# of events, and whose user can see the object. |
|
56 |
# |
|
57 |
# Object must have a project_id and respond to visible?(user) |
|
58 |
def self.hooks_for(event, object) |
|
59 |
Webhook.active |
|
60 |
.joins("INNER JOIN projects_webhooks on projects_webhooks.webhook_id = webhooks.id") |
|
61 |
.eager_load(:user) |
|
62 |
.where(users: { status: User::STATUS_ACTIVE }, projects_webhooks: { project_id: object.project_id }) |
|
63 |
.to_a.select do |hook| |
|
64 |
hook.events.include?(event) && object.visible?(hook.user) |
|
65 |
end |
|
66 |
end |
|
67 | ||
68 |
def setable_projects |
|
69 |
Project.visible |
|
70 |
end |
|
71 | ||
72 |
def setable_events |
|
73 |
WebhookPayload::EVENTS |
|
74 |
end |
|
75 | ||
76 |
def setable_event_names |
|
77 |
setable_events.map{|type, actions| actions.map{|action| "#{type}.#{action}"}}.flatten |
|
78 |
end |
|
79 | ||
80 |
# computes the payload. this happens when the hook is triggered, and the |
|
81 |
# payload is stored as part of the hook job definition. |
|
82 |
# event must be of the form 'type.action' (like 'issue.created') |
|
83 |
def payload(event, object) |
|
84 |
WebhookPayload.new(event, object, user).to_h |
|
85 |
end |
|
86 | ||
87 |
# POSTs the given payload to the hook URL, returns true if successful, false otherwise. |
|
88 |
# |
|
89 |
# logs any unsuccessful hook calls, but does not raise |
|
90 |
def call(payload_json) |
|
91 |
Executor.new(url, payload_json, secret).call |
|
92 |
true |
|
93 |
rescue => e |
|
94 |
Rails.logger.warn { "Webhook Error: #{e.message} (#{e.class})\n#{e.backtrace.join "\n"}" } |
|
95 |
false |
|
96 |
end |
|
97 | ||
98 |
private |
|
99 | ||
100 |
def check_events_array |
|
101 |
unless events.is_a?(Array) |
|
102 |
errors.add(:events, :invalid) |
|
103 |
return |
|
104 |
end |
|
105 | ||
106 |
events.reject!(&:blank?) |
|
107 |
if (events - setable_event_names).any? |
|
108 |
errors.add(:events, :invalid) |
|
109 |
end |
|
110 |
end |
|
111 |
end |
app/models/webhook_payload.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Webhook payload |
|
4 |
class WebhookPayload |
|
5 |
attr_accessor :event, :object, :user |
|
6 | ||
7 |
def initialize(event, object, user) |
|
8 |
self.event = event |
|
9 |
self.object = object |
|
10 |
self.user = user |
|
11 |
end |
|
12 | ||
13 |
EVENTS = { |
|
14 |
issue: %w[created updated deleted] |
|
15 |
} |
|
16 | ||
17 |
def to_h |
|
18 |
type, action = event.split('.') |
|
19 |
if EVENTS[type.to_sym].include?(action) |
|
20 |
send("#{type}_payload", action) |
|
21 |
else |
|
22 |
raise ArgumentError, "invalid event: #{event}" |
|
23 |
end |
|
24 |
end |
|
25 | ||
26 |
private |
|
27 | ||
28 |
def issue_payload(action) |
|
29 |
issue = object |
|
30 |
if issue.current_journal.present? |
|
31 |
journal = issue.journals.visible(user).find_by_id(issue.current_journal.id) |
|
32 |
end |
|
33 |
ts = case action |
|
34 |
when 'created' |
|
35 |
issue.created_on |
|
36 |
when 'deleted' |
|
37 |
Time.now |
|
38 |
else |
|
39 |
journal&.created_on || issue.updated_on |
|
40 |
end |
|
41 |
h = { |
|
42 |
type: event, |
|
43 |
timestamp: ts.iso8601, |
|
44 |
data: { |
|
45 |
issue: ApiRenderer.new("app/views/issues/show.api.rsb", user).to_h(issue: issue) |
|
46 |
} |
|
47 |
} |
|
48 |
if action == 'updated' && journal.present? |
|
49 |
h[:data][:journal] = journal_payload(journal) |
|
50 |
end |
|
51 |
h |
|
52 |
end |
|
53 | ||
54 |
def journal_payload(journal) |
|
55 |
{ |
|
56 |
id: journal.id, |
|
57 |
created_on: journal.created_on.iso8601, |
|
58 |
notes: journal.notes, |
|
59 |
user: { |
|
60 |
id: journal.user.id, |
|
61 |
name: journal.user.name, |
|
62 |
}, |
|
63 |
details: journal.visible_details(user).map do |d| |
|
64 |
{ |
|
65 |
property: d.property, |
|
66 |
prop_key: d.prop_key, |
|
67 |
old_value: d.old_value, |
|
68 |
value: d.value, |
|
69 |
} |
|
70 |
end |
|
71 |
} |
|
72 |
end |
|
73 | ||
74 |
# given a path to an API template (relative to RAILS_ROOT), renders it and returns the resulting hash |
|
75 |
class ApiRenderer |
|
76 |
include ApplicationHelper |
|
77 |
include CustomFieldsHelper |
|
78 |
attr_accessor :path, :params, :user |
|
79 | ||
80 |
DummyRequest = Struct.new(:params) |
|
81 | ||
82 |
def initialize(path, user, params = nil) |
|
83 |
self.path = path |
|
84 |
self.user = user |
|
85 |
self.params = params || {} |
|
86 |
end |
|
87 | ||
88 |
def to_h(**ivars) |
|
89 |
req = DummyRequest.new(params) |
|
90 |
api = Redmine::Views::Builders::Json.new(req, nil) |
|
91 |
ivars.each { |k, v| instance_variable_set :"@#{k}", v } |
|
92 |
original_user = User.current |
|
93 |
begin |
|
94 |
User.current = self.user |
|
95 |
instance_eval(File.read(Rails.root.join(path)), path, 1) |
|
96 |
ensure |
|
97 |
User.current = original_user |
|
98 |
end |
|
99 |
end |
|
100 |
end |
|
101 |
end |
app/views/my/account.html.erb | ||
---|---|---|
1 | 1 |
<div class="contextual"> |
2 | 2 |
<%= additional_emails_link(@user) %> |
3 | 3 |
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %> |
4 |
<%= link_to sprite_icon('webhook', l(:label_webhook_plural)), webhooks_path, class: 'icon icon-webhook' %> |
|
4 | 5 |
<%= call_hook(:view_my_account_contextual, :user => @user)%> |
5 | 6 |
</div> |
6 | 7 |
app/views/webhooks/_form.html.erb | ||
---|---|---|
1 |
<%= error_messages_for @webhook %> |
|
2 | ||
3 |
<div class="splitcontent"> |
|
4 |
<div class="splitcontentleft"> |
|
5 |
<div class="box tabular"> |
|
6 |
<p><%= f.text_field :url, required: true, size: 60 %> |
|
7 |
<em class="info"><%= l :webhook_url_info %></em> |
|
8 |
</p> |
|
9 |
<p> |
|
10 |
<%= f.text_field :secret %> |
|
11 |
<em class="info"><%= raw l :webhook_secret_info_html %></em> |
|
12 |
</p> |
|
13 |
<p><%= f.check_box :active %></p> |
|
14 |
</div> |
|
15 | ||
16 |
<h3><%= l :label_webhook_events %></h3> |
|
17 |
<div class="box tabular" id="events"> |
|
18 |
<% @webhook.setable_events.keys.sort.each do |type| %> |
|
19 |
<fieldset id="<%= type %>_events"><legend><%= toggle_checkboxes_link("##{type}_events\ input") %><%= l_or_humanize(type, prefix: 'webhook_events_') %></legend> |
|
20 |
<% @webhook.setable_events[type].each do |action| %> |
|
21 |
<% name = "#{type}.#{action}" %> |
|
22 |
<label class="floating"> |
|
23 |
<%= check_box_tag 'webhook[events][]', name, @webhook.events.include?(name), id: "webhook_events_#{name}" %> |
|
24 |
<%= l_or_humanize(name.tr('.', '_'), :prefix => 'webhook_events_') %> |
|
25 |
</label> |
|
26 |
<% end %> |
|
27 |
</fieldset> |
|
28 |
<% end %> |
|
29 |
<br /><%= check_all_links 'events' %> |
|
30 |
<%= hidden_field_tag 'webhook[events][]', '' %> |
|
31 |
</div> |
|
32 |
</div> |
|
33 | ||
34 |
<div class="splitcontentright"> |
|
35 |
<fieldset class="box" id="webhook_project_ids"><legend><%= toggle_checkboxes_link("#webhook_project_ids input[type=checkbox]") %><%= l(:label_project_plural) %></legend> |
|
36 |
<% project_ids = @webhook.project_ids.to_a %> |
|
37 |
<%= render_project_nested_lists(@webhook.setable_projects) do |p| |
|
38 |
content_tag('label', check_box_tag('webhook[project_ids][]', p.id, project_ids.include?(p.id), :id => nil) + ' ' + h(p)) |
|
39 |
end %> |
|
40 |
<%= hidden_field_tag('webhook[project_ids][]', '', :id => nil) %> |
|
41 |
</fieldset> |
|
42 | ||
43 |
</div> |
|
44 |
</div> |
app/views/webhooks/edit.html.erb | ||
---|---|---|
1 |
<h2><%= l :label_webhook_edit %></h2> |
|
2 | ||
3 |
<%= labelled_form_for @webhook, html: { method: :patch } do |f| %> |
|
4 |
<%= render :partial => 'form', :locals => { :f => f } %> |
|
5 |
<%= submit_tag l(:button_save) %> |
|
6 |
<% end %> |
app/views/webhooks/index.html.erb | ||
---|---|---|
1 |
<div class="contextual"> |
|
2 |
<%= link_to sprite_icon('add', l(:label_webhook_new)), new_webhook_path, class: 'icon icon-add' %> |
|
3 |
</div> |
|
4 | ||
5 |
<%= title l :label_webhook_plural %> |
|
6 | ||
7 |
<% if @webhooks.any? %> |
|
8 |
<div class="autoscroll"> |
|
9 |
<table class="list"> |
|
10 |
<thead><tr> |
|
11 |
<th><%= l :field_active %></th> |
|
12 |
<th><%= l :label_url %></th> |
|
13 |
<th><%= l :label_webhook_events %></th> |
|
14 |
<th><%= l :label_project_plural %></th> |
|
15 |
<th></th> |
|
16 |
</tr></thead> |
|
17 |
<tbody> |
|
18 |
<% @webhooks.each do |webhook| %> |
|
19 |
<tr id="webhook_<%= webhook.id %>" class="<%= cycle("odd", "even") %>"> |
|
20 |
<td><%= webhook.active ? l(:general_text_Yes) : l(:general_text_No) %></td> |
|
21 |
<td><%= truncate webhook.url, length: 40 %></td> |
|
22 |
<td><%= safe_join webhook.events.map{|e| content_tag :code, e }, ', ' %></td> |
|
23 |
<td><%= safe_join webhook.projects.visible.map{|p| link_to_project(p) }, ', ' %></td> |
|
24 |
<td class="buttons"> |
|
25 |
<%= link_to sprite_icon('edit', l(:button_edit)), edit_webhook_path(webhook), class: 'icon icon-edit' %> |
|
26 |
<%= link_to sprite_icon('del', l(:button_delete)), webhook_path(webhook), :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :class => 'icon icon-del' %> |
|
27 |
</td> |
|
28 |
</tr> |
|
29 |
<% end %> |
|
30 |
</tbody> |
|
31 |
</table> |
|
32 |
</div> |
|
33 |
<% else %> |
|
34 |
<p class="nodata"><%= l(:label_no_data) %></p> |
|
35 |
<% end %> |
app/views/webhooks/new.html.erb | ||
---|---|---|
1 |
<h2><%= l :label_webhook_new %></h2> |
|
2 | ||
3 |
<%= labelled_form_for @webhook, url: webhooks_path do |f| %> |
|
4 |
<%= render :partial => 'webhooks/form', locals: { f: f } %> |
|
5 |
<%= submit_tag l(:button_create) %> |
|
6 |
<%= link_to l(:button_cancel), webhooks_path %> |
|
7 |
<% end %> |
config/configuration.yml.example | ||
---|---|---|
224 | 224 |
# false: switches to default common mark where two or more spaces are required |
225 | 225 |
# common_mark_enable_hardbreaks: true |
226 | 226 | |
227 |
# Webhooks |
|
228 |
# |
|
229 |
# An optional list of hosts and/or IP addresses and/or IP networks which |
|
230 |
# should NOT be valid as webhook targets. You can add your internal IPs and |
|
231 |
# hostnames here to avoid possible SSRF attacks. |
|
232 |
# webhook_blocklist: |
|
233 |
# - 10.0.0.0/8 |
|
234 |
# - 172.16.0.0/12 |
|
235 |
# - 192.168.0.0/16 |
|
236 |
# - fc00::/7 |
|
237 |
# - example.org |
|
238 |
# - "*.example.com" |
|
239 | ||
227 | 240 |
# specific configuration options for production environment |
228 | 241 |
# that overrides the default ones |
229 | 242 |
production: |
config/icon_source.yml | ||
---|---|---|
221 | 221 |
- name: unwatch |
222 | 222 |
svg: eye-off |
223 | 223 |
- name: copy-pre-content |
224 |
svg: clipboard |
|
224 |
svg: clipboard |
|
225 |
- name: webhook |
|
226 |
svg: webhook |
config/locales/de.yml | ||
---|---|---|
845 | 845 |
label_yesterday: gestern |
846 | 846 |
label_default_query: Standardabfrage |
847 | 847 |
label_progressbar: Fortschrittsbalken |
848 |
label_webhook_plural: Webhooks |
|
849 |
label_webhook_new: Neuer Webhook |
|
850 |
label_webhook_edit: Webhook bearbeiten |
|
851 |
label_webhook_events: Ereignisse |
|
852 | ||
853 |
webhook_events_issue: Aufgaben |
|
854 |
webhook_events_issue_created: Aufgabe angelegt |
|
855 |
webhook_events_issue_updated: Aufgabe bearbeitet |
|
856 |
webhook_events_issue_deleted: Aufgabe gelöscht |
|
857 |
webhook_url_info: Redmine sendet einen POST-Request an diese URL, wenn eines der gewählten Ereignisse in einem der ausgewählten Projekte eintritt. |
|
858 |
webhook_secret_info_html: Wenn gesetzt, wird Redmine mit jedem Request eine Hash-Signatur im X-Redmine-Signature-256 header übermitteln, die zur Authentifizierung des Requests herangezogen werden kann. |
|
848 | 859 | |
849 | 860 |
mail_body_account_activation_request: "Ein neuer Benutzer (%{value}) hat sich registriert. Sein Konto wartet auf Ihre Genehmigung:" |
850 | 861 |
mail_body_account_information: Ihre Konto-Informationen |
config/locales/en.yml | ||
---|---|---|
1157 | 1157 |
label_time_by_author: "%{time} by %{author}" |
1158 | 1158 |
label_involved_principals: Author / Previous assignee |
1159 | 1159 |
label_progressbar: Progress bar |
1160 |
label_webhook_plural: Webhooks |
|
1161 |
label_webhook_new: New webhook |
|
1162 |
label_webhook_edit: Edit webhook |
|
1163 |
label_webhook_events: Events |
|
1164 | ||
1165 |
webhook_events_issue: Issues |
|
1166 |
webhook_events_issue_created: Issue created |
|
1167 |
webhook_events_issue_updated: Issue updated |
|
1168 |
webhook_events_issue_deleted: Issue deleted |
|
1169 |
webhook_url_info: Redmine will send a POST request to this URL whenever one of the selected events occurs in one of the selected projects. |
|
1170 |
webhook_secret_info_html: If provided, Redmine will use this to create a hash signature that is sent with each delivery as the value of the X-Redmine-Signature-256 header. |
|
1160 | 1171 | |
1161 | 1172 |
button_login: Login |
1162 | 1173 |
button_submit: Submit |
config/routes.rb | ||
---|---|---|
30 | 30 |
match 'account/activate', :to => 'account#activate', :via => :get |
31 | 31 |
get 'account/activation_email', :to => 'account#activation_email', :as => 'activation_email' |
32 | 32 | |
33 |
resources :webhooks, only: [:index, :new, :create, :edit, :update, :destroy] |
|
34 | ||
33 | 35 |
match '/news/preview', :controller => 'previews', :action => 'news', :as => 'preview_news', :via => [:get, :post, :put, :patch] |
34 | 36 |
match '/issues/preview', :to => 'previews#issue', :as => 'preview_issue', :via => [:get, :post, :put, :patch] |
35 | 37 |
match '/preview/text', :to => 'previews#text', :as => 'preview_text', :via => [:get, :post, :put, :patch] |
db/migrate/20240923073256_create_webhooks.rb | ||
---|---|---|
1 |
class CreateWebhooks < ActiveRecord::Migration[5.2] |
|
2 |
def change |
|
3 |
create_table :webhooks do |t| |
|
4 |
t.string :url, null: false, limit: 2000 |
|
5 |
t.string :secret |
|
6 |
t.text :events |
|
7 |
t.integer :user_id, null: false, index: true |
|
8 |
t.boolean :active, null: false, default: false, index: true |
|
9 |
t.timestamps |
|
10 |
end |
|
11 | ||
12 |
create_table :projects_webhooks do |t| |
|
13 |
t.integer :project_id, null: false, index: true |
|
14 |
t.integer :webhook_id, null: false, index: true |
|
15 |
end |
|
16 |
end |
|
17 |
end |
lib/webhook_endpoint_validator.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'uri' |
|
4 | ||
5 |
class WebhookEndpointValidator < ActiveModel::EachValidator |
|
6 |
def validate_each(record, attribute, value) |
|
7 |
return if value.blank? |
|
8 | ||
9 |
unless self.class.safe_webhook_uri?(value) |
|
10 |
record.errors.add attribute, :invalid |
|
11 |
end |
|
12 |
end |
|
13 | ||
14 |
def self.safe_webhook_uri?(value) |
|
15 |
uri = value.is_a?(URI) ? value : URI.parse(value) |
|
16 |
return false if uri.nil? |
|
17 | ||
18 |
return false unless valid_scheme?(uri.scheme) |
|
19 |
return false unless valid_host?(uri.host) |
|
20 |
return false unless valid_port?(uri.port) |
|
21 | ||
22 |
true |
|
23 |
rescue |
|
24 |
Rails.logger.warn { "URI failed webhook safety checks: #{uri}" } |
|
25 |
false |
|
26 |
end |
|
27 | ||
28 |
def self.valid_port?(port) |
|
29 |
!BAD_PORTS.include?(port) |
|
30 |
end |
|
31 | ||
32 |
def self.valid_scheme?(scheme) |
|
33 |
%w[http https].include?(scheme) |
|
34 |
end |
|
35 | ||
36 |
def self.blocked_hosts |
|
37 |
@blocked_hosts ||= begin |
|
38 |
ips = [] |
|
39 |
wildcards = [] |
|
40 |
hosts = [] |
|
41 | ||
42 |
Array(Redmine::Configuration['webhook_blocklist']).map(&:to_s).each do |block| |
|
43 |
# We try to parse the block as an IP address first... |
|
44 |
ips << IPAddr.new(block) |
|
45 |
rescue IPAddr::Error |
|
46 |
# If that failed, we assume it is a (wildcard) hostname |
|
47 |
if block.start_with?('*.') |
|
48 |
wildcards << Regexp.escape(block[2..]) |
|
49 |
else |
|
50 |
hosts << Regexp.escape(block) |
|
51 |
end |
|
52 |
end |
|
53 | ||
54 |
regex_parts = [] |
|
55 |
regex_parts << "(?:#{hosts.join('|')})" if hosts.any? |
|
56 |
regex_parts << "(?:.*\\.)?(?:#{wildcards.join('|')})" if wildcards.any? |
|
57 | ||
58 |
{ |
|
59 |
ips: ips.freeze, |
|
60 |
host: regex_parts.any? ? /\A(?:#{regex_parts.join('|')})\z/i : nil |
|
61 |
}.freeze |
|
62 |
end |
|
63 |
end |
|
64 | ||
65 |
def self.valid_host?(host) |
|
66 |
return false if host.blank? |
|
67 | ||
68 |
return false if blocked_hosts[:host]&.match?(host) |
|
69 | ||
70 |
Resolv.each_address(host) do |ip| |
|
71 |
ipaddr = IPAddr.new(ip) |
|
72 |
return false if ipaddr.link_local? || ipaddr.loopback? |
|
73 |
return false if IPAddr.new('224.0.0.0/24').include?(ipaddr) # multicast |
|
74 |
return false if blocked_hosts[:ips].any? { |net| net.include?(ipaddr) } |
|
75 |
end |
|
76 | ||
77 |
true |
|
78 |
end |
|
79 | ||
80 |
# A general port blacklist. Connections to these ports will not be allowed |
|
81 |
# unless the protocol overrides. |
|
82 |
# |
|
83 |
# This list is to be kept in sync with "bad ports" as defined in the |
|
84 |
# WHATWG Fetch standard at https://fetch.spec.whatwg.org/#port-blocking |
|
85 |
# |
|
86 |
# see also: https://github.com/mozilla/gecko-dev/blob/d55e89d48a8053ce45a74b0ec92c0ff6a9dcc43d/netwerk/base/nsIOService.cpp#L109-L199 |
|
87 |
# |
|
88 |
BAD_PORTS = Set[ |
|
89 |
1, # tcpmux |
|
90 |
7, # echo |
|
91 |
9, # discard |
|
92 |
11, # systat |
|
93 |
13, # daytime |
|
94 |
15, # netstat |
|
95 |
17, # qotd |
|
96 |
19, # chargen |
|
97 |
20, # ftp-data |
|
98 |
21, # ftp |
|
99 |
22, # ssh |
|
100 |
23, # telnet |
|
101 |
25, # smtp |
|
102 |
37, # time |
|
103 |
42, # name |
|
104 |
43, # nicname |
|
105 |
53, # domain |
|
106 |
69, # tftp |
|
107 |
77, # priv-rjs |
|
108 |
79, # finger |
|
109 |
87, # ttylink |
|
110 |
95, # supdup |
|
111 |
101, # hostriame |
|
112 |
102, # iso-tsap |
|
113 |
103, # gppitnp |
|
114 |
104, # acr-nema |
|
115 |
109, # pop2 |
|
116 |
110, # pop3 |
|
117 |
111, # sunrpc |
|
118 |
113, # auth |
|
119 |
115, # sftp |
|
120 |
117, # uucp-path |
|
121 |
119, # nntp |
|
122 |
123, # ntp |
|
123 |
135, # loc-srv / epmap |
|
124 |
137, # netbios |
|
125 |
139, # netbios |
|
126 |
143, # imap2 |
|
127 |
161, # snmp |
|
128 |
179, # bgp |
|
129 |
389, # ldap |
|
130 |
427, # afp (alternate) |
|
131 |
465, # smtp (alternate) |
|
132 |
512, # print / exec |
|
133 |
513, # login |
|
134 |
514, # shell |
|
135 |
515, # printer |
|
136 |
526, # tempo |
|
137 |
530, # courier |
|
138 |
531, # chat |
|
139 |
532, # netnews |
|
140 |
540, # uucp |
|
141 |
548, # afp |
|
142 |
554, # rtsp |
|
143 |
556, # remotefs |
|
144 |
563, # nntp+ssl |
|
145 |
587, # smtp (outgoing) |
|
146 |
601, # syslog-conn |
|
147 |
636, # ldap+ssl |
|
148 |
989, # ftps-data |
|
149 |
990, # ftps |
|
150 |
993, # imap+ssl |
|
151 |
995, # pop3+ssl |
|
152 |
1719, # h323gatestat |
|
153 |
1720, # h323hostcall |
|
154 |
1723, # pptp |
|
155 |
2049, # nfs |
|
156 |
3659, # apple-sasl |
|
157 |
4045, # lockd |
|
158 |
4190, # sieve |
|
159 |
5060, # sip |
|
160 |
5061, # sips |
|
161 |
6000, # x11 |
|
162 |
6566, # sane-port |
|
163 |
6665, # irc (alternate) |
|
164 |
6666, # irc (alternate) |
|
165 |
6667, # irc (default) |
|
166 |
6668, # irc (alternate) |
|
167 |
6669, # irc (alternate) |
|
168 |
6679, # osaut |
|
169 |
6697, # irc+tls |
|
170 |
10080 # amanda |
|
171 |
].freeze |
|
172 |
end |
test/functional/webhooks_controller_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'test_helper' |
|
4 | ||
5 |
class WebhooksControllerTest < Redmine::ControllerTest |
|
6 |
fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles, |
|
7 |
:groups_users, |
|
8 |
:trackers, :projects_trackers, |
|
9 |
:enabled_modules, |
|
10 |
:versions, |
|
11 |
:issue_statuses, :issue_categories, :issue_relations, :workflows, |
|
12 |
:enumerations, |
|
13 |
:issues, :journals, :journal_details |
|
14 | ||
15 |
setup do |
|
16 |
@project = Project.find 'ecookbook' |
|
17 |
@dlopper = User.find_by_login 'dlopper' |
|
18 |
@issue = @project.issues.first |
|
19 |
@hook = create_hook |
|
20 |
@other_hook = create_hook user: User.find_by_login('admin'), url: 'https://example.com/other/hook' |
|
21 |
@request.session[:user_id] = @dlopper.id |
|
22 |
end |
|
23 | ||
24 |
test "should require login" do |
|
25 |
@request.session[:user_id] = nil |
|
26 |
get :index |
|
27 |
assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fwebhooks' |
|
28 |
end |
|
29 | ||
30 |
test "should get index" do |
|
31 |
get :index |
|
32 |
assert_response :success |
|
33 |
assert_select 'td', text: @hook.url |
|
34 |
assert_select 'td', text: @other_hook.url, count: 0 |
|
35 |
end |
|
36 | ||
37 |
test "should get new" do |
|
38 |
get :new |
|
39 |
assert_response :success |
|
40 |
end |
|
41 | ||
42 |
test "should create webhook" do |
|
43 |
assert_difference 'Webhook.count' do |
|
44 |
post :create, params: { webhook: { url: 'https://example.com/new/hook', events: %w(issue.created), project_ids: [@project.id] } } |
|
45 |
end |
|
46 |
assert_redirected_to webhooks_path |
|
47 |
end |
|
48 | ||
49 |
test "should get edit" do |
|
50 |
get :edit, params: { id: @hook.id } |
|
51 |
assert_response :success |
|
52 |
end |
|
53 | ||
54 |
test "should update webhook" do |
|
55 |
patch :update, params: { id: @hook.id, webhook: { url: 'https://example.com/updated/hook' } } |
|
56 |
assert_redirected_to webhooks_path |
|
57 |
assert_equal 'https://example.com/updated/hook', @hook.reload.url |
|
58 |
end |
|
59 | ||
60 |
test 'edit should not find hook of other user' do |
|
61 |
get :edit, params: { id: @other_hook.id } |
|
62 |
assert_response :not_found |
|
63 |
end |
|
64 | ||
65 |
private |
|
66 | ||
67 |
def create_hook(url: 'https://example.com/some/hook', |
|
68 |
user: User.find_by_login('dlopper'), |
|
69 |
events: %w(issue.created issue.updated), |
|
70 |
projects: [Project.find('ecookbook')]) |
|
71 |
Webhook.create!(url: url, user: user, events: events, projects: projects) |
|
72 |
end |
|
73 |
end |
test/unit/lib/webhook_endpoint_validator_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'test_helper' |
|
4 | ||
5 |
class WebhookEndpointValidatorTest < ActiveSupport::TestCase |
|
6 |
class TestModel |
|
7 |
include ActiveModel::Validations |
|
8 |
attr_accessor :url |
|
9 | ||
10 |
def initialize(url) |
|
11 |
self.url = url |
|
12 |
end |
|
13 | ||
14 |
validates :url, webhook_endpoint: true |
|
15 |
end |
|
16 | ||
17 |
setup do |
|
18 |
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do |
|
19 |
# blocked_hosts is cached as a class variable, so initialize this here to |
|
20 |
# make sure it is done before any tests run |
|
21 |
WebhookEndpointValidator.blocked_hosts |
|
22 |
end |
|
23 |
end |
|
24 | ||
25 |
test "should validate url" do |
|
26 |
%w[ |
|
27 |
mailto:user@example.com |
|
28 |
foobar |
|
29 |
example.com |
|
30 |
file://example.com |
|
31 |
https://x.example.org/ |
|
32 |
http://x.example.org/ |
|
33 |
].each do |url| |
|
34 |
assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid" |
|
35 |
record = TestModel.new url |
|
36 |
assert_not record.valid? |
|
37 |
assert record.errors[:url].any? |
|
38 |
end |
|
39 | ||
40 |
assert WebhookEndpointValidator.safe_webhook_uri? 'https://acme.com/some/webhook?foo=bar' |
|
41 |
record = TestModel.new 'https://acme.com/some/webhook?foo=bar' |
|
42 |
assert record.valid?, record.errors.inspect |
|
43 |
end |
|
44 | ||
45 |
test "should validate ports" do |
|
46 |
%w[ |
|
47 |
http://example.com:22 |
|
48 |
http://example.com:1 |
|
49 |
].each do |url| |
|
50 |
assert_not WebhookEndpointValidator.safe_webhook_uri?(url), "#{url} should be invalid" |
|
51 |
end |
|
52 |
%w[ |
|
53 |
http://example.com |
|
54 |
http://example.com:80 |
|
55 |
http://example.com:443 |
|
56 |
http://example.com:8080 |
|
57 |
].each do |url| |
|
58 |
assert WebhookEndpointValidator.safe_webhook_uri? url |
|
59 |
end |
|
60 |
end |
|
61 | ||
62 |
test "should validate ip addresses" do |
|
63 |
%w[ |
|
64 |
127.0.0.0 |
|
65 |
127.0.0.1 |
|
66 |
10.0.0.0 |
|
67 |
10.0.1.0 |
|
68 |
169.254.1.9 |
|
69 |
192.168.2.1 |
|
70 |
224.0.0.1 |
|
71 |
::1/128 |
|
72 |
fe80::/10 |
|
73 |
].each do |ip| |
|
74 |
assert_not WebhookEndpointValidator.safe_webhook_uri? ip |
|
75 |
h = TestModel.new "http://#{ip}" |
|
76 |
assert_not h.valid?, "IP #{ip} should be invalid" |
|
77 |
assert h.errors[:url].any? |
|
78 |
end |
|
79 |
end |
|
80 |
end |
test/unit/webhook_payload_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'test_helper' |
|
4 | ||
5 |
class WebhookPayloadTest < ActiveSupport::TestCase |
|
6 |
include ActiveJob::TestHelper |
|
7 | ||
8 |
fixtures :projects, :users, :trackers, :projects_trackers, :versions, |
|
9 |
:issue_statuses, :issue_categories, :issue_relations, |
|
10 |
:enumerations, :issues, :journals, :journal_details |
|
11 | ||
12 |
setup do |
|
13 |
@dlopper = User.find_by_login 'dlopper' |
|
14 |
@project = Project.find 'ecookbook' |
|
15 |
@issue = @project.issues.first |
|
16 |
end |
|
17 | ||
18 |
test "issue update payload should contain journal" do |
|
19 |
@issue.init_journal(@dlopper) |
|
20 |
@issue.subject = "new subject" |
|
21 |
@issue.save |
|
22 |
p = WebhookPayload.new('issue.updated', @issue, @dlopper) |
|
23 |
assert h = p.to_h |
|
24 |
assert_equal 'issue.updated', h[:type] |
|
25 |
assert j = h.dig(:data, :journal) |
|
26 |
assert_equal 'Dave Lopper', j[:user][:name] |
|
27 |
assert i = h.dig(:data, :issue) |
|
28 |
assert_equal 'new subject', i[:subject], i.inspect |
|
29 |
end |
|
30 | ||
31 |
test "should compute payload of deleted issue" do |
|
32 |
@issue.destroy |
|
33 |
p = WebhookPayload.new('issue.deleted', @issue, @dlopper) |
|
34 |
assert h = p.to_h |
|
35 |
assert_equal 'issue.deleted', h[:type] |
|
36 |
assert_nil h.dig(:data, :journal) |
|
37 |
assert i = h.dig(:data, :issue) |
|
38 |
assert_equal @issue.subject, i[:subject], i.inspect |
|
39 |
end |
|
40 |
end |
test/unit/webhook_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'test_helper' |
|
4 |
require 'pp' |
|
5 | ||
6 |
class WebhookTest < ActiveSupport::TestCase |
|
7 |
include ActiveJob::TestHelper |
|
8 | ||
9 |
fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles, |
|
10 |
:groups_users, |
|
11 |
:trackers, :projects_trackers, |
|
12 |
:enabled_modules, |
|
13 |
:versions, |
|
14 |
:issue_statuses, :issue_categories, :issue_relations, :workflows, |
|
15 |
:enumerations, |
|
16 |
:issues, :journals, :journal_details |
|
17 | ||
18 |
setup do |
|
19 |
# Set ActiveJob to use the test adapter |
|
20 |
@original_adapter = ActiveJob::Base.queue_adapter |
|
21 |
ActiveJob::Base.queue_adapter = :test |
|
22 | ||
23 |
@project = Project.find 'ecookbook' |
|
24 |
@dlopper = User.find_by_login 'dlopper' |
|
25 |
@issue = @project.issues.first |
|
26 |
Redmine::Configuration.with('webhook_blocklist' => ['*.example.org', '10.0.0.0/8', '192.168.0.0/16']) do |
|
27 |
# blocked_hosts is cached as a class variable, so initialize this here to |
|
28 |
# make sure it is done before any tests run |
|
29 |
WebhookEndpointValidator.blocked_hosts |
|
30 |
end |
|
31 |
end |
|
32 | ||
33 |
teardown do |
|
34 |
# Restore the original adapter |
|
35 |
ActiveJob::Base.queue_adapter = @original_adapter |
|
36 |
end |
|
37 | ||
38 |
test "should validate url" do |
|
39 |
%w[ |
|
40 |
mailto:user@example.com |
|
41 |
https://x.example.org/ |
|
42 |
https://example.org/ |
|
43 |
https://x.example.org/foo/bar?a=b |
|
44 |
foobar |
|
45 |
example.com |
|
46 |
https://10.1.0.12/ |
|
47 |
].each do |url| |
|
48 |
hook = Webhook.new(url: url) |
|
49 |
assert_not hook.valid?, "URL '#{url}' should be invalid" |
|
50 |
assert hook.errors[:url].any? |
|
51 |
end |
|
52 |
end |
|
53 | ||
54 |
test "should validate secret length" do |
|
55 |
hook = Webhook.new secret: 'abdc' * 100 |
|
56 |
assert_not hook.valid? |
|
57 |
assert hook.errors[:secret].any? |
|
58 |
end |
|
59 | ||
60 |
test "should validate events" do |
|
61 |
Webhook.new.setable_event_names.each do |event| |
|
62 |
h = create_hook events: [event] |
|
63 |
assert h.persisted? |
|
64 |
end |
|
65 |
hook = Webhook.new(events: ['issue.created', 'invalid.event']) |
|
66 |
assert_not hook.valid? |
|
67 |
assert hook.errors[:events].any? |
|
68 |
assert_raise(ActiveRecord::SerializationTypeMismatch){ Webhook.new(events: 'issue.created') } |
|
69 |
end |
|
70 | ||
71 |
test "should clean up project list on save" do |
|
72 |
h = create_hook |
|
73 |
assert_equal [@project], h.projects |
|
74 |
@project.memberships.destroy_all |
|
75 |
@project.update is_public: false |
|
76 | ||
77 |
h.reload |
|
78 |
h.save |
|
79 |
h.reload |
|
80 |
assert_equal [], h.projects |
|
81 |
end |
|
82 | ||
83 |
test "should check ip address at run time" do |
|
84 |
%w[ |
|
85 |
127.0.0.0 |
|
86 |
127.0.0.1 |
|
87 |
10.0.0.0 |
|
88 |
10.0.1.0 |
|
89 |
169.254.1.9 |
|
90 |
192.168.2.1 |
|
91 |
224.0.0.1 |
|
92 |
::1/128 |
|
93 |
fe80::/10 |
|
94 |
].each do |ip| |
|
95 |
h = Webhook.new url: "http://#{ip}" |
|
96 |
assert_not h.valid?, "IP #{ip} should be invalid" |
|
97 |
assert h.errors[:url].any? |
|
98 |
end |
|
99 |
end |
|
100 | ||
101 |
test "should find hooks for issue" do |
|
102 |
hook = create_hook |
|
103 |
assert @issue.visible?(hook.user) |
|
104 |
assert_equal [hook], Webhook.hooks_for('issue.created', @issue) |
|
105 |
assert_equal [], Webhook.hooks_for('issue.deleted', @issue) |
|
106 |
@issue.update_column :project_id, 99 |
|
107 |
assert_equal [], Webhook.hooks_for('issue.created', @issue) |
|
108 |
end |
|
109 | ||
110 |
test "should not find inactive hook" do |
|
111 |
hook = create_hook active: false |
|
112 |
assert @issue.visible?(hook.user) |
|
113 |
assert_equal [], Webhook.hooks_for('issue.created', @issue) |
|
114 |
end |
|
115 | ||
116 |
test "should not find hook of inactive user" do |
|
117 |
admin = User.find_by_login 'admin' |
|
118 |
hook = create_hook user: admin |
|
119 |
assert_equal [hook], Webhook.hooks_for('issue.created', @issue) |
|
120 |
admin.update_column :status, 3 |
|
121 |
assert_equal [], Webhook.hooks_for('issue.created', @issue) |
|
122 |
end |
|
123 | ||
124 |
test "should find hook for deleted issue" do |
|
125 |
hook = create_hook events: ['issue.deleted'] |
|
126 |
@issue.destroy |
|
127 |
assert_equal [hook], Webhook.hooks_for('issue.deleted', @issue) |
|
128 |
end |
|
129 | ||
130 |
test "schedule should enqueue jobs for hooks" do |
|
131 |
hook = create_hook |
|
132 |
assert_enqueued_jobs 1 do |
|
133 |
assert_enqueued_with(job: WebhookJob) do |
|
134 |
Webhook.trigger('issue.created', @issue) |
|
135 |
end |
|
136 |
end |
|
137 |
end |
|
138 | ||
139 |
test "should not enqueue job for inactive hook" do |
|
140 |
hook = create_hook active: false |
|
141 |
assert_no_enqueued_jobs do |
|
142 |
Webhook.trigger('issue.created', @issue) |
|
143 |
end |
|
144 |
end |
|
145 | ||
146 |
test "should compute payload" do |
|
147 |
hook = create_hook |
|
148 |
payload = hook.payload('issue.created', @issue) |
|
149 |
assert_equal 'issue.created', payload[:type] |
|
150 |
assert_equal @issue.id, payload.dig(:data, :issue, :id) |
|
151 |
end |
|
152 | ||
153 |
test "should compute correct signature" do |
|
154 |
# we're implementing the same signature mechanism as GitHub, so might as well re-use their |
|
155 |
# example. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries |
|
156 |
e = Webhook::Executor.new('https://example.com', 'Hello, World!', "It's a Secret to Everybody") |
|
157 |
assert_equal "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17", e.compute_signature |
|
158 |
end |
|
159 | ||
160 |
private |
|
161 | ||
162 |
def create_hook(url: 'https://example.com/some/hook', user: User.find_by_login('dlopper'), projects: [Project.find('ecookbook')], events: ['issue.created'], active: true) |
|
163 |
Webhook.create!(url: url, user: user, projects: projects, events: events, active: active) |
|
164 |
end |
|
165 |
end |