webhukhs 0.7.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.yard-lint.yml +1 -0
  3. data/AGENTS.md +9 -11
  4. data/Appraisals +2 -2
  5. data/CHANGELOG.md +13 -0
  6. data/README.md +98 -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 +60 -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 +50 -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 +334 -0
  41. data/test/base_handler_test.rb +3 -3
  42. data/test/browser/admin_click_through.rb +208 -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 -8
  49. data/example/.ruby-version +0 -1
  50. data/gemfiles/rails_7.1.gemfile.lock +0 -412
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ module Admin
5
+ # Lists and shows stored webhooks. Read-only: the lifecycle belongs to Webhukhs::ProcessingJob.
6
+ class ReceivedWebhooksController < ApplicationController
7
+ # Webhooks per index page
8
+ PAGE_SIZE = 50
9
+ # Columns the index can be sorted by
10
+ SORTABLE_COLUMNS = %w[created_at updated_at handler_module_name handler_event_id status].freeze
11
+
12
+ helper_method :handler_options, :status_options
13
+
14
+ # Renders a filtered, sorted page of webhooks.
15
+ #
16
+ # @return [void]
17
+ def index
18
+ @sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "created_at"
19
+ @direction = (params[:direction] == "asc") ? "asc" : "desc"
20
+ @page = [params[:page].to_i, 1].max
21
+
22
+ filters = {handler_module_name: params[:handler], status: params[:status]}.compact_blank
23
+
24
+ # Most columns have plenty of ties (status especially), and without a tiebreaker the DB is free
25
+ # to return them in a different order for every OFFSET - rows then repeat or vanish between pages
26
+ rows = ReceivedWebhook
27
+ .where(filters)
28
+ .order(@sort => @direction, :id => @direction)
29
+ .offset((@page - 1) * PAGE_SIZE)
30
+ .limit(PAGE_SIZE + 1)
31
+ @next_page = @page + 1 if rows.length > PAGE_SIZE
32
+ @webhooks = rows.first(PAGE_SIZE).map { |webhook| ReceivedWebhookPresenter.new(webhook) }
33
+ end
34
+
35
+ # Renders one webhook with its body and headers.
36
+ #
37
+ # @return [void]
38
+ def show
39
+ @webhook = ReceivedWebhookPresenter.new(ReceivedWebhook.find(params.fetch(:id)))
40
+ end
41
+
42
+ private
43
+
44
+ # The configured handlers rather than a DISTINCT over the table, so that a handler which has
45
+ # not received anything yet can still be picked - "nothing arrived" is an answer too.
46
+ #
47
+ # @return [Array<String>] handler class names
48
+ def handler_options
49
+ Webhukhs.configuration.active_handlers.values.map(&:to_s).uniq.sort
50
+ end
51
+
52
+ # Statuses a webhook can be in, to filter by.
53
+ #
54
+ # @return [Hash{String => String}]
55
+ def status_options
56
+ ReceivedWebhook.statuses
57
+ end
58
+ end
59
+ end
60
+ 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,50 @@
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
+ ["Received", "created_at"],
23
+ ["Handler", "handler_module_name"],
24
+ ["Status", "status"],
25
+ ["Event id", "handler_event_id"],
26
+ ["Updated", "updated_at"]
27
+ ].map { |label, column| sort_link(label, column, sort: @sort, direction: @direction) }
28
+ %>
29
+ <div id="received-webhooks">
30
+ <%# The id cell carries the link, and the CSS stretches it over the whole row %>
31
+ <%= admin_table(["Id"] + columns, @webhooks) do |webhook|
32
+ [
33
+ link_to(tag.code(webhook.id, class: "cell-ellipsis cell-id", title: webhook.id), received_webhook_path(webhook), class: "row-link"),
34
+ time_tag(webhook.created_at, webhook.created_at.iso8601),
35
+ webhook.handler_module_name,
36
+ tag.span(webhook.status, class: ["badge", webhook.status_class]),
37
+ tag.code(webhook.handler_event_id, class: "cell-ellipsis", title: webhook.handler_event_id),
38
+ time_tag(webhook.updated_at, webhook.updated_at.iso8601)
39
+ ]
40
+ end %>
41
+
42
+ <nav class="pagination">
43
+ <% if @page > 1 %>
44
+ <%= link_to "← Previous", request.query_parameters.merge("page" => @page - 1), rel: "prev" %>
45
+ <% end %>
46
+ <% if @next_page %>
47
+ <%= link_to "Next →", request.query_parameters.merge("page" => @next_page), rel: "next" %>
48
+ <% end %>
49
+ </nav>
50
+ </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.0"
5
5
  end
@@ -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
@@ -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