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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +2 -0
  3. data/.yard-lint.yml +296 -0
  4. data/AGENTS.md +56 -0
  5. data/Appraisals +6 -2
  6. data/CHANGELOG.md +20 -0
  7. data/README.md +101 -11
  8. data/Rakefile +7 -0
  9. data/config/mutant.rb +37 -0
  10. data/config/mutant.yml +25 -0
  11. data/example/Gemfile +5 -6
  12. data/example/Gemfile.lock +260 -141
  13. data/example/app/webhooks/acme_handler.rb +16 -0
  14. data/example/app/webhooks/flaky_handler.rb +8 -0
  15. data/example/app/webhooks/signed_handler.rb +10 -0
  16. data/example/config/initializers/webhukhs.rb +4 -3
  17. data/example/config/routes.rb +3 -0
  18. data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
  19. data/example/db/schema.rb +2 -1
  20. data/example/db/seeds.rb +71 -7
  21. data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
  22. data/gemfiles/rails_7.2.gemfile.lock +477 -0
  23. data/gemfiles/rails_8.0.gemfile +0 -3
  24. data/gemfiles/rails_8.0.gemfile.lock +74 -4
  25. data/gemfiles/rails_8.1.gemfile +0 -3
  26. data/gemfiles/rails_8.1.gemfile.lock +74 -4
  27. data/gemfiles/rails_master.gemfile +7 -0
  28. data/lib/webhukhs/admin/application_controller.rb +32 -0
  29. data/lib/webhukhs/admin/assets/admin.css +476 -0
  30. data/lib/webhukhs/admin/assets/admin.js +111 -0
  31. data/lib/webhukhs/admin/config/routes.rb +6 -0
  32. data/lib/webhukhs/admin/engine.rb +20 -0
  33. data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
  34. data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
  35. data/lib/webhukhs/admin/view_helper.rb +50 -0
  36. data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
  37. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
  38. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
  39. data/lib/webhukhs/admin.rb +17 -0
  40. data/lib/webhukhs/base_handler.rb +9 -6
  41. data/lib/webhukhs/controllers/receive_webhooks_controller.rb +26 -7
  42. data/lib/webhukhs/engine.rb +1 -4
  43. data/lib/webhukhs/install_generator.rb +9 -2
  44. data/lib/webhukhs/jobs/processing_job.rb +13 -12
  45. data/lib/webhukhs/models/received_webhook.rb +21 -9
  46. data/lib/webhukhs/version.rb +1 -1
  47. data/lib/webhukhs.rb +9 -0
  48. data/test/admin_test.rb +334 -0
  49. data/test/base_handler_test.rb +125 -0
  50. data/test/browser/admin_click_through.rb +208 -0
  51. data/test/processing_job_test.rb +152 -12
  52. data/test/received_webhook_test.rb +260 -0
  53. data/test/test_app.rb +39 -15
  54. data/test/test_helper.rb +69 -0
  55. data/test/webhukhs_test.rb +111 -49
  56. metadata +173 -11
  57. data/example/.ruby-version +0 -1
  58. data/gemfiles/rails_7.1.gemfile.lock +0 -412
@@ -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