webhukhs 0.7.0 → 0.8.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.yard-lint.yml +1 -0
  3. data/AGENTS.md +9 -17
  4. data/Appraisals +2 -2
  5. data/CHANGELOG.md +18 -0
  6. data/README.md +106 -10
  7. data/Rakefile +7 -0
  8. data/example/Gemfile +5 -6
  9. data/example/Gemfile.lock +260 -141
  10. data/example/app/webhooks/acme_handler.rb +16 -0
  11. data/example/app/webhooks/flaky_handler.rb +8 -0
  12. data/example/app/webhooks/signed_handler.rb +10 -0
  13. data/example/config/initializers/webhukhs.rb +4 -3
  14. data/example/config/routes.rb +3 -0
  15. data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
  16. data/example/db/schema.rb +2 -1
  17. data/example/db/seeds.rb +71 -7
  18. data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
  19. data/gemfiles/rails_7.2.gemfile.lock +477 -0
  20. data/gemfiles/rails_8.0.gemfile +0 -3
  21. data/gemfiles/rails_8.0.gemfile.lock +74 -4
  22. data/gemfiles/rails_8.1.gemfile +0 -3
  23. data/gemfiles/rails_8.1.gemfile.lock +74 -4
  24. data/gemfiles/rails_master.gemfile +0 -3
  25. data/lib/webhukhs/admin/application_controller.rb +32 -0
  26. data/lib/webhukhs/admin/assets/admin.css +476 -0
  27. data/lib/webhukhs/admin/assets/admin.js +111 -0
  28. data/lib/webhukhs/admin/config/routes.rb +6 -0
  29. data/lib/webhukhs/admin/engine.rb +20 -0
  30. data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
  31. data/lib/webhukhs/admin/received_webhooks_controller.rb +62 -0
  32. data/lib/webhukhs/admin/view_helper.rb +50 -0
  33. data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
  34. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +51 -0
  35. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
  36. data/lib/webhukhs/admin.rb +17 -0
  37. data/lib/webhukhs/engine.rb +0 -4
  38. data/lib/webhukhs/models/received_webhook.rb +3 -0
  39. data/lib/webhukhs/version.rb +1 -1
  40. data/test/admin_test.rb +354 -0
  41. data/test/base_handler_test.rb +3 -3
  42. data/test/browser/admin_click_through.rb +212 -0
  43. data/test/processing_job_test.rb +15 -28
  44. data/test/received_webhook_test.rb +16 -0
  45. data/test/test_app.rb +2 -0
  46. data/test/test_helper.rb +9 -6
  47. data/test/webhukhs_test.rb +20 -62
  48. metadata +165 -9
  49. data/example/.ruby-version +0 -1
  50. data/gemfiles/rails_7.1.gemfile.lock +0 -412
  51. data/lib/tasks/webhukhs_tasks.rake +0 -4
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public namespace for Webhukhs runtime and configuration.
4
+ module Webhukhs
5
+ # Read-only administration for received webhooks.
6
+ module Admin
7
+ # Lists and shows stored webhooks. Read-only: the lifecycle belongs to Webhukhs::ProcessingJob.
8
+ class ReceivedWebhooksController < ApplicationController
9
+ # Webhooks per index page
10
+ PAGE_SIZE = 50
11
+ # Additional sortable columns beyond the default id order
12
+ SORTABLE_COLUMNS = %w[created_at updated_at handler_module_name handler_event_id status].freeze
13
+
14
+ helper_method :handler_options, :status_options
15
+
16
+ # Renders a filtered, sorted page of webhooks.
17
+ #
18
+ # @return [void]
19
+ def index
20
+ @sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "id"
21
+ @direction = (params[:direction] == "asc") ? "asc" : "desc"
22
+ @page = [params[:page].to_i, 1].max
23
+
24
+ filters = {handler_module_name: params[:handler], status: params[:status]}.compact_blank
25
+
26
+ # Most columns have plenty of ties (status especially), and without a tiebreaker the DB is free
27
+ # to return them in a different order for every OFFSET - rows then repeat or vanish between pages
28
+ rows = ReceivedWebhook
29
+ .where(filters)
30
+ .order(@sort => @direction, "id" => @direction)
31
+ .offset((@page - 1) * PAGE_SIZE)
32
+ .limit(PAGE_SIZE + 1)
33
+ @next_page = @page + 1 if rows.length > PAGE_SIZE
34
+ @webhooks = rows.first(PAGE_SIZE).map { |webhook| ReceivedWebhookPresenter.new(webhook) }
35
+ end
36
+
37
+ # Renders one webhook with its body and headers.
38
+ #
39
+ # @return [void]
40
+ def show
41
+ @webhook = ReceivedWebhookPresenter.new(ReceivedWebhook.find(params.fetch(:id)))
42
+ end
43
+
44
+ private
45
+
46
+ # The configured handlers rather than a DISTINCT over the table, so that a handler which has
47
+ # not received anything yet can still be picked - "nothing arrived" is an answer too.
48
+ #
49
+ # @return [Array<String>] handler class names
50
+ def handler_options
51
+ Webhukhs.configuration.active_handlers.values.map(&:to_s).uniq.sort
52
+ end
53
+
54
+ # Statuses a webhook can be in, to filter by.
55
+ #
56
+ # @return [Hash{String => String}]
57
+ def status_options
58
+ ReceivedWebhook.statuses
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ module Admin
5
+ # Table-building helpers for the admin views.
6
+ module ViewHelper
7
+ # Renders a table inside a scroll container. Cells are `white-space: nowrap`, so one long
8
+ # column would otherwise take the whole page sideways with it.
9
+ #
10
+ # @param columns [Array<String>] header cell contents
11
+ # @param records [Array] rows to render
12
+ # @yieldparam record [Object] one of the records
13
+ # @yieldreturn [Array] cell contents for that record
14
+ # @return [ActiveSupport::SafeBuffer]
15
+ def admin_table(columns, records)
16
+ rows = records.map do |record|
17
+ tag.tr { safe_join(yield(record).map { |cell| tag.td(cell) }) }
18
+ end
19
+ rows = [tag.tr(tag.td("No records.", colspan: columns.length, class: "empty"))] if rows.empty?
20
+
21
+ thead = tag.thead(tag.tr(safe_join(columns.map { |column| tag.th(column) })))
22
+ tag.div(tag.table(safe_join([thead, tag.tbody(safe_join(rows))]), class: "admin-table"), class: "table-scroll")
23
+ end
24
+
25
+ # Column header linking to the index sorted by that column. Clicking the active column
26
+ # flips the direction, any other column starts out descending.
27
+ #
28
+ # @param label [String] header text
29
+ # @param column [String] column to sort by
30
+ # @param sort [String] currently sorted column
31
+ # @param direction [String] current direction, "asc" or "desc"
32
+ # @return [ActiveSupport::SafeBuffer]
33
+ def sort_link(label, column, sort:, direction:)
34
+ active = column == sort
35
+ next_direction = (active && direction == "desc") ? "asc" : "desc"
36
+ query = request.query_parameters.except("page").merge("sort" => column, "direction" => next_direction)
37
+ label = safe_join([label, tag.span((direction == "asc") ? "▲" : "▼", class: "sort-arrow")], " ") if active
38
+ link_to(label, query, class: "sort-link")
39
+ end
40
+
41
+ # Button copying a value to the clipboard (see admin.js).
42
+ #
43
+ # @param value [String] text to copy
44
+ # @return [ActiveSupport::SafeBuffer]
45
+ def copy_button(value)
46
+ tag.button(class: "btn-copy", type: "button", title: "Copy to clipboard", data: {copy: value})
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? "#{yield(:title)} · Webhukhs" : "Webhukhs" %></title>
7
+ <link rel="icon" href="data:,">
8
+ <%= assiette_stylesheet_tag "/admin.css" %>
9
+ <script type="module" src="<%= assiette_asset_path "/admin.js" %>" integrity="<%= assiette_asset_integrity "/admin.js" %>" crossorigin="anonymous"></script>
10
+ </head>
11
+ <body>
12
+ <header class="admin-header">
13
+ <strong><%= link_to "Webhukhs", root_path %></strong>
14
+ <nav>
15
+ <%= link_to "Received webhooks", received_webhooks_path %>
16
+ </nav>
17
+ </header>
18
+ <main>
19
+ <%= yield %>
20
+ </main>
21
+ </body>
22
+ </html>
@@ -0,0 +1,51 @@
1
+ <% content_for :title, "Received webhooks" %>
2
+ <div class="page-heading">
3
+ <h1>Received webhooks</h1>
4
+ <%# Off by default, admin.js remembers the choice and refetches the listing while it is on %>
5
+ <label class="switch">
6
+ <input type="checkbox" id="auto-refresh" role="switch">
7
+ Auto-refresh
8
+ </label>
9
+ </div>
10
+
11
+ <%= form_tag received_webhooks_path, method: :get, class: "filters" do %>
12
+ <%= hidden_field_tag :sort, @sort, id: nil %>
13
+ <%= hidden_field_tag :direction, @direction, id: nil %>
14
+ <%= select_tag :handler, options_for_select([["Any handler", ""]] + handler_options, params[:handler]), "aria-label": "Handler" %>
15
+ <%= select_tag :status, options_for_select([["Any status", ""]] + status_options.map { |status, _| [status.humanize, status] }, params[:status]), "aria-label": "Status" %>
16
+ <%= submit_tag "Filter", name: nil %>
17
+ <%= link_to "Clear", received_webhooks_path(sort: @sort, direction: @direction) %>
18
+ <% end %>
19
+
20
+ <%
21
+ columns = [
22
+ ["Id", "id"],
23
+ ["Received", "created_at"],
24
+ ["Handler", "handler_module_name"],
25
+ ["Status", "status"],
26
+ ["Event id", "handler_event_id"],
27
+ ["Updated", "updated_at"]
28
+ ].map { |label, column| sort_link(label, column, sort: @sort, direction: @direction) }
29
+ %>
30
+ <div id="received-webhooks">
31
+ <%# The id cell carries the link, and the CSS stretches it over the whole row %>
32
+ <%= admin_table(columns, @webhooks) do |webhook|
33
+ [
34
+ link_to(tag.code(webhook.id, class: "cell-ellipsis cell-id", title: webhook.id), received_webhook_path(webhook), class: "row-link"),
35
+ time_tag(webhook.created_at, webhook.created_at.iso8601),
36
+ webhook.handler_module_name,
37
+ tag.span(webhook.status, class: ["badge", webhook.status_class]),
38
+ tag.code(webhook.handler_event_id, class: "cell-ellipsis", title: webhook.handler_event_id),
39
+ time_tag(webhook.updated_at, webhook.updated_at.iso8601)
40
+ ]
41
+ end %>
42
+
43
+ <nav class="pagination">
44
+ <% if @page > 1 %>
45
+ <%= link_to "← Previous", request.query_parameters.merge("page" => @page - 1), rel: "prev" %>
46
+ <% end %>
47
+ <% if @next_page %>
48
+ <%= link_to "Next →", request.query_parameters.merge("page" => @next_page), rel: "next" %>
49
+ <% end %>
50
+ </nav>
51
+ </div>
@@ -0,0 +1,55 @@
1
+ <% content_for :title, "Webhook #{@webhook.id}" %>
2
+ <nav class="breadcrumb">
3
+ <%= link_to "Received webhooks", received_webhooks_path %> / <code><%= @webhook.id %></code>
4
+ </nav>
5
+
6
+ <h1><%= @webhook.handler_module_name %> <span class="badge <%= @webhook.status_class %>"><%= @webhook.status %></span></h1>
7
+
8
+ <h2>Attributes</h2>
9
+ <div class="table-scroll attr-scroll">
10
+ <table class="admin-table attr-table">
11
+ <tbody>
12
+ <tr><th>Id</th><td><code><%= @webhook.id %></code> <%= copy_button(@webhook.id.to_s) %></td></tr>
13
+ <tr><th>Handler</th><td><code><%= @webhook.handler_module_name %></code></td></tr>
14
+ <tr><th>Event id</th><td><code><%= @webhook.handler_event_id %></code> <%= copy_button(@webhook.handler_event_id) %></td></tr>
15
+ <tr><th>Status</th><td><span class="badge <%= @webhook.status_class %>"><%= @webhook.status %></span></td></tr>
16
+ <tr><th>Received</th><td><%= time_tag(@webhook.created_at, @webhook.created_at.iso8601) %></td></tr>
17
+ <tr><th>Updated</th><td><%= time_tag(@webhook.updated_at, @webhook.updated_at.iso8601) %></td></tr>
18
+ </tbody>
19
+ </table>
20
+ </div>
21
+
22
+ <h2>Body <span class="muted">(<%= number_to_human_size(@webhook.body.bytesize) %>)</span></h2>
23
+ <% if @webhook.truncated? %>
24
+ <p class="flash alert">The body was <%= number_to_human_size(@webhook.body.bytesize) %>, display is truncated</p>
25
+ <pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
26
+ <% elsif (pretty_json = @webhook.pretty_json) %>
27
+ <div class="tabs">
28
+ <div class="tab-bar">
29
+ <div role="tablist" aria-label="Body">
30
+ <button type="button" role="tab" id="body-tab-pretty" aria-controls="body-pretty" aria-selected="true">Pretty</button>
31
+ <button type="button" role="tab" id="body-tab-raw" aria-controls="body-raw" aria-selected="false" tabindex="-1">As sent</button>
32
+ </div>
33
+ <button type="button" class="btn-copy-panel" title="Copy the visible body to the clipboard">Copy</button>
34
+ </div>
35
+ <div role="tabpanel" id="body-pretty" aria-labelledby="body-tab-pretty">
36
+ <pre class="body-detail"><code><%= pretty_json %></code></pre>
37
+ </div>
38
+ <div role="tabpanel" id="body-raw" aria-labelledby="body-tab-raw" hidden>
39
+ <pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
40
+ </div>
41
+ </div>
42
+ <% else %>
43
+ <p class="flash alert">This body is not valid JSON, so it is shown as it was sent</p>
44
+ <pre class="body-detail body-raw"><code><%= @webhook.body_text %></code></pre>
45
+ <% end %>
46
+
47
+ <h2>Request headers</h2>
48
+ <p class="muted">
49
+ As presented by the web server and any proxies in front of it.
50
+ <code>action_dispatch.request.path_parameters</code> is not a header, but the parameters
51
+ the router matched for the request.
52
+ </p>
53
+ <%= admin_table(["Header", "Value"], @webhook.header_rows) do |name, value|
54
+ [tag.code(name), tag.code(value, class: "header-value")]
55
+ end %>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "assiette"
4
+ require_relative "../webhukhs"
5
+ require_relative "admin/engine"
6
+ require_relative "admin/view_helper"
7
+ require_relative "admin/received_webhook_presenter"
8
+ require_relative "admin/application_controller"
9
+ require_relative "admin/received_webhooks_controller"
10
+
11
+ # Public namespace for Webhukhs runtime and configuration.
12
+ module Webhukhs
13
+ # Optional, read-only admin UI for received webhooks. It is not loaded by `require "webhukhs"` -
14
+ # require "webhukhs/admin" and mount {Webhukhs::Admin::Engine} yourself, behind your own authentication.
15
+ module Admin
16
+ end
17
+ end
@@ -17,9 +17,5 @@ module Webhukhs
17
17
  generators do
