webhukhs 0.8.1 → 0.8.2
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/CHANGELOG.md +6 -0
- data/README.md +15 -3
- data/lib/webhukhs/admin/assets/admin.css +1 -1
- data/lib/webhukhs/admin/config/routes.rb +1 -1
- data/lib/webhukhs/admin/received_webhooks_controller.rb +1 -6
- data/lib/webhukhs/admin/view_helper.rb +19 -10
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +10 -11
- data/lib/webhukhs/version.rb +1 -1
- data/test/admin_test.rb +97 -70
- data/test/browser/admin_click_through.rb +23 -22
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ffeb74619482ef4f9e42e69a58768c40ce5db996de005b0edd305bf44aacdb0
|
|
4
|
+
data.tar.gz: a812b122d5d561c47626fea17f0e06869545b6a06302f9b9de4ece966409b5ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8efe8a18da360410eb87cd5d38ef2ee1788240bc6ced802660be235703f1805036b30f51846157cf9f535ad84888f16f7d8eae67f68d24cdd33fafa2cc0bcccb
|
|
7
|
+
data.tar.gz: 87997be0db27c11cfa17659360cd1a0673811473af0f356c6d3ed77ad1e1a56b2759a3c1d0c721a5355377f3015ca90f3788694063056a01e2684aad9b4eeecf
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
### Changed
|
|
9
|
+
- Admin URLs no longer include `/received`.
|
|
10
|
+
- Restrict admin sorting to ID, with an ascending/descending toggle, to avoid expensive column sorts.
|
|
11
|
+
- Abbreviate UUIDs to their first and last 4 characters in the admin listing, keeping the full ID on hover.
|
|
12
|
+
- Group the admin listing's Received and Updated timestamps in the rightmost columns.
|
|
13
|
+
|
|
8
14
|
## 0.8.1
|
|
9
15
|
|
|
10
16
|
### Fixed
|
data/README.md
CHANGED
|
@@ -76,7 +76,11 @@ Now you will be able to accept webhooks on `/webhooks/example` path.
|
|
|
76
76
|
|
|
77
77
|
Webhukhs comes with a read-only admin for looking at the webhooks you have received: a sortable,
|
|
78
78
|
filterable list, and a page per webhook with its body (pretty-printed when it is JSON) and the
|
|
79
|
-
request headers it arrived with.
|
|
79
|
+
request headers it arrived with.
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
It is not loaded by default - require it in your Gemfile (it has to be
|
|
80
84
|
loaded before the app boots, so `config/routes.rb` is too late):
|
|
81
85
|
|
|
82
86
|
```ruby
|
|
@@ -92,6 +96,9 @@ Rails.application.routes.draw do
|
|
|
92
96
|
end
|
|
93
97
|
```
|
|
94
98
|
|
|
99
|
+
The list lives at the mount point itself (`/admin/webhooks` above), with each webhook at
|
|
100
|
+
`/admin/webhooks/:id`.
|
|
101
|
+
|
|
95
102
|
The admin does no authentication of its own, and webhook bodies tend to carry personal data and
|
|
96
103
|
secrets - so mount it behind whatever guards the rest of your admin. For example, with Devise:
|
|
97
104
|
|
|
@@ -121,8 +128,13 @@ no build step.
|
|
|
121
128
|
To try it out, run `bin/dev` from a checkout of this repository. It starts the app in `example`
|
|
122
129
|
with some webhooks seeded, and the admin at http://localhost:3000/admin/webhukhs.
|
|
123
130
|
|
|
124
|
-
The list
|
|
125
|
-
needed. Click **
|
|
131
|
+
The list sorts only by ID, descending by default, using the existing primary-key index; no extra
|
|
132
|
+
migration is needed. Click **ID** to switch between ascending and descending order. Other columns
|
|
133
|
+
are not sortable, avoiding expensive sorts on large webhook tables. Handler and status filters are
|
|
134
|
+
still available.
|
|
135
|
+
|
|
136
|
+
UUIDs are abbreviated in the list, for example `01a1..6e51`; hover over the ID to see the full
|
|
137
|
+
value. Detail pages and copied IDs retain the full UUID.
|
|
126
138
|
|
|
127
139
|
**We strongly recommend against UUIDv4 primary keys.** Use UUIDv7 or auto-incrementing integer IDs
|
|
128
140
|
instead. UUIDv4 IDs are random, so sorting by ID does not reflect creation time, and inserts have
|
|
@@ -8,8 +8,6 @@ module Webhukhs
|
|
|
8
8
|
class ReceivedWebhooksController < ApplicationController
|
|
9
9
|
# Webhooks per index page
|
|
10
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
11
|
|
|
14
12
|
helper_method :handler_options, :status_options
|
|
15
13
|
|
|
@@ -17,17 +15,14 @@ module Webhukhs
|
|
|
17
15
|
#
|
|
18
16
|
# @return [void]
|
|
19
17
|
def index
|
|
20
|
-
@sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "id"
|
|
21
18
|
@direction = (params[:direction] == "asc") ? "asc" : "desc"
|
|
22
19
|
@page = [params[:page].to_i, 1].max
|
|
23
20
|
|
|
24
21
|
filters = {handler_module_name: params[:handler], status: params[:status]}.compact_blank
|
|
25
22
|
|
|
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
23
|
rows = ReceivedWebhook
|
|
29
24
|
.where(filters)
|
|
30
|
-
.order(
|
|
25
|
+
.order(id: @direction)
|
|
31
26
|
.offset((@page - 1) * PAGE_SIZE)
|
|
32
27
|
.limit(PAGE_SIZE + 1)
|
|
33
28
|
@next_page = @page + 1 if rows.length > PAGE_SIZE
|
|
@@ -4,6 +4,20 @@ module Webhukhs
|
|
|
4
4
|
module Admin
|
|
5
5
|
# Table-building helpers for the admin views.
|
|
6
6
|
module ViewHelper
|
|
7
|
+
# Canonical UUID format, independent of UUID version.
|
|
8
|
+
UUID_RE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
9
|
+
|
|
10
|
+
# Displays the first 4 and last 4 UUID characters, keeping the full ID on hover.
|
|
11
|
+
#
|
|
12
|
+
# @param uuid_str [String] ID to display
|
|
13
|
+
# @return [String, ActiveSupport::SafeBuffer]
|
|
14
|
+
def short_id(uuid_str)
|
|
15
|
+
return uuid_str unless UUID_RE.match?(uuid_str)
|
|
16
|
+
|
|
17
|
+
shortened = [uuid_str.first(4), uuid_str.last(4)].join("..")
|
|
18
|
+
content_tag(:abbr, shortened, title: uuid_str)
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# Renders a table inside a scroll container. Cells are `white-space: nowrap`, so one long
|
|
8
22
|
# column would otherwise take the whole page sideways with it.
|
|
9
23
|
#
|
|
@@ -22,19 +36,14 @@ module Webhukhs
|
|
|
22
36
|
tag.div(tag.table(safe_join([thead, tag.tbody(safe_join(rows))]), class: "admin-table"), class: "table-scroll")
|
|
23
37
|
end
|
|
24
38
|
|
|
25
|
-
#
|
|
26
|
-
# flips the direction, any other column starts out descending.
|
|
39
|
+
# ID column header linking to the index with the sort direction reversed.
|
|
27
40
|
#
|
|
28
|
-
# @param label [String] header text
|
|
29
|
-
# @param column [String] column to sort by
|
|
30
|
-
# @param sort [String] currently sorted column
|
|
31
41
|
# @param direction [String] current direction, "asc" or "desc"
|
|
32
42
|
# @return [ActiveSupport::SafeBuffer]
|
|
33
|
-
def sort_link(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
label = safe_join([label, tag.span((direction == "asc") ? "▲" : "▼", class: "sort-arrow")], " ") if active
|
|
43
|
+
def sort_link(direction:)
|
|
44
|
+
next_direction = (direction == "desc") ? "asc" : "desc"
|
|
45
|
+
query = request.query_parameters.except("page", "sort").merge("direction" => next_direction)
|
|
46
|
+
label = safe_join(["Id", tag.span((direction == "asc") ? "▲" : "▼", class: "sort-arrow")], " ")
|
|
38
47
|
link_to(label, query, class: "sort-link")
|
|
39
48
|
end
|
|
40
49
|
|
|
@@ -9,33 +9,32 @@
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
<%= form_tag received_webhooks_path, method: :get, class: "filters" do %>
|
|
12
|
-
<%= hidden_field_tag :sort, @sort, id: nil %>
|
|
13
12
|
<%= hidden_field_tag :direction, @direction, id: nil %>
|
|
14
13
|
<%= select_tag :handler, options_for_select([["Any handler", ""]] + handler_options, params[:handler]), "aria-label": "Handler" %>
|
|
15
14
|
<%= select_tag :status, options_for_select([["Any status", ""]] + status_options.map { |status, _| [status.humanize, status] }, params[:status]), "aria-label": "Status" %>
|
|
16
15
|
<%= submit_tag "Filter", name: nil %>
|
|
17
|
-
<%= link_to "Clear", received_webhooks_path(
|
|
16
|
+
<%= link_to "Clear", received_webhooks_path(direction: @direction) %>
|
|
18
17
|
<% end %>
|
|
19
18
|
|
|
20
19
|
<%
|
|
21
20
|
columns = [
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
]
|
|
21
|
+
sort_link(direction: @direction),
|
|
22
|
+
"Handler",
|
|
23
|
+
"Status",
|
|
24
|
+
"Event id",
|
|
25
|
+
"Received",
|
|
26
|
+
"Updated"
|
|
27
|
+
]
|
|
29
28
|
%>
|
|
30
29
|
<div id="received-webhooks">
|
|
31
30
|
<%# The id cell carries the link, and the CSS stretches it over the whole row %>
|
|
32
31
|
<%= admin_table(columns, @webhooks) do |webhook|
|
|
33
32
|
[
|
|
34
|
-
link_to(tag.code(webhook.id, class: "cell-ellipsis cell-id"
|
|
35
|
-
time_tag(webhook.created_at, webhook.created_at.iso8601),
|
|
33
|
+
link_to(tag.code(short_id(webhook.id.to_s), class: "cell-ellipsis cell-id"), received_webhook_path(webhook), class: "row-link", title: webhook.id, "aria-label": webhook.id),
|
|
36
34
|
webhook.handler_module_name,
|
|
37
35
|
tag.span(webhook.status, class: ["badge", webhook.status_class]),
|
|
38
36
|
tag.code(webhook.handler_event_id, class: "cell-ellipsis", title: webhook.handler_event_id),
|
|
37
|
+
time_tag(webhook.created_at, webhook.created_at.iso8601),
|
|
39
38
|
time_tag(webhook.updated_at, webhook.updated_at.iso8601)
|
|
40
39
|
]
|
|
41
40
|
end %>
|
data/lib/webhukhs/version.rb
CHANGED
data/test/admin_test.rb
CHANGED
|
@@ -20,48 +20,97 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def listed_event_ids
|
|
23
|
-
css_select("table.admin-table tbody tr td:nth-child(
|
|
23
|
+
css_select("table.admin-table tbody tr td:nth-child(4) code").map(&:text)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
test "lists webhooks by descending id regardless of received time by default" do
|
|
27
27
|
create_webhook(handler_event_id: "first", created_at: 1.day.ago)
|
|
28
28
|
create_webhook(handler_event_id: "last", created_at: 2.days.ago)
|
|
29
29
|
|
|
30
|
-
get "/admin
|
|
30
|
+
get "/admin"
|
|
31
31
|
|
|
32
32
|
assert_response :ok
|
|
33
33
|
assert_equal ["last", "first"], listed_event_ids
|
|
34
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/
|
|
36
|
-
assert_select "th a.sort-link[href=?]", "/admin
|
|
37
|
-
assert_select "th a
|
|
35
|
+
assert_select "tbody tr td:first-child a.row-link[href^='/admin/']", count: 2
|
|
36
|
+
assert_select "th a.sort-link[href=?]", "/admin/?direction=asc", text: "Id ▼"
|
|
37
|
+
assert_select "th a", count: 1
|
|
38
38
|
assert_select "th a.sort-link span.sort-arrow", text: "▼", count: 1
|
|
39
|
-
|
|
39
|
+
assert_equal ["Id ▼", "Handler", "Status", "Event id", "Received", "Updated"], css_select("th").map(&:text)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
test "groups receipt and update timestamps in the rightmost columns" do
|
|
43
|
+
webhook = create_webhook(created_at: 2.days.ago, updated_at: 1.day.ago)
|
|
44
|
+
|
|
45
|
+
get "/admin"
|
|
46
|
+
|
|
47
|
+
assert_select "tbody tr td:nth-child(5) time[datetime=?]", webhook.created_at.iso8601
|
|
48
|
+
assert_select "tbody tr td:nth-child(6) time[datetime=?]", webhook.updated_at.iso8601
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
test "sorts by id in ascending order when requested" do
|
|
43
52
|
create_webhook(handler_event_id: "first", created_at: 1.day.ago)
|
|
44
53
|
create_webhook(handler_event_id: "last", created_at: 2.days.ago)
|
|
45
54
|
|
|
46
|
-
get "/admin
|
|
55
|
+
get "/admin", params: {direction: "asc"}
|
|
47
56
|
|
|
48
57
|
assert_equal ["first", "last"], listed_event_ids
|
|
49
|
-
assert_select "th a.sort-link[href=?]", "/admin
|
|
58
|
+
assert_select "th a.sort-link[href=?]", "/admin/?direction=desc", text: "Id ▲"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test "short_id abbreviates UUIDs in an abbr with the full id as its title" do
|
|
62
|
+
{
|
|
63
|
+
"01a1895c-806f-7518-a388-c6c6a4496e51" => "01a1..6e51",
|
|
64
|
+
"01A1895C-806F-7518-A388-C6C6A4496E51" => "01A1..6E51",
|
|
65
|
+
"f47ac10b-58cc-4372-a567-0e02b2c3d479" => "f47a..d479"
|
|
66
|
+
}.each do |id, label|
|
|
67
|
+
assert_equal %(<abbr title="#{id}">#{label}</abbr>), Webhukhs::Admin::ApplicationController.helpers.short_id(id)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test "short_id leaves non-UUID strings unchanged" do
|
|
72
|
+
uuid = "01a1895c-806f-7518-a388-c6c6a4496e51"
|
|
73
|
+
ids = ["123", "manual-id", "prefix#{uuid}", "#{uuid}suffix", "#{uuid}\n"]
|
|
74
|
+
groups = uuid.split("-")
|
|
75
|
+
groups.each_index do |index|
|
|
76
|
+
[groups[index][0...-1], "#{groups[index]}0", "g#{groups[index][1..]}"].each do |invalid_group|
|
|
77
|
+
ids << groups.each_with_index.map { |group, i| (i == index) ? invalid_group : group }.join("-")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
ids.each do |id|
|
|
82
|
+
assert_equal id, Webhukhs::Admin::ApplicationController.helpers.short_id(id)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "keeps the full id in the row link and hover text" do
|
|
87
|
+
webhook = create_webhook
|
|
88
|
+
|
|
89
|
+
get "/admin"
|
|
90
|
+
|
|
91
|
+
assert_select "a.row-link[href=?][title=?][aria-label=?]", "/admin/#{webhook.id}", webhook.id.to_s, webhook.id.to_s do
|
|
92
|
+
assert_select "code.cell-id", text: webhook.id.to_s
|
|
93
|
+
assert_select "abbr", count: 0
|
|
94
|
+
end
|
|
50
95
|
end
|
|
51
96
|
|
|
52
|
-
test "
|
|
53
|
-
create_webhook(handler_event_id: "
|
|
54
|
-
create_webhook(handler_event_id: "
|
|
97
|
+
test "ignores legacy sort columns in either direction" do
|
|
98
|
+
create_webhook(handler_event_id: "z", status: "skipped", created_at: 1.day.ago, updated_at: 1.hour.ago)
|
|
99
|
+
create_webhook(handler_event_id: "a", handler_module_name: "PrivateHandler", status: "error", created_at: 2.days.ago, updated_at: 2.hours.ago)
|
|
55
100
|
|
|
56
|
-
|
|
101
|
+
%w[created_at updated_at handler_module_name handler_event_id status].each do |column|
|
|
102
|
+
get "/admin", params: {sort: column, direction: "desc"}
|
|
103
|
+
assert_equal ["a", "z"], listed_event_ids, column
|
|
57
104
|
|
|
58
|
-
|
|
105
|
+
get "/admin", params: {sort: column, direction: "asc"}
|
|
106
|
+
assert_equal ["z", "a"], listed_event_ids, column
|
|
107
|
+
end
|
|
59
108
|
end
|
|
60
109
|
|
|
61
110
|
test "offers an auto-refresh switch, off by default, for the listing it refetches" do
|
|
62
111
|
create_webhook
|
|
63
112
|
|
|
64
|
-
get "/admin
|
|
113
|
+
get "/admin"
|
|
65
114
|
|
|
66
115
|
assert_select ".page-heading label.switch input#auto-refresh[type=checkbox][role=switch]:not([checked])"
|
|
67
116
|
# admin.js swaps this element for the one in the refetched page
|
|
@@ -76,33 +125,8 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
76
125
|
|
|
77
126
|
assert_response :ok
|
|
78
127
|
assert_equal ["at-root"], listed_event_ids
|
|
79
|
-
|
|
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
|
|
128
|
+
assert_select ".admin-header a[href=?]", "/admin/", text: "Received webhooks"
|
|
129
|
+
assert_select "form.filters[action=?]", "/admin/"
|
|
106
130
|
end
|
|
107
131
|
|
|
108
132
|
test "ignores unsupported sort columns and directions" do
|
|
@@ -110,7 +134,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
110
134
|
create_webhook(handler_event_id: "new", created_at: 1.day.ago, status: "skipped", body: "z")
|
|
111
135
|
create_webhook(handler_event_id: "old", created_at: 2.days.ago, status: "error", body: "a")
|
|
112
136
|
|
|
113
|
-
get "/admin
|
|
137
|
+
get "/admin", params: {sort: "body", direction: "sideways"}
|
|
114
138
|
|
|
115
139
|
assert_response :ok
|
|
116
140
|
assert_equal ["old", "new"], listed_event_ids
|
|
@@ -121,25 +145,27 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
121
145
|
create_webhook(handler_event_id: "test-error", status: "error")
|
|
122
146
|
create_webhook(handler_event_id: "private-processed", handler_module_name: "PrivateHandler")
|
|
123
147
|
|
|
124
|
-
get "/admin
|
|
148
|
+
get "/admin", params: {handler: "WebhookTestHandler"}
|
|
125
149
|
assert_equal ["test-error", "test-processed"], listed_event_ids.sort
|
|
126
150
|
|
|
127
|
-
get "/admin
|
|
151
|
+
get "/admin", params: {status: "error"}
|
|
128
152
|
assert_equal ["test-error"], listed_event_ids
|
|
129
153
|
|
|
130
|
-
get "/admin
|
|
154
|
+
get "/admin", params: {handler: "", status: ""}
|
|
131
155
|
assert_equal 3, listed_event_ids.length
|
|
132
156
|
end
|
|
133
157
|
|
|
134
158
|
test "offers every configured handler and status as a filter" do
|
|
135
|
-
get "/admin
|
|
159
|
+
get "/admin", params: {handler: "PrivateHandler", direction: "asc"}
|
|
136
160
|
|
|
137
161
|
handlers = css_select("select[name=handler] option").map { |option| option["value"] }
|
|
138
162
|
assert_equal ["", *Webhukhs.configuration.active_handlers.values.uniq.sort], handlers
|
|
139
163
|
assert_select "select[name=handler] option[value='']", text: "Any handler"
|
|
140
164
|
assert_select "select[name=status] option[value='']", text: "Any status"
|
|
141
|
-
#
|
|
142
|
-
assert_select "form.filters
|
|
165
|
+
# Filtering and clearing the filters keep the direction, without a sort column
|
|
166
|
+
assert_select "form.filters input[name=direction][value=asc]"
|
|
167
|
+
assert_select "form.filters input[name=sort]", count: 0
|
|
168
|
+
assert_select "form.filters a[href=?]", "/admin/?direction=asc", text: "Clear"
|
|
143
169
|
assert_select "select[name=handler] option[selected]", text: "PrivateHandler"
|
|
144
170
|
|
|
145
171
|
statuses = css_select("select[name=status] option").map { |option| option["value"] }
|
|
@@ -150,7 +176,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
150
176
|
original_handlers = Webhukhs.configuration.active_handlers
|
|
151
177
|
Webhukhs.configuration.active_handlers = {a: WebhookTestHandler, b: "WebhookTestHandler"}
|
|
152
178
|
|
|
153
|
-
get "/admin
|
|
179
|
+
get "/admin"
|
|
154
180
|
|
|
155
181
|
handlers = css_select("select[name=handler] option").map { |option| option["value"] }
|
|
156
182
|
assert_equal ["", "WebhookTestHandler"], handlers
|
|
@@ -162,16 +188,16 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
162
188
|
page_size = Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE
|
|
163
189
|
(page_size + 1).times { |i| create_webhook(handler_event_id: "evt-#{i}", created_at: i.minutes.ago) }
|
|
164
190
|
|
|
165
|
-
get "/admin
|
|
191
|
+
get "/admin", params: {direction: "asc"}
|
|
166
192
|
assert_equal page_size, listed_event_ids.length
|
|
167
|
-
assert_equal "evt-#{
|
|
193
|
+
assert_equal (0...page_size).map { |i| "evt-#{i}" }, listed_event_ids
|
|
168
194
|
assert_select "a[rel=prev]", count: 0
|
|
169
|
-
assert_select "a[rel=next][href=?]", "/admin
|
|
195
|
+
assert_select "a[rel=next][href=?]", "/admin/?direction=asc&page=2"
|
|
170
196
|
|
|
171
|
-
get "/admin
|
|
172
|
-
assert_equal ["evt
|
|
197
|
+
get "/admin", params: {direction: "asc", page: 2}
|
|
198
|
+
assert_equal ["evt-#{page_size}"], listed_event_ids
|
|
173
199
|
assert_select "a[rel=next]", count: 0
|
|
174
|
-
assert_select "a[rel=prev][href=?]", "/admin
|
|
200
|
+
assert_select "a[rel=prev][href=?]", "/admin/?direction=asc&page=1"
|
|
175
201
|
end
|
|
176
202
|
|
|
177
203
|
test "loads one row past the page and no more" do
|
|
@@ -181,7 +207,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
181
207
|
loaded = 0
|
|
182
208
|
counter = ->(*, payload) { loaded += payload[:record_count] if payload[:class_name] == "Webhukhs::ReceivedWebhook" }
|
|
183
209
|
ActiveSupport::Notifications.subscribed(counter, "instantiation.active_record") do
|
|
184
|
-
get "/admin
|
|
210
|
+
get "/admin"
|
|
185
211
|
end
|
|
186
212
|
|
|
187
213
|
assert_equal page_size + 1, loaded
|
|
@@ -190,7 +216,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
190
216
|
test "shows no next page when the last page is exactly full" do
|
|
191
217
|
Webhukhs::Admin::ReceivedWebhooksController::PAGE_SIZE.times { create_webhook }
|
|
192
218
|
|
|
193
|
-
get "/admin
|
|
219
|
+
get "/admin"
|
|
194
220
|
|
|
195
221
|
assert_select "a[rel=next]", count: 0
|
|
196
222
|
end
|
|
@@ -198,20 +224,20 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
198
224
|
test "treats a page below 1 as the first page" do
|
|
199
225
|
create_webhook(handler_event_id: "only")
|
|
200
226
|
|
|
201
|
-
get "/admin
|
|
227
|
+
get "/admin", params: {page: -3}
|
|
202
228
|
|
|
203
229
|
assert_equal ["only"], listed_event_ids
|
|
204
230
|
assert_select "a[rel=prev]", count: 0
|
|
205
231
|
end
|
|
206
232
|
|
|
207
|
-
test "drops the page when
|
|
208
|
-
get "/admin
|
|
233
|
+
test "keeps filters but drops the page and legacy sort when reversing the order" do
|
|
234
|
+
get "/admin", params: {page: 3, sort: "updated_at", handler: "PrivateHandler", status: "error"}
|
|
209
235
|
|
|
210
|
-
assert_select "th a.sort-link[href=?]", "/admin
|
|
236
|
+
assert_select "th a.sort-link[href=?]", "/admin/?direction=asc&handler=PrivateHandler&status=error"
|
|
211
237
|
end
|
|
212
238
|
|
|
213
239
|
test "says so when there is nothing to list" do
|
|
214
|
-
get "/admin
|
|
240
|
+
get "/admin"
|
|
215
241
|
|
|
216
242
|
assert_select "div.table-scroll > table.admin-table > thead > tr > th", count: 6
|
|
217
243
|
assert_select "table.admin-table > tbody > tr > td.empty[colspan='6']", text: "No records."
|
|
@@ -234,10 +260,11 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
234
260
|
create_webhook(handler_event_id: "decoy", body: "not this one")
|
|
235
261
|
webhook = create_webhook(body: '{"event":"thing.happened","data":{"id":1}}')
|
|
236
262
|
|
|
237
|
-
get "/admin
|
|
263
|
+
get "/admin/#{webhook.id}"
|
|
238
264
|
|
|
239
265
|
assert_response :ok
|
|
240
266
|
assert_select "h1", text: /WebhookTestHandler/
|
|
267
|
+
assert_select ".breadcrumb a[href=?]", "/admin/", text: "Received webhooks"
|
|
241
268
|
assert_select "[role=tab][aria-selected=true]", text: "Pretty"
|
|
242
269
|
assert_select "[role=tab][aria-selected=false]", text: "As sent"
|
|
243
270
|
# assert_select squashes whitespace, which is the very thing under test here
|
|
@@ -250,7 +277,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
250
277
|
test "shows a body that is not JSON as sent, replacing invalid UTF-8" do
|
|
251
278
|
webhook = create_webhook(body: "event=thing&broken=\xFF".b)
|
|
252
279
|
|
|
253
|
-
get "/admin
|
|
280
|
+
get "/admin/#{webhook.id}"
|
|
254
281
|
|
|
255
282
|
assert_response :ok
|
|
256
283
|
assert_select "[role=tab]", count: 0
|
|
@@ -263,7 +290,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
263
290
|
json = %({"padding":"#{"x" * limit}"})
|
|
264
291
|
webhook = create_webhook(body: json)
|
|
265
292
|
|
|
266
|
-
get "/admin
|
|
293
|
+
get "/admin/#{webhook.id}"
|
|
267
294
|
|
|
268
295
|
assert_response :ok
|
|
269
296
|
assert_select "[role=tab]", count: 0
|
|
@@ -303,7 +330,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
303
330
|
"action_dispatch.request.path_parameters" => {"service_id" => "test"}
|
|
304
331
|
})
|
|
305
332
|
|
|
306
|
-
get "/admin
|
|
333
|
+
get "/admin/#{webhook.id}"
|
|
307
334
|
|
|
308
335
|
rows = css_select("table.admin-table thead:has(th:contains('Header')) + tbody tr").map do |row|
|
|
309
336
|
row.css("td").map(&:text)
|
|
@@ -318,7 +345,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
318
345
|
test "shows webhooks stored before headers were recorded" do
|
|
319
346
|
webhook = create_webhook(request_headers: nil)
|
|
320
347
|
|
|
321
|
-
get "/admin
|
|
348
|
+
get "/admin/#{webhook.id}"
|
|
322
349
|
|
|
323
350
|
assert_response :ok
|
|
324
351
|
assert_select "td.empty[colspan='2']", text: "No records."
|
|
@@ -327,14 +354,14 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
327
354
|
test "renders copy buttons for the identifiers" do
|
|
328
355
|
webhook = create_webhook(handler_event_id: "evt_123")
|
|
329
356
|
|
|
330
|
-
get "/admin
|
|
357
|
+
get "/admin/#{webhook.id}"
|
|
331
358
|
|
|
332
359
|
assert_select "button.btn-copy[type=button][title='Copy to clipboard'][data-copy=?]", webhook.id.to_s
|
|
333
360
|
assert_select "button.btn-copy[data-copy=?]", "evt_123"
|
|
334
361
|
end
|
|
335
362
|
|
|
336
363
|
test "locks the pages down with a content security policy" do
|
|
337
|
-
get "/admin
|
|
364
|
+
get "/admin"
|
|
338
365
|
|
|
339
366
|
assert_equal "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " \
|
|
340
367
|
"connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
|
@@ -342,7 +369,7 @@ class AdminTest < ActionDispatch::IntegrationTest
|
|
|
342
369
|
end
|
|
343
370
|
|
|
344
371
|
test "serves its own assets, fingerprinted, from under the mount point" do
|
|
345
|
-
get "/admin
|
|
372
|
+
get "/admin"
|
|
346
373
|
|
|
347
374
|
assert_select "link[rel=stylesheet][href^='/admin/admin.css?s='][integrity^='sha256-']"
|
|
348
375
|
assert_select "script[type=module][src^='/admin/admin.js?s='][integrity^='sha256-']"
|
|
@@ -89,20 +89,21 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
89
89
|
PAGE_SIZE.times { |i| create_webhook(handler_event_id: "evt-#{i}", created_at: i.minutes.ago) }
|
|
90
90
|
create_webhook(handler_event_id: "the-failed-one", status: "error", created_at: 2.days.ago)
|
|
91
91
|
|
|
92
|
-
visit "
|
|
92
|
+
visit ""
|
|
93
93
|
assert_equal "Received webhooks", @browser.at_css("h1").text
|
|
94
94
|
# The stylesheet must have made it through the CSP - unstyled, the header would not be a flexbox
|
|
95
95
|
assert_equal "flex", @browser.evaluate("getComputedStyle(document.querySelector('.admin-header')).display")
|
|
96
96
|
assert_equal PAGE_SIZE, @browser.css("tbody tr").length
|
|
97
|
-
assert_equal "the-failed-one", texts("tbody tr td:nth-child(
|
|
97
|
+
assert_equal "the-failed-one", texts("tbody tr td:nth-child(4)").first
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert_equal "evt-0", texts("tbody tr td:nth-child(5)").first
|
|
102
|
-
|
|
103
|
-
click_and_wait_for_page(@browser.at_xpath("//th/a[contains(., 'Received')]"))
|
|
99
|
+
assert_equal 1, @browser.css("th a").length
|
|
100
|
+
click_and_wait_for_page(@browser.at_xpath("//th/a[contains(., 'Id')]"))
|
|
104
101
|
assert_includes @browser.current_url, "direction=asc"
|
|
105
|
-
assert_equal "
|
|
102
|
+
assert_equal "evt-0", texts("tbody tr td:nth-child(4)").first
|
|
103
|
+
|
|
104
|
+
click_and_wait_for_page(@browser.at_xpath("//th/a[contains(., 'Id')]"))
|
|
105
|
+
assert_includes @browser.current_url, "direction=desc"
|
|
106
|
+
assert_equal "the-failed-one", texts("tbody tr td:nth-child(4)").first
|
|
106
107
|
|
|
107
108
|
# Not Ferrum's Node#select: it blanks the select by assigning it `undefined` first, which
|
|
108
109
|
# left it blank on the Chromium snapshot CI got
|
|
@@ -113,9 +114,9 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
113
114
|
JS
|
|
114
115
|
assert_equal "error", @browser.evaluate("document.querySelector('select[name=status]').value")
|
|
115
116
|
click_and_wait_for_page(@browser.at_css("form.filters input[type=submit]"))
|
|
116
|
-
assert_equal ["the-failed-one"], texts("tbody tr td:nth-child(
|
|
117
|
+
assert_equal ["the-failed-one"], texts("tbody tr td:nth-child(4)")
|
|
117
118
|
# The sort survives filtering
|
|
118
|
-
assert_includes @browser.current_url, "direction=
|
|
119
|
+
assert_includes @browser.current_url, "direction=desc"
|
|
119
120
|
|
|
120
121
|
click_and_wait_for_page(@browser.at_xpath("//form//a[text()='Clear']"))
|
|
121
122
|
assert_equal "Any status", @browser.evaluate("document.querySelector('select[name=status]').selectedOptions[0].text")
|
|
@@ -129,18 +130,18 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
129
130
|
test "opens a webhook by clicking anywhere on its row" do
|
|
130
131
|
webhook = create_webhook(handler_event_id: "evt-clicked")
|
|
131
132
|
|
|
132
|
-
visit "
|
|
133
|
+
visit ""
|
|
133
134
|
# The status cell is not a link, but the row link stretched over it takes the click
|
|
134
|
-
click_and_wait_for_page(@browser.at_css("tbody tr td:nth-child(
|
|
135
|
+
click_and_wait_for_page(@browser.at_css("tbody tr td:nth-child(3)"))
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
assert_equal "#{@base_url}/#{webhook.id}", @browser.current_url
|
|
137
138
|
assert_includes @browser.at_css("h1").text, "WebhookTestHandler"
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
test "switches between the pretty and the as-sent body" do
|
|
141
142
|
webhook = create_webhook(body: '{"event":"thing.happened","data":{"id":1}}')
|
|
142
143
|
|
|
143
|
-
visit "
|
|
144
|
+
visit "/#{webhook.id}"
|
|
144
145
|
pretty = -> { @browser.evaluate("document.getElementById('body-pretty').hidden") == false }
|
|
145
146
|
raw = -> { @browser.evaluate("document.getElementById('body-raw').hidden") == false }
|
|
146
147
|
assert pretty.call
|
|
@@ -161,7 +162,7 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
161
162
|
test "copies to the clipboard" do
|
|
162
163
|
webhook = create_webhook(handler_event_id: "evt-to-copy")
|
|
163
164
|
|
|
164
|
-
visit "
|
|
165
|
+
visit "/#{webhook.id}"
|
|
165
166
|
# Headless Chrome has no clipboard to grant, so we only check that the right text gets handed to it
|
|
166
167
|
@browser.execute("navigator.clipboard.writeText = (text) => { window.copied = text; return Promise.resolve() }")
|
|
167
168
|
|
|
@@ -178,19 +179,19 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
178
179
|
form = create_webhook(body: "event=paid&amount=12.50")
|
|
179
180
|
large = create_webhook(body: "x" * (Webhukhs::Admin::ReceivedWebhookPresenter::DISPLAY_LIMIT + 1))
|
|
180
181
|
|
|
181
|
-
visit "
|
|
182
|
+
visit "/#{form.id}"
|
|
182
183
|
assert_equal "This body is not valid JSON, so it is shown as it was sent", @browser.at_css(".flash.alert").text
|
|
183
184
|
assert_equal "event=paid&amount=12.50", @browser.at_css("pre.body-raw code").text
|
|
184
185
|
assert_empty @browser.css("[role=tab]")
|
|
185
186
|
|
|
186
|
-
visit "
|
|
187
|
+
visit "/#{large.id}"
|
|
187
188
|
assert_equal "The body was 256 KB, display is truncated", @browser.at_css(".flash.alert").text
|
|
188
189
|
end
|
|
189
190
|
|
|
190
191
|
test "picks up new webhooks while auto-refresh is on, and remembers the switch" do
|
|
191
192
|
create_webhook(handler_event_id: "evt-before")
|
|
192
193
|
|
|
193
|
-
visit "
|
|
194
|
+
visit ""
|
|
194
195
|
switch = -> { @browser.evaluate("document.getElementById('auto-refresh').checked") }
|
|
195
196
|
refute switch.call
|
|
196
197
|
@browser.at_css("label.switch").click
|
|
@@ -198,14 +199,14 @@ class AdminClickThrough < ActiveSupport::TestCase
|
|
|
198
199
|
|
|
199
200
|
create_webhook(handler_event_id: "evt-arrived")
|
|
200
201
|
# One poll interval, with some slack
|
|
201
|
-
wait_for("the new webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(
|
|
202
|
-
assert_equal ["evt-arrived", "evt-before"], texts("tbody tr td:nth-child(
|
|
202
|
+
wait_for("the new webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(4)").include?("evt-arrived") }
|
|
203
|
+
assert_equal ["evt-arrived", "evt-before"], texts("tbody tr td:nth-child(4)")
|
|
203
204
|
|
|
204
|
-
visit "
|
|
205
|
+
visit ""
|
|
205
206
|
assert switch.call, "The switch should stay on after a reload"
|
|
206
207
|
# After a page load only the timer refetches, so this one has to come in through a poll
|
|
207
208
|
create_webhook(handler_event_id: "evt-polled")
|
|
208
|
-
wait_for("the polled webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(
|
|
209
|
+
wait_for("the polled webhook to show up", timeout: 8) { texts("tbody tr td:nth-child(4)").include?("evt-polled") }
|
|
209
210
|
@browser.at_css("label.switch").click
|
|
210
211
|
wait_for("the switch to turn off") { !switch.call }
|
|
211
212
|
end
|