webhukhs 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.standard.yml +2 -0
- data/.yard-lint.yml +296 -0
- data/AGENTS.md +56 -0
- data/Appraisals +6 -2
- data/CHANGELOG.md +20 -0
- data/README.md +101 -11
- data/Rakefile +7 -0
- data/config/mutant.rb +37 -0
- data/config/mutant.yml +25 -0
- data/example/Gemfile +5 -6
- data/example/Gemfile.lock +260 -141
- data/example/app/webhooks/acme_handler.rb +16 -0
- data/example/app/webhooks/flaky_handler.rb +8 -0
- data/example/app/webhooks/signed_handler.rb +10 -0
- data/example/config/initializers/webhukhs.rb +4 -3
- data/example/config/routes.rb +3 -0
- data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
- data/example/db/schema.rb +2 -1
- data/example/db/seeds.rb +71 -7
- data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
- data/gemfiles/rails_7.2.gemfile.lock +477 -0
- data/gemfiles/rails_8.0.gemfile +0 -3
- data/gemfiles/rails_8.0.gemfile.lock +74 -4
- data/gemfiles/rails_8.1.gemfile +0 -3
- data/gemfiles/rails_8.1.gemfile.lock +74 -4
- data/gemfiles/rails_master.gemfile +7 -0
- data/lib/webhukhs/admin/application_controller.rb +32 -0
- data/lib/webhukhs/admin/assets/admin.css +476 -0
- data/lib/webhukhs/admin/assets/admin.js +111 -0
- data/lib/webhukhs/admin/config/routes.rb +6 -0
- data/lib/webhukhs/admin/engine.rb +20 -0
- data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
- data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
- data/lib/webhukhs/admin/view_helper.rb +50 -0
- data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
- data/lib/webhukhs/admin.rb +17 -0
- data/lib/webhukhs/base_handler.rb +9 -6
- data/lib/webhukhs/controllers/receive_webhooks_controller.rb +26 -7
- data/lib/webhukhs/engine.rb +1 -4
- data/lib/webhukhs/install_generator.rb +9 -2
- data/lib/webhukhs/jobs/processing_job.rb +13 -12
- data/lib/webhukhs/models/received_webhook.rb +21 -9
- data/lib/webhukhs/version.rb +1 -1
- data/lib/webhukhs.rb +9 -0
- data/test/admin_test.rb +334 -0
- data/test/base_handler_test.rb +125 -0
- data/test/browser/admin_click_through.rb +208 -0
- data/test/processing_job_test.rb +152 -12
- data/test/received_webhook_test.rb +260 -0
- data/test/test_app.rb +39 -15
- data/test/test_helper.rb +69 -0
- data/test/webhukhs_test.rb +111 -49
- metadata +173 -11
- data/example/.ruby-version +0 -1
- data/gemfiles/rails_7.1.gemfile.lock +0 -412
data/test/admin_test.rb
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class AdminTest < ActionDispatch::IntegrationTest
|
|
6
|
+
cover "Webhukhs::Admin*"
|
|
7
|
+
|
|
8
|
+
self.app = WebhukhsTestApp
|
|
9
|
+
|
|
10
|
+
teardown { Webhukhs::ReceivedWebhook.delete_all }
|
|
11
|
+
|
|
12
|
+
def create_webhook(**attributes)
|
|
13
|
+
Webhukhs::ReceivedWebhook.create!(
|
|
14
|
+
handler_module_name: "WebhookTestHandler",
|
|
15
|
+
handler_event_id: SecureRandom.uuid,
|
|
16
|
+
status: "processed",
|
|
17
|
+
body: '{"event":"thing.happened"}',
|
|
18
|
+
**attributes
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def listed_event_ids
|
|
23
|
+
css_select("table.admin-table tbody tr td:nth-child(5) code").map(&:text)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
test "lists webhooks newest first by default" do
|
|
27
|
+
create_webhook(handler_event_id: "old", created_at: 2.days.ago)
|
|
28
|
+
create_webhook(handler_event_id: "new", created_at: 1.day.ago)
|
|
29
|
+
|
|
30
|
+
get "/admin/received"
|
|
31
|
+
|
|
32
|
+
assert_response :ok
|
|
33
|
+
assert_equal ["new", "old"], listed_event_ids
|
|
34
|
+
# The whole row is the link, so it has to be there in the first cell
|
|
35
|
+
assert_select "tbody tr td:first-child a.row-link[href^='/admin/received/']", count: 2
|
|
36
|
+
assert_select "th a.sort-link[href=?]", "/admin/received?direction=asc&sort=created_at", text: "Received ▼"
|
|
37
|
+
assert_select "th a.sort-link span.sort-arrow", text: "▼", count: 1
|
|
38
|
+
assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status", text: "Status"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
test "offers an auto-refresh switch, off by default, for the listing it refetches" do
|
|
42
|
+
create_webhook
|
|
43
|
+
|
|
44
|
+
get "/admin/received"
|
|
45
|
+
|
|
46
|
+
assert_select ".page-heading label.switch input#auto-refresh[type=checkbox][role=switch]:not([checked])"
|
|
47
|
+
# admin.js swaps this element for the one in the refetched page
|
|
48
|
+
assert_select "#received-webhooks table.admin-table tbody tr", count: 1
|
|
49
|
+
assert_select "#received-webhooks nav.pagination"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
test "serves the index at the engine root" do
|
|
53
|
+
create_webhook(handler_event_id: "at-root")
|
|
54
|
+
|
|
55
|
+
get "/admin"
|
|
56
|
+
|
|
57
|
+
assert_response :ok
|
|
58
|
+
assert_equal ["at-root"], listed_event_ids
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test "sorts by a whitelisted column in either direction" do
|
|
62
|
+
create_webhook(handler_event_id: "b", status: "skipped")
|
|
63
|
+
create_webhook(handler_event_id: "a", status: "error")
|
|
64
|
+
|
|
65
|
+
get "/admin/received", params: {sort: "status", direction: "asc"}
|
|
66
|
+
assert_equal ["a", "b"], listed_event_ids
|
|
67
|
+
# Clicking the active column again flips the direction
|
|
68
|
+
assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status", text: "Status ▲"
|
|
69
|
+
|
|
70
|
+
get "/admin/received", params: {sort: "status", direction: "desc"}
|
|
71
|
+
assert_equal ["b", "a"], listed_event_ids
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
test "breaks ties by id in the sort direction, so pages do not overlap" do
|
|
75
|
+
# Event ids run opposite to ids: SQLite walks the dedup index on (handler_module_name,
|
|
76
|
+
# handler_event_id) to sort by handler, and would order the ties by event id otherwise
|
|
77
|
+
first = create_webhook(handler_event_id: "z")
|
|
78
|
+
second = create_webhook(handler_event_id: "a")
|
|
79
|
+
assert_operator first.id, :<, second.id
|
|
80
|
+
|
|
81
|
+
get "/admin/received", params: {sort: "handler_module_name", direction: "desc"}
|
|
82
|
+
assert_equal ["a", "z"], listed_event_ids
|
|
83
|
+
|
|
84
|
+
get "/admin/received", params: {sort: "handler_module_name", direction: "asc"}
|
|
85
|
+
assert_equal ["z", "a"], listed_event_ids
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "ignores sort columns and directions it does not know" do
|
|
89
|
+
# Created out of order, so that falling back to anything but created_at shows
|
|
90
|
+
create_webhook(handler_event_id: "new", created_at: 1.day.ago, status: "skipped")
|
|
91
|
+
create_webhook(handler_event_id: "old", created_at: 2.days.ago, status: "error")
|
|
92
|
+
|
|
93
|
+
get "/admin/received", params: {sort: "no_such_column", direction: "sideways"}
|
|
94
|
+
|
|
95
|
+
assert_response :ok
|
|
96
|
+
assert_equal ["new", "old"], listed_event_ids
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
test "filters by handler and status, and treats blank filters as no filter" do
|
|
100
|
+
create_webhook(handler_event_id: "test-processed")
|
|
101
|
+
create_webhook(handler_event_id: "test-error", status: "error")
|
|
102
|
+
create_webhook(handler_event_id: "private-processed", handler_module_name: "PrivateHandler")
|
|
103
|
+
|
|
104
|
+
get "/admin/received", params: {handler: "WebhookTestHandler"}
|
|
105
|
+
assert_equal ["test-error", "test-processed"], listed_event_ids.sort
|
|
106
|
+
|
|
107
|
+
get "/admin/received", params: {status: "error"}
|
|
108
|
+
assert_equal ["test-error"], listed_event_ids
|
|
109
|
+
|
|
110
|
+
get "/admin/received", params: {handler: "", status: ""}
|
|
111
|
+
assert_equal 3, listed_event_ids.length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
test "offers every configured handler and status as a filter" do
|
|
115
|
+
get "/admin/received", params: {handler: "PrivateHandler"}
|
|
116
|
+
|
|
117
|
+
handlers = css_select("select[name=handler] option").map { |option| option["value"] }
|
|
118
|
+
assert_equal ["", *Webhukhs.configuration.active_handlers.values.uniq.sort], handlers
|
|
119
|
+
assert_select "select[name=handler] option[value='']", text: "Any handler"
|
|
120
|
+
assert_select "select[name=status] option[value='']", text: "Any status"
|
|
121
|
+
# Clearing the filters keeps the sort
|
|
122
|
+
assert_select "form.filters a[href=?]", "/admin/received?direction=desc&sort=created_at", text: "Clear"
|
|
123
|
+
assert_select "select[name=handler] option[selected]", text: "PrivateHandler"
|
|
124
|
+
|
|
125
|
+
statuses = css_select("select[name=status] option").map { |option| option["value"] }
|
|
126
|
+
assert_equal ["", *Webhukhs::ReceivedWebhook.statuses.keys], statuses
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
test "lists a handler configured as a class once, by name" do
|
|
130
|
+
original_handlers = Webhukhs.configuration.active_handlers
|
|
131
|
+
Webhukhs.configuration.active_handlers = {a: WebhookTestHandler, b: "WebhookTestHandler"}
|
|
132
|
+
|
|
133
|
+
get "/admin/received"
|
|
134
|
+
|
|
135
|
+
handlers = css_select("select[name=handler] option").map { |option| option["value"] }
|
|
136
|
+
assert_equal ["", "WebhookTestHandler"], handlers
|
|
137
|
+
ensure
|
|
138
|
+
Webhukhs.configuration.active_handlers = original_handlers
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
test "paginates by #{Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE}" do
|
|
142
|
+
page_size = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
|
|
143
|
+
(page_size + 1).times { |i| create_webhook(handler_event_id: "evt-#{i}", created_at: i.minutes.ago) }
|
|
144
|
+
|
|
145
|
+
get "/admin/received", params: {sort: "status"}
|
|
146
|
+
assert_equal page_size, listed_event_ids.length
|
|
147
|
+
assert_equal "evt-#{page_size}", listed_event_ids.first # all ties on status, so by id
|
|
148
|
+
assert_select "a[rel=prev]", count: 0
|
|
149
|
+
assert_select "a[rel=next][href=?]", "/admin/received?page=2&sort=status"
|
|
150
|
+
|
|
151
|
+
get "/admin/received", params: {sort: "status", page: 2}
|
|
152
|
+
assert_equal ["evt-0"], listed_event_ids
|
|
153
|
+
assert_select "a[rel=next]", count: 0
|
|
154
|
+
assert_select "a[rel=prev][href=?]", "/admin/received?page=1&sort=status"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
test "loads one row past the page and no more" do
|
|
158
|
+
page_size = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
|
|
159
|
+
(page_size + 5).times { create_webhook }
|
|
160
|
+
|
|
161
|
+
loaded = 0
|
|
162
|
+
counter = ->(*, payload) { loaded += payload[:record_count] if payload[:class_name] == "Webhukhs::ReceivedWebhook" }
|
|
163
|
+
ActiveSupport::Notifications.subscribed(counter, "instantiation.active_record") do
|
|
164
|
+
get "/admin/received"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
assert_equal page_size + 1, loaded
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
test "shows no next page when the last page is exactly full" do
|
|
171
|
+
Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE.times { create_webhook }
|
|
172
|
+
|
|
173
|
+
get "/admin/received"
|
|
174
|
+
|
|
175
|
+
assert_select "a[rel=next]", count: 0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
test "treats a page below 1 as the first page" do
|
|
179
|
+
create_webhook(handler_event_id: "only")
|
|
180
|
+
|
|
181
|
+
get "/admin/received", params: {page: -3}
|
|
182
|
+
|
|
183
|
+
assert_equal ["only"], listed_event_ids
|
|
184
|
+
assert_select "a[rel=prev]", count: 0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
test "drops the page when changing the sort" do
|
|
188
|
+
get "/admin/received", params: {page: 3}
|
|
189
|
+
|
|
190
|
+
assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
test "says so when there is nothing to list" do
|
|
194
|
+
get "/admin/received"
|
|
195
|
+
|
|
196
|
+
assert_select "div.table-scroll > table.admin-table > thead > tr > th", count: 6
|
|
197
|
+
assert_select "table.admin-table > tbody > tr > td.empty[colspan='6']", text: "No records."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
test "colors the status badges" do
|
|
201
|
+
{
|
|
202
|
+
"processed" => "status-ok",
|
|
203
|
+
"error" => "status-bad",
|
|
204
|
+
"failed_validation" => "status-bad",
|
|
205
|
+
"received" => "status-waiting",
|
|
206
|
+
"processing" => "status-waiting",
|
|
207
|
+
"skipped" => "status-neutral"
|
|
208
|
+
}.each do |status, css_class|
|
|
209
|
+
assert_equal css_class, Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(status: status)).status_class
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
test "shows a JSON body pretty-printed, with the body as sent in a second tab" do
|
|
214
|
+
create_webhook(handler_event_id: "decoy", body: "not this one")
|
|
215
|
+
webhook = create_webhook(body: '{"event":"thing.happened","data":{"id":1}}')
|
|
216
|
+
|
|
217
|
+
get "/admin/received/#{webhook.id}"
|
|
218
|
+
|
|
219
|
+
assert_response :ok
|
|
220
|
+
assert_select "h1", text: /WebhookTestHandler/
|
|
221
|
+
assert_select "[role=tab][aria-selected=true]", text: "Pretty"
|
|
222
|
+
assert_select "[role=tab][aria-selected=false]", text: "As sent"
|
|
223
|
+
# assert_select squashes whitespace, which is the very thing under test here
|
|
224
|
+
assert_equal JSON.pretty_generate({"event" => "thing.happened", "data" => {"id" => 1}}),
|
|
225
|
+
css_select("#body-pretty:not([hidden]) code").text
|
|
226
|
+
assert_equal '{"event":"thing.happened","data":{"id":1}}', css_select("#body-raw[hidden] code").text
|
|
227
|
+
assert_select ".flash.alert", count: 0
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
test "shows a body that is not JSON as sent, replacing invalid UTF-8" do
|
|
231
|
+
webhook = create_webhook(body: "event=thing&broken=\xFF".b)
|
|
232
|
+
|
|
233
|
+
get "/admin/received/#{webhook.id}"
|
|
234
|
+
|
|
235
|
+
assert_response :ok
|
|
236
|
+
assert_select "[role=tab]", count: 0
|
|
237
|
+
assert_select ".flash.alert", text: /not valid JSON/
|
|
238
|
+
assert_select "pre.body-raw code", text: "event=thing&broken=\u{FFFD}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
test "shows a body over the display limit cut off, without pretty-printing it" do
|
|
242
|
+
limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
|
|
243
|
+
json = %({"padding":"#{"x" * limit}"})
|
|
244
|
+
webhook = create_webhook(body: json)
|
|
245
|
+
|
|
246
|
+
get "/admin/received/#{webhook.id}"
|
|
247
|
+
|
|
248
|
+
assert_response :ok
|
|
249
|
+
assert_select "[role=tab]", count: 0
|
|
250
|
+
assert_select ".flash.alert", text: "The body was 256 KB, display is truncated"
|
|
251
|
+
assert_equal json.byteslice(0, limit), css_select("pre.body-raw code").text
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
test "displays a body of exactly the display limit in full" do
|
|
255
|
+
limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
|
|
256
|
+
presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: "x" * limit))
|
|
257
|
+
|
|
258
|
+
assert_false presenter.truncated?
|
|
259
|
+
assert_equal limit, presenter.body_text.bytesize
|
|
260
|
+
assert_true Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: "x" * (limit + 1))).truncated?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
test "replaces a character the display limit cuts through" do
|
|
264
|
+
limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
|
|
265
|
+
presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: ("x" * (limit - 1)) + "é"))
|
|
266
|
+
|
|
267
|
+
assert_equal ("x" * (limit - 1)) + "\u{FFFD}", presenter.body_text
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
test "decodes the body without touching the stored bytes" do
|
|
271
|
+
webhook = create_webhook(body: "caf\xC3\xA9".b).reload
|
|
272
|
+
presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(webhook)
|
|
273
|
+
|
|
274
|
+
assert_equal "café", presenter.body_text
|
|
275
|
+
assert_equal Encoding::UTF_8, presenter.body_text.encoding
|
|
276
|
+
assert_equal Encoding::BINARY, webhook.body.encoding
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
test "shows the request headers sorted by name, with the path parameters as JSON" do
|
|
280
|
+
webhook = create_webhook(request_headers: {
|
|
281
|
+
"HTTP_X_SIGNATURE" => "sig",
|
|
282
|
+
"CONTENT_TYPE" => "application/json",
|
|
283
|
+
"action_dispatch.request.path_parameters" => {"service_id" => "test"}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
get "/admin/received/#{webhook.id}"
|
|
287
|
+
|
|
288
|
+
rows = css_select("table.admin-table thead:has(th:contains('Header')) + tbody tr").map do |row|
|
|
289
|
+
row.css("td").map(&:text)
|
|
290
|
+
end
|
|
291
|
+
assert_equal [
|
|
292
|
+
["CONTENT_TYPE", "application/json"],
|
|
293
|
+
["HTTP_X_SIGNATURE", "sig"],
|
|
294
|
+
["action_dispatch.request.path_parameters", '{"service_id":"test"}']
|
|
295
|
+
], rows
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
test "shows webhooks stored before headers were recorded" do
|
|
299
|
+
webhook = create_webhook(request_headers: nil)
|
|
300
|
+
|
|
301
|
+
get "/admin/received/#{webhook.id}"
|
|
302
|
+
|
|
303
|
+
assert_response :ok
|
|
304
|
+
assert_select "td.empty[colspan='2']", text: "No records."
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
test "renders copy buttons for the identifiers" do
|
|
308
|
+
webhook = create_webhook(handler_event_id: "evt_123")
|
|
309
|
+
|
|
310
|
+
get "/admin/received/#{webhook.id}"
|
|
311
|
+
|
|
312
|
+
assert_select "button.btn-copy[type=button][title='Copy to clipboard'][data-copy=?]", webhook.id.to_s
|
|
313
|
+
assert_select "button.btn-copy[data-copy=?]", "evt_123"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
test "locks the pages down with a content security policy" do
|
|
317
|
+
get "/admin/received"
|
|
318
|
+
|
|
319
|
+
assert_equal "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " \
|
|
320
|
+
"connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
321
|
+
response.headers["Content-Security-Policy"]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
test "serves its own assets, fingerprinted, from under the mount point" do
|
|
325
|
+
get "/admin/received"
|
|
326
|
+
|
|
327
|
+
assert_select "link[rel=stylesheet][href^='/admin/admin.css?s='][integrity^='sha256-']"
|
|
328
|
+
assert_select "script[type=module][src^='/admin/admin.js?s='][integrity^='sha256-']"
|
|
329
|
+
|
|
330
|
+
get "/admin/admin.css"
|
|
331
|
+
assert_response :ok
|
|
332
|
+
assert_includes response.body, ".admin-table"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class EnqueueProbeJob
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :received_webhook
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.perform_later(webhook)
|
|
11
|
+
self.received_webhook = webhook
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class HandleProbeHandler < Webhukhs::BaseHandler
|
|
16
|
+
attr_reader :enqueued_webhook
|
|
17
|
+
|
|
18
|
+
def enqueue(webhook)
|
|
19
|
+
@enqueued_webhook = webhook
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class BaseHandlerTest < ActiveSupport::TestCase
|
|
24
|
+
cover "Webhukhs::BaseHandler*"
|
|
25
|
+
|
|
26
|
+
teardown { Webhukhs::ReceivedWebhook.delete_all }
|
|
27
|
+
|
|
28
|
+
test "process is a no-op by default" do
|
|
29
|
+
assert_nil Webhukhs::BaseHandler.new.process(nil)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "defaults to active, valid and exposing errors" do
|
|
33
|
+
handler = Webhukhs::BaseHandler.new
|
|
34
|
+
|
|
35
|
+
assert_true handler.active?
|
|
36
|
+
assert_true handler.valid?(nil)
|
|
37
|
+
assert_true handler.expose_errors_to_sender?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "generates unique UUID event ids by default" do
|
|
41
|
+
handler = Webhukhs::BaseHandler.new
|
|
42
|
+
|
|
43
|
+
first_id = handler.extract_event_id_from_request(nil)
|
|
44
|
+
second_id = handler.extract_event_id_from_request(nil)
|
|
45
|
+
|
|
46
|
+
assert_match(/\A[0-9a-f-]{36}\z/, first_id)
|
|
47
|
+
assert_match(/\A[0-9a-f-]{36}\z/, second_id)
|
|
48
|
+
refute_equal first_id, second_id
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "handle passes the handler class name into received webhooks" do
|
|
52
|
+
captured_attributes = nil
|
|
53
|
+
request = ActionDispatch::Request.new(
|
|
54
|
+
"rack.input" => StringIO.new("{}"),
|
|
55
|
+
"REQUEST_METHOD" => "POST",
|
|
56
|
+
"CONTENT_TYPE" => "application/json",
|
|
57
|
+
"action_dispatch.request.path_parameters" => {}
|
|
58
|
+
)
|
|
59
|
+
webhook = Object.new
|
|
60
|
+
handler = HandleProbeHandler.new
|
|
61
|
+
|
|
62
|
+
def webhook.save!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
with_overridden_singleton_method(Webhukhs::ReceivedWebhook, :new, ->(**attributes) {
|
|
66
|
+
captured_attributes = attributes
|
|
67
|
+
webhook
|
|
68
|
+
}) do
|
|
69
|
+
handler.handle(request)
|
|
70
|
+
|
|
71
|
+
assert_same webhook, handler.enqueued_webhook
|
|
72
|
+
assert_equal "HandleProbeHandler", captured_attributes.fetch(:handler_module_name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test "handle logs duplicate deliveries" do
|
|
77
|
+
handler = ExtractIdHandler.new
|
|
78
|
+
request_headers = {
|
|
79
|
+
"CONTENT_TYPE" => "application/json",
|
|
80
|
+
"action_dispatch.request.path_parameters" => {}
|
|
81
|
+
}
|
|
82
|
+
payload = {event_id: "duplicate-event"}.to_json
|
|
83
|
+
|
|
84
|
+
first_request = ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(payload)))
|
|
85
|
+
second_request = ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(payload)))
|
|
86
|
+
|
|
87
|
+
handler.handle(first_request)
|
|
88
|
+
|
|
89
|
+
with_captured_info_logs(Rails) do |messages|
|
|
90
|
+
handler.handle(second_request)
|
|
91
|
+
|
|
92
|
+
assert_equal ["#{handler.inspect} Webhook duplicate-event is a duplicate delivery and will not be stored."], messages
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
test "enqueue uses configured processing job classes directly" do
|
|
97
|
+
original_job_class = Webhukhs.configuration.processing_job_class
|
|
98
|
+
webhook = Object.new
|
|
99
|
+
|
|
100
|
+
Webhukhs.configuration.processing_job_class = EnqueueProbeJob
|
|
101
|
+
EnqueueProbeJob.received_webhook = nil
|
|
102
|
+
|
|
103
|
+
Webhukhs::BaseHandler.new.enqueue(webhook)
|
|
104
|
+
|
|
105
|
+
assert_same webhook, EnqueueProbeJob.received_webhook
|
|
106
|
+
ensure
|
|
107
|
+
Webhukhs.configuration.processing_job_class = original_job_class
|
|
108
|
+
EnqueueProbeJob.received_webhook = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
test "enqueue constantizes configured processing job class names" do
|
|
112
|
+
original_job_class = Webhukhs.configuration.processing_job_class
|
|
113
|
+
webhook = Object.new
|
|
114
|
+
|
|
115
|
+
Webhukhs.configuration.processing_job_class = "EnqueueProbeJob"
|
|
116
|
+
EnqueueProbeJob.received_webhook = nil
|
|
117
|
+
|
|
118
|
+
Webhukhs::BaseHandler.new.enqueue(webhook)
|
|
119
|
+
|
|
120
|
+
assert_same webhook, EnqueueProbeJob.received_webhook
|
|
121
|
+
ensure
|
|
122
|
+
Webhukhs.configuration.processing_job_class = original_job_class
|
|
123
|
+
EnqueueProbeJob.received_webhook = nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "ferrum"
|
|
5
|
+
require "puma"
|
|
6
|
+
require "puma/server"
|
|
7
|
+
|
|
8
|
+
# Clicks through the admin in a real Chrome, so that what the request tests cannot see - the JS, the
|
|
9
|
+
# CSP letting it run, assets resolving under the mount point - is exercised too. Not part of `rake test`,
|
|
10
|
+
# run it with `rake test:browser`.
|
|
11
|
+
class AdminClickThrough < ActiveSupport::TestCase
|
|
12
|
+
# The app answers from Puma threads with connections of their own, which would not see rows
|
|
13
|
+
# created inside a test transaction
|
|
14
|
+
self.use_transactional_tests = false
|
|
15
|
+
|
|
16
|
+
PAGE_SIZE = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
|
|
17
|
+
|
|
18
|
+
def self.server
|
|
19
|
+
@server ||= Puma::Server.new(WebhukhsTestApp, nil, min_threads: 0, max_threads: 4, log_writer: Puma::LogWriter.null).tap do |server|
|
|
20
|
+
server.add_tcp_listener("127.0.0.1", 0)
|
|
21
|
+
server.run
|
|
22
|
+
Minitest.after_run { server.stop(true) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
setup do
|
|
27
|
+
@base_url = "http://127.0.0.1:#{self.class.server.connected_ports.first}/admin"
|
|
28
|
+
@console_errors = []
|
|
29
|
+
# GitHub's Ubuntu runners do not let Chrome set up its sandbox
|
|
30
|
+
browser_options = ENV["CI"] ? {"no-sandbox" => nil} : {}
|
|
31
|
+
@browser = Ferrum::Browser.new(timeout: 10, js_errors: true, browser_options: browser_options)
|
|
32
|
+
@browser.on("Log.entryAdded") { |params| @console_errors << params.dig("entry", "text") if params.dig("entry", "level") == "error" }
|
|
33
|
+
@browser.on("Runtime.consoleAPICalled") { |params| @console_errors << params["args"].map { |arg| arg["value"] }.join(" ") if params["type"] == "error" }
|
|
34
|
+
@browser.page.command("Log.enable")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
teardown do
|
|
38
|
+
failed = @browser.network.traffic.filter_map do |exchange|
|
|
39
|
+
"#{exchange.response.status} #{exchange.request.url}" if exchange.response && exchange.response.status >= 400
|
|
40
|
+
end
|
|
41
|
+
@browser.quit
|
|
42
|
+
Webhukhs::ReceivedWebhook.delete_all
|
|
43
|
+
|
|
44
|
+
assert_empty failed, "Requests failed"
|
|
45
|
+
assert_empty @console_errors, "The browser logged errors"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_webhook(**attributes)
|
|
49
|
+
Webhukhs::ReceivedWebhook.create!(
|
|
50
|
+
handler_module_name: "WebhookTestHandler",
|
|
51
|
+
handler_event_id: SecureRandom.uuid,
|
|
52
|
+
status: "processed",
|
|
53
|
+
body: '{"event":"thing.happened"}',
|
|
54
|
+
**attributes
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit(path)
|
|
59
|
+
@browser.go_to(@base_url + path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def wait_for(what, timeout: 5)
|
|
63
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
64
|
+
until (result = yield)
|
|
65
|
+
flunk "Timed out waiting for #{what}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
66
|
+
sleep 0.05
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def click_and_wait_for_page(node)
|
|
72
|
+
url = @browser.current_url
|
|
73
|
+
label = node.text.strip
|
|
74
|
+
# Ferrum clicks at the node's coordinates as they are, and a click below the fold hits nothing
|
|
75
|
+
node.scroll_into_view
|
|
76
|
+
node.click
|
|
77
|
+
wait_for("#{label.inspect} to leave #{url}") { @browser.current_url != url }
|
|
78
|
+
# The URL changes as soon as the navigation commits, while the new document may still be parsing
|
|
79
|
+
wait_for("the page after #{label.inspect} to load") { @browser.evaluate("document.readyState") == "complete" }
|
|
80
|
+
@browser.network.wait_for_idle
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# In one evaluate, so that auto-refresh swapping the table cannot pull the nodes out from under us
|
|
84
|
+
def texts(selector)
|
|
85
|
+
@browser.evaluate("Array.from(document.querySelectorAll(#{selector.to_json}), (node) => node.textContent.trim())")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "lists, sorts, filters and pages through webhooks" do
|
|
89
|
+
PAGE_SIZE.times { |i| create_webhook(handler_event_id: "evt-#{i}", created_at: i.minutes.ago) }
|
|
90
|
+
create_webhook(handler_event_id: "the-failed-one", status: "error", created_at: 2.days.ago)
|
|
91
|
+
|
|
92
|
+
visit "/received"
|
|
93
|
+
assert_equal "Received webhooks", @browser.at_css("h1").text
|
|
94
|
+
# The stylesheet must have made it through the CSP - unstyled, the header would not be a flexbox
|
|
95
|
+
assert_equal "flex", @browser.evaluate("getComputedStyle(document.querySelector('.admin-header')).display")
|
|
96
|
+
assert_equal PAGE_SIZE, @browser.css("tbody tr").length
|
|
97
|
+
assert_equal "evt-0", texts("tbody tr td:nth-child(5)").first
|
|
98
|
+
|
|
99
|
+
click_and_wait_for_page(@browser.at_xpath("//th/a[contains(., 'Received')]"))
|
|
100
|
+
assert_includes @browser.current_url, "direction=asc"
|
|
101
|
+
assert_equal "the-failed-one", texts("tbody tr td:nth-child(5)").first
|
|
102
|
+
|
|
103
|
+
# Not Ferrum's Node#select: it blanks the select by assigning it `undefined` first, which
|
|
104
|
+
# left it blank on the Chromium snapshot CI got
|
|
105
|
+
@browser.execute(<<~JS)
|
|
106
|
+
const select = document.querySelector("select[name=status]")
|
|
107
|
+
select.value = "error"
|
|
108
|
+
select.dispatchEvent(new Event("change", {bubbles: true}))
|
|
109
|
+
JS
|
|
110
|
+
assert_equal "error", @browser.evaluate("document.querySelector('select[name=status]').value")
|
|
111
|
+
click_and_wait_for_page(@browser.at_css("form.filters input[type=submit]"))
|
|
112
|
+
assert_equal ["the-failed-one"], texts("tbody tr td:nth-child(5)")
|
|
113
|
+
# The sort survives filtering
|
|
114
|
+
assert_includes @browser.current_url, "direction=asc"
|
|
115
|
+
|
|
116
|
+
click_and_wait_for_page(@browser.at_xpath("//form//a[text()='Clear']"))
|
|
117
|
+
assert_equal "Any status", @browser.evaluate("document.querySelector('select[name=status]').selectedOptions[0].text")
|
|
118
|
+
|
|
119
|
+
click_and_wait_for_page(@browser.at_css("a[rel=next]"))
|
|
120
|
+
assert_equal 1, @browser.css("tbody tr").length
|
|
121
|
+
click_and_wait_for_page(@browser.at_css("a[rel=prev]"))
|
|
122
|
+
assert_equal PAGE_SIZE, @browser.css("tbody tr").length
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
test "opens a webhook by clicking anywhere on its row" do
|
|
126
|
+
webhook = create_webhook(handler_event_id: "evt-clicked")
|
|
127
|
+
|
|
128
|
+
visit "/received"
|
|
129
|
+
# The status cell is not a link, but the row link stretched over it takes the click
|
|
130
|
+
click_and_wait_for_page(@browser.at_css("tbody tr td:nth-child(4)"))
|
|
131
|
+
|
|
132
|
+
assert @browser.current_url.end_with?("/received/#{webhook.id}"), @browser.current_url
|
|
133
|
+
assert_includes @browser.at_css("h1").text, "WebhookTestHandler"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
test "switches between the pretty and the as-sent body" do
|
|
137
|
+
webhook = create_webhook(body: '{"event":"thing.happened","data":{"id":1}}')
|
|
138
|
+
|
|
139
|
+
visit "/received/#{webhook.id}"
|
|
140
|
+
pretty = -> { @browser.evaluate("document.getElementById('body-pretty').hidden") == false }
|
|
141
|
+
raw = -> { @browser.evaluate("document.getElementById('body-raw').hidden") == false }
|
|
142
|
+
assert pretty.call
|
|
143
|
+
refute raw.call
|
|
144
|
+
|
|
145
|
+
@browser.at_css("#body-tab-raw").click
|
|
146
|
+
wait_for("the as-sent tab") { raw.call }
|
|
147
|
+
refute pretty.call
|
|
148
|
+
assert_equal "true", @browser.at_css("#body-tab-raw").attribute("aria-selected")
|
|
149
|
+
assert_equal '{"event":"thing.happened","data":{"id":1}}', @browser.at_css("#body-raw code").text
|
|
150
|
+
|
|
151
|
+
@browser.at_css("#body-tab-raw").focus
|
|
152
|
+
@browser.keyboard.type(:Left)
|
|
153
|
+
wait_for("the arrow key to go back to the pretty tab") { pretty.call }
|
|
154
|
+
assert_equal "body-tab-pretty", @browser.evaluate("document.activeElement.id")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
test "copies to the clipboard" do
|
|
158
|
+
webhook = create_webhook(handler_event_id: "evt-to-copy")
|
|
159
|
+
|
|
160
|
+
visit "/received/#{webhook.id}"
|
|
161
|
+
# Headless Chrome has no clipboard to grant, so we only check that the right text gets handed to it
|
|
162
|
+
@browser.execute("navigator.clipboard.writeText = (text) => { window.copied = text; return Promise.resolve() }")
|
|
163
|
+
|
|
164
|
+
@browser.at_css("button.btn-copy[data-copy='evt-to-copy']").click
|
|
165
|
+
wait_for("the copy button to flash") { @browser.at_css("button.btn-copy.copied[data-copy='evt-to-copy']") }
|
|
166
|
+
assert_equal "evt-to-copy", @browser.evaluate("window.copied")
|
|
167
|
+
|
|
168
|
+
@browser.at_css("button.btn-copy-panel").click
|
|
169
|
+
wait_for("the body to be copied") { @browser.evaluate("window.copied") != "evt-to-copy" }
|
|
170
|
+
assert_equal JSON.pretty_generate({"event" => "thing.happened"}), @browser.evaluate("window.copied")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
test "shows bodies that are not JSON, or too large to display" do
|
|
174
|
+
form = create_webhook(body: "event=paid&amount=12.50")
|
|
175
|
+
large = create_webhook(body: "x" * (Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT + 1))
|
|
176
|
+
|
|
177
|
+
visit "/received/#{form.id}"
|
|
178
|
+
assert_equal "This body is not valid JSON, so it is shown as it was sent", @browser.at_css(".flash.alert").text
|
|
179
|
+
assert_equal "event=paid&amount=12.50", @browser.at_css("pre.body-raw code").text
|
|
180
|
+
assert_empty @browser.css("[role=tab]")
|
|
181
|
+
|
|
182
|
+
visit "/received/#{large.id}"
|
|
183
|
+
assert_equal "The body was 256 KB, display is truncated", @browser.at_css(".flash.alert").text
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
test "picks up new webhooks while auto-refresh is on, and remembers the switch" do
|
|
187
|
+
create_webhook(handler_event_id: "evt-before")
|
|
188
|
+
|
|
189
|
+
visit "/received"
|
|
190
|
+
switch = -> { @browser.evaluate("document.getElementById('auto-refresh').checked") }
|
|
191
|
+
refute switch.call
|
|
192
|
+
@browser.at_css("label.switch").click
|
|
193
|
+
wait_for("the switch to turn on") { switch.call }
|
|
194
|
+
|
|
195
|
+
create_webhook(handler_event_id: "evt-arrived")
|
|
196
|
+
# One poll interval, with some slack
|
|
197
|
+
wait_for("the new webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(5)").include?("evt-arrived") }
|
|
198
|
+
assert_equal ["evt-arrived", "evt-before"], texts("tbody tr td:nth-child(5)")
|
|
199
|
+
|
|
200
|
+
visit "/received"
|
|
201
|
+
assert switch.call, "The switch should stay on after a reload"
|
|
202
|
+
# After a page load only the timer refetches, so this one has to come in through a poll
|
|
203
|
+
create_webhook(handler_event_id: "evt-polled")
|
|
204
|
+
wait_for("the polled webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(5)").include?("evt-polled") }
|
|
205
|
+
@browser.at_css("label.switch").click
|
|
206
|
+
wait_for("the switch to turn off") { !switch.call }
|
|
207
|
+
end
|
|
208
|
+
end
|