18
18
  require_relative "install_generator"
19
19
  end
20
-
21
- routes do
22
- post "/:service_id" => "received_webhooks#create"
23
- end
24
20
  end
25
21
  end
@@ -53,6 +53,9 @@ module Webhukhs
53
53
  raise "Cannot accept the webhook as the request body is larger than #{Webhukhs.configuration.request_body_size_limit} bytes"
54
54
  end
55
55
 
56
+ # Rails parses a form-encoded body into params before the handler gets the request, and
57
+ # leaves the stream at its end - we would store an empty body without the rewind
58
+ request_body_io.rewind
56
59
  write_attribute("body", request_body_io.read.b)
57
60
  write_attribute("request_headers", headers)
58
61
  ensure
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhukhs
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -0,0 +1,354 @@
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 by descending id regardless of received time by default" do
27
+ create_webhook(handler_event_id: "first", created_at: 1.day.ago)
28
+ create_webhook(handler_event_id: "last", created_at: 2.days.ago)
29
+
30
+ get "/admin/received"
31
+
32
+ assert_response :ok
33
+ assert_equal ["last", "first"], 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=id", text: "Id ▼"
37
+ assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=created_at", text: "Received"
38
+ assert_select "th a.sort-link span.sort-arrow", text: "▼", count: 1
39
+ assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status", text: "Status"
40
+ end
41
+
42
+ test "sorts by id in ascending order when requested" do
43
+ create_webhook(handler_event_id: "first", created_at: 1.day.ago)
44
+ create_webhook(handler_event_id: "last", created_at: 2.days.ago)
45
+
46
+ get "/admin/received", params: {sort: "id", direction: "asc"}
47
+
48
+ assert_equal ["first", "last"], listed_event_ids
49
+ assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=id", text: "Id ▲"
50
+ end
51
+
52
+ test "still sorts by received time when explicitly requested" do
53
+ create_webhook(handler_event_id: "new", created_at: 1.day.ago)
54
+ create_webhook(handler_event_id: "old", created_at: 2.days.ago)
55
+
56
+ get "/admin/received", params: {sort: "created_at"}
57
+
58
+ assert_equal ["new", "old"], listed_event_ids
59
+ end
60
+
61
+ test "offers an auto-refresh switch, off by default, for the listing it refetches" do
62
+ create_webhook
63
+
64
+ get "/admin/received"
65
+
66
+ assert_select ".page-heading label.switch input#auto-refresh[type=checkbox][role=switch]:not([checked])"
67
+ # admin.js swaps this element for the one in the refetched page
68
+ assert_select "#received-webhooks table.admin-table tbody tr", count: 1
69
+ assert_select "#received-webhooks nav.pagination"
70
+ end
71
+
72
+ test "serves the index at the engine root" do
73
+ create_webhook(handler_event_id: "at-root")
74
+
75
+ get "/admin"
76
+
77
+ assert_response :ok
78
+ assert_equal ["at-root"], listed_event_ids
79
+ end
80
+
81
+ test "sorts by a whitelisted column in either direction" do
82
+ create_webhook(handler_event_id: "b", status: "skipped")
83
+ create_webhook(handler_event_id: "a", status: "error")
84
+
85
+ get "/admin/received", params: {sort: "status", direction: "asc"}
86
+ assert_equal ["a", "b"], listed_event_ids
87
+ # Clicking the active column again flips the direction
88
+ assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status", text: "Status ▲"
89
+
90
+ get "/admin/received", params: {sort: "status", direction: "desc"}
91
+ assert_equal ["b", "a"], listed_event_ids
92
+ end
93
+
94
+ test "breaks ties by id in the sort direction, so pages do not overlap" do
95
+ # Event ids run opposite to ids: SQLite walks the dedup index on (handler_module_name,
96
+ # handler_event_id) to sort by handler, and would order the ties by event id otherwise
97
+ first = create_webhook(handler_event_id: "z")
98
+ second = create_webhook(handler_event_id: "a")
99
+ assert_operator first.id, :<, second.id
100
+
101
+ get "/admin/received", params: {sort: "handler_module_name", direction: "desc"}
102
+ assert_equal ["a", "z"], listed_event_ids
103
+
104
+ get "/admin/received", params: {sort: "handler_module_name", direction: "asc"}
105
+ assert_equal ["z", "a"], listed_event_ids
106
+ end
107
+
108
+ test "ignores unsupported sort columns and directions" do
109
+ # Creation order differs from received time, so the fallback must use id.
110
+ create_webhook(handler_event_id: "new", created_at: 1.day.ago, status: "skipped", body: "z")
111
+ create_webhook(handler_event_id: "old", created_at: 2.days.ago, status: "error", body: "a")
112
+
113
+ get "/admin/received", params: {sort: "body", direction: "sideways"}
114
+
115
+ assert_response :ok
116
+ assert_equal ["old", "new"], listed_event_ids
117
+ end
118
+
119
+ test "filters by handler and status, and treats blank filters as no filter" do
120
+ create_webhook(handler_event_id: "test-processed")
121
+ create_webhook(handler_event_id: "test-error", status: "error")
122
+ create_webhook(handler_event_id: "private-processed", handler_module_name: "PrivateHandler")
123
+
124
+ get "/admin/received", params: {handler: "WebhookTestHandler"}
125
+ assert_equal ["test-error", "test-processed"], listed_event_ids.sort
126
+
127
+ get "/admin/received", params: {status: "error"}
128
+ assert_equal ["test-error"], listed_event_ids
129
+
130
+ get "/admin/received", params: {handler: "", status: ""}
131
+ assert_equal 3, listed_event_ids.length
132
+ end
133
+
134
+ test "offers every configured handler and status as a filter" do
135
+ get "/admin/received", params: {handler: "PrivateHandler"}
136
+
137
+ handlers = css_select("select[name=handler] option").map { |option| option["value"] }
138
+ assert_equal ["", *Webhukhs.configuration.active_handlers.values.uniq.sort], handlers
139
+ assert_select "select[name=handler] option[value='']", text: "Any handler"
140
+ assert_select "select[name=status] option[value='']", text: "Any status"
141
+ # Clearing the filters keeps the sort
142
+ assert_select "form.filters a[href=?]", "/admin/received?direction=desc&sort=id", text: "Clear"
143
+ assert_select "select[name=handler] option[selected]", text: "PrivateHandler"
144
+
145
+ statuses = css_select("select[name=status] option").map { |option| option["value"] }
146
+ assert_equal ["", *Webhukhs::ReceivedWebhook.statuses.keys], statuses
147
+ end
148
+
149
+ test "lists a handler configured as a class once, by name" do
150
+ original_handlers = Webhukhs.configuration.active_handlers
151
+ Webhukhs.configuration.active_handlers = {a: WebhookTestHandler, b: "WebhookTestHandler"}
152
+
153
+ get "/admin/received"
154
+
155
+ handlers = css_select("select[name=handler] option").map { |option| option["value"] }
156
+ assert_equal ["", "WebhookTestHandler"], handlers
157
+ ensure
158
+ Webhukhs.configuration.active_handlers = original_handlers
159
+ end
160
+
161
+ test "paginates by #{Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE}" do
162
+ page_size = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
163
+ (page_size + 1).times { |i| create_webhook(handler_event_id: "evt-#{i}", created_at: i.minutes.ago) }
164
+
165
+ get "/admin/received", params: {sort: "status"}
166
+ assert_equal page_size, listed_event_ids.length
167
+ assert_equal "evt-#{page_size}", listed_event_ids.first # all ties on status, so by id
168
+ assert_select "a[rel=prev]", count: 0
169
+ assert_select "a[rel=next][href=?]", "/admin/received?page=2&sort=status"
170
+
171
+ get "/admin/received", params: {sort: "status", page: 2}
172
+ assert_equal ["evt-0"], listed_event_ids
173
+ assert_select "a[rel=next]", count: 0
174
+ assert_select "a[rel=prev][href=?]", "/admin/received?page=1&sort=status"
175
+ end
176
+
177
+ test "loads one row past the page and no more" do
178
+ page_size = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
179
+ (page_size + 5).times { create_webhook }
180
+
181
+ loaded = 0
182
+ counter = ->(*, payload) { loaded += payload[:record_count] if payload[:class_name] == "Webhukhs::ReceivedWebhook" }
183
+ ActiveSupport::Notifications.subscribed(counter, "instantiation.active_record") do
184
+ get "/admin/received"
185
+ end
186
+
187
+ assert_equal page_size + 1, loaded
188
+ end
189
+
190
+ test "shows no next page when the last page is exactly full" do
191
+ Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE.times { create_webhook }
192
+
193
+ get "/admin/received"
194
+
195
+ assert_select "a[rel=next]", count: 0
196
+ end
197
+
198
+ test "treats a page below 1 as the first page" do
199
+ create_webhook(handler_event_id: "only")
200
+
201
+ get "/admin/received", params: {page: -3}
202
+
203
+ assert_equal ["only"], listed_event_ids
204
+ assert_select "a[rel=prev]", count: 0
205
+ end
206
+
207
+ test "drops the page when changing the sort" do
208
+ get "/admin/received", params: {page: 3}
209
+
210
+ assert_select "th a.sort-link[href=?]", "/admin/received?direction=desc&sort=status"
211
+ end
212
+
213
+ test "says so when there is nothing to list" do
214
+ get "/admin/received"
215
+
216
+ assert_select "div.table-scroll > table.admin-table > thead > tr > th", count: 6
217
+ assert_select "table.admin-table > tbody > tr > td.empty[colspan='6']", text: "No records."
218
+ end
219
+
220
+ test "colors the status badges" do
221
+ {
222
+ "processed" => "status-ok",
223
+ "error" => "status-bad",
224
+ "failed_validation" => "status-bad",
225
+ "received" => "status-waiting",
226
+ "processing" => "status-waiting",
227
+ "skipped" => "status-neutral"
228
+ }.each do |status, css_class|
229
+ assert_equal css_class, Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(status: status)).status_class
230
+ end
231
+ end
232
+
233
+ test "shows a JSON body pretty-printed, with the body as sent in a second tab" do
234
+ create_webhook(handler_event_id: "decoy", body: "not this one")
235
+ webhook = create_webhook(body: '{"event":"thing.happened","data":{"id":1}}')
236
+
237
+ get "/admin/received/#{webhook.id}"
238
+
239
+ assert_response :ok
240
+ assert_select "h1", text: /WebhookTestHandler/
241
+ assert_select "[role=tab][aria-selected=true]", text: "Pretty"
242
+ assert_select "[role=tab][aria-selected=false]", text: "As sent"
243
+ # assert_select squashes whitespace, which is the very thing under test here
244
+ assert_equal JSON.pretty_generate({"event" => "thing.happened", "data" => {"id" => 1}}),
245
+ css_select("#body-pretty:not([hidden]) code").text
246
+ assert_equal '{"event":"thing.happened","data":{"id":1}}', css_select("#body-raw[hidden] code").text
247
+ assert_select ".flash.alert", count: 0
248
+ end
249
+
250
+ test "shows a body that is not JSON as sent, replacing invalid UTF-8" do
251
+ webhook = create_webhook(body: "event=thing&broken=\xFF".b)
252
+
253
+ get "/admin/received/#{webhook.id}"
254
+
255
+ assert_response :ok
256
+ assert_select "[role=tab]", count: 0
257
+ assert_select ".flash.alert", text: /not valid JSON/
258
+ assert_select "pre.body-raw code", text: "event=thing&broken=\u{FFFD}"
259
+ end
260
+
261
+ test "shows a body over the display limit cut off, without pretty-printing it" do
262
+ limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
263
+ json = %({"padding":"#{"x" * limit}"})
264
+ webhook = create_webhook(body: json)
265
+
266
+ get "/admin/received/#{webhook.id}"
267
+
268
+ assert_response :ok
269
+ assert_select "[role=tab]", count: 0
270
+ assert_select ".flash.alert", text: "The body was 256 KB, display is truncated"
271
+ assert_equal json.byteslice(0, limit), css_select("pre.body-raw code").text
272
+ end
273
+
274
+ test "displays a body of exactly the display limit in full" do
275
+ limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
276
+ presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: "x" * limit))
277
+
278
+ assert_false presenter.truncated?
279
+ assert_equal limit, presenter.body_text.bytesize
280
+ assert_true Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: "x" * (limit + 1))).truncated?
281
+ end
282
+
283
+ test "replaces a character the display limit cuts through" do
284
+ limit = Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT
285
+ presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(create_webhook(body: ("x" * (limit - 1)) + "é"))
286
+
287
+ assert_equal ("x" * (limit - 1)) + "\u{FFFD}", presenter.body_text
288
+ end
289
+
290
+ test "decodes the body without touching the stored bytes" do
291
+ webhook = create_webhook(body: "caf\xC3\xA9".b).reload
292
+ presenter = Webhukhs::Admin::ReceivedWebhookPresenter.new(webhook)
293
+
294
+ assert_equal "café", presenter.body_text
295
+ assert_equal Encoding::UTF_8, presenter.body_text.encoding
296
+ assert_equal Encoding::BINARY, webhook.body.encoding
297
+ end
298
+
299
+ test "shows the request headers sorted by name, with the path parameters as JSON" do
300
+ webhook = create_webhook(request_headers: {
301
+ "HTTP_X_SIGNATURE" => "sig",
302
+ "CONTENT_TYPE" => "application/json",
303
+ "action_dispatch.request.path_parameters" => {"service_id" => "test"}
304
+ })
305
+
306
+ get "/admin/received/#{webhook.id}"
307
+
308
+ rows = css_select("table.admin-table thead:has(th:contains('Header')) + tbody tr").map do |row|
309
+ row.css("td").map(&:text)
310
+ end
311
+ assert_equal [
312
+ ["CONTENT_TYPE", "application/json"],
313
+ ["HTTP_X_SIGNATURE", "sig"],
314
+ ["action_dispatch.request.path_parameters", '{"service_id":"test"}']
315
+ ], rows
316
+ end
317
+
318
+ test "shows webhooks stored before headers were recorded" do
319
+ webhook = create_webhook(request_headers: nil)
320
+
321
+ get "/admin/received/#{webhook.id}"
322
+
323
+ assert_response :ok
324
+ assert_select "td.empty[colspan='2']", text: "No records."
325
+ end
326
+
327
+ test "renders copy buttons for the identifiers" do
328
+ webhook = create_webhook(handler_event_id: "evt_123")
329
+
330
+ get "/admin/received/#{webhook.id}"
331
+
332
+ assert_select "button.btn-copy[type=button][title='Copy to clipboard'][data-copy=?]", webhook.id.to_s
333
+ assert_select "button.btn-copy[data-copy=?]", "evt_123"
334
+ end
335
+
336
+ test "locks the pages down with a content security policy" do
337
+ get "/admin/received"
338
+
339
+ assert_equal "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " \
340
+ "connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
341
+ response.headers["Content-Security-Policy"]
342
+ end
343
+
344
+ test "serves its own assets, fingerprinted, from under the mount point" do
345
+ get "/admin/received"
346
+
347
+ assert_select "link[rel=stylesheet][href^='/admin/admin.css?s='][integrity^='sha256-']"
348
+ assert_select "script[type=module][src^='/admin/admin.js?s='][integrity^='sha256-']"
349
+
350
+ get "/admin/admin.css"
351
+ assert_response :ok
352
+ assert_includes response.body, ".admin-table"
353
+ end
354
+ end
@@ -32,9 +32,9 @@ class BaseHandlerTest < ActiveSupport::TestCase
32
32
  test "defaults to active, valid and exposing errors" do
33
33
  handler = Webhukhs::BaseHandler.new
34
34
 
35
- assert_equal true, handler.active?
36
- assert_equal true, handler.valid?(nil)
37
- assert_equal true, handler.expose_errors_to_sender?
35
+ assert_true handler.active?
36
+ assert_true handler.valid?(nil)
37
+ assert_true handler.expose_errors_to_sender?
38
38
  end
39
39
 
40
40
  test "generates unique UUID event ids by default" do