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.
- checksums.yaml +4 -4
- data/.yard-lint.yml +1 -0
- data/AGENTS.md +9 -11
- data/Appraisals +2 -2
- data/CHANGELOG.md +13 -0
- data/README.md +98 -10
- data/Rakefile +7 -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 +0 -3
- 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/engine.rb +0 -4
- data/lib/webhukhs/models/received_webhook.rb +3 -0
- data/lib/webhukhs/version.rb +1 -1
- data/test/admin_test.rb +334 -0
- data/test/base_handler_test.rb +3 -3
- data/test/browser/admin_click_through.rb +208 -0
- data/test/processing_job_test.rb +15 -28
- data/test/received_webhook_test.rb +16 -0
- data/test/test_app.rb +2 -0
- data/test/test_helper.rb +9 -6
- data/test/webhukhs_test.rb +20 -62
- metadata +165 -8
- data/example/.ruby-version +0 -1
- 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
|
data/lib/webhukhs/engine.rb
CHANGED
|
@@ -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
|
data/lib/webhukhs/version.rb
CHANGED
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
|
data/test/base_handler_test.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|