Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69587febfc |
@@ -3104,7 +3104,7 @@ returns to the page that was asked for.
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one its original arrived at), its request headers, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -48,18 +49,20 @@ type EventLogView struct {
|
||||
|
||||
Body BodyView
|
||||
|
||||
// Entrypoint names the entrypoint the event arrived at, or for a
|
||||
// resubmitted copy, which did not arrive, the one its original
|
||||
// arrived at: its description, "Entrypoint" when it has none, or
|
||||
// "deleted entrypoint". Never its URL, which is the entrypoint's
|
||||
// secret.
|
||||
// Entrypoint names the entrypoint the event arrived at. A
|
||||
// resubmitted copy, even a copy of a copy, did not arrive; it
|
||||
// names the one the request it copies arrived at. The name is
|
||||
// the entrypoint's description, "Entrypoint" when it has none,
|
||||
// or "deleted entrypoint", never its URL, which is the
|
||||
// entrypoint's secret.
|
||||
Entrypoint string
|
||||
|
||||
// Headers is the event's request headers, one "Name: value"
|
||||
// per value, sorted by name. HeadersCut reports headers left
|
||||
// out because they hold more than maxRenderedBodyBytes, stored
|
||||
// or as lines; only the event log leaves them out.
|
||||
Headers []string
|
||||
// Headers is the event's request headers as text, one
|
||||
// "Name: value" line per value, sorted by name. HeadersCut
|
||||
// reports headers left out because they hold more than
|
||||
// maxRenderedBodyBytes, stored or as text; only the event log
|
||||
// leaves them out.
|
||||
Headers string
|
||||
HeadersCut bool
|
||||
|
||||
// ResubmittedFromID names the event this one was copied
|
||||
@@ -100,7 +103,7 @@ type eventLogRow struct {
|
||||
|
||||
// view projects a loaded row of the webhook's events for
|
||||
// rendering. It shows the request headers when the row holds them
|
||||
// whole and their lines hold at most maxHeaderBytes.
|
||||
// whole and their text holds at most maxHeaderBytes.
|
||||
func (r *eventLogRow) view(
|
||||
webhookID string, maxHeaderBytes int,
|
||||
) EventLogView {
|
||||
@@ -120,7 +123,7 @@ func (r *eventLogRow) view(
|
||||
Body: newBodyView(
|
||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||
),
|
||||
Headers: headers,
|
||||
Headers: strings.Join(headers, "\n"),
|
||||
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||
ResubmittedFromID: from,
|
||||
}
|
||||
@@ -130,8 +133,9 @@ func (r *eventLogRow) view(
|
||||
// JSON the receiver writes, into one "Name: value" line per value,
|
||||
// sorted by name. Headers that do not parse, as when the event log
|
||||
// has cut them, show as none. It reports false, with no lines, when
|
||||
// the lines would hold more than maxBytes: a header sent many times
|
||||
// is stored with its name once but shown with it on every line.
|
||||
// the lines, each with the newline that follows it, would hold more
|
||||
// than maxBytes: a header sent many times is stored with its name
|
||||
// once but shown with it on every line.
|
||||
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||
var headers http.Header
|
||||
|
||||
@@ -154,7 +158,7 @@ func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||
for _, value := range headers[name] {
|
||||
line := name + ": " + value
|
||||
|
||||
size += len(line)
|
||||
size += len(line) + len("\n")
|
||||
if size > maxBytes {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -20,16 +20,19 @@ func arrivedAt(name string) string {
|
||||
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
||||
}
|
||||
|
||||
// originalArrivedAt is how a page names the entrypoint a resubmitted
|
||||
// copy's original arrived at.
|
||||
func originalArrivedAt(name string) string {
|
||||
return `Its original arrived at <span class="text-gray-900">` +
|
||||
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
|
||||
// the entrypoint the request it copies arrived at.
|
||||
func copiedRequestArrivedAt(name string) string {
|
||||
return `The request it copies arrived at <span class="text-gray-900">` +
|
||||
name + `</span>`
|
||||
}
|
||||
|
||||
// headerLine is how a page shows one request header line.
|
||||
func headerLine(line string) string {
|
||||
return `<div class="whitespace-pre-wrap">` + line + `</div>`
|
||||
// headerBox is how a page shows an event's request header lines: as
|
||||
// one block of text in a single box.
|
||||
func headerBox(lines ...string) string {
|
||||
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
|
||||
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
|
||||
`break-all">` + strings.Join(lines, "\n") + `</pre>`
|
||||
}
|
||||
|
||||
// showHeadersLink is the event log's link to an event's own page for
|
||||
@@ -111,24 +114,12 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||
olderShows := func(t *testing.T, page string) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
accept = "Accept: */*"
|
||||
userAgent = "User-Agent: shop/1 build\t7"
|
||||
shopEvent = "X-Shop-Event: order.created"
|
||||
)
|
||||
|
||||
assert.Contains(t, page, arrivedAt("Billing sender"))
|
||||
assert.Contains(t, page, headerLine(accept))
|
||||
assert.Contains(t, page, headerLine(userAgent))
|
||||
assert.Contains(t, page, headerLine(shopEvent))
|
||||
assert.Less(t,
|
||||
strings.Index(page, accept), strings.Index(page, userAgent),
|
||||
"headers are sorted by name",
|
||||
)
|
||||
assert.Less(t,
|
||||
strings.Index(page, userAgent), strings.Index(page, shopEvent),
|
||||
"headers are sorted by name",
|
||||
)
|
||||
assert.Contains(t, page, headerBox(
|
||||
"Accept: */*",
|
||||
"User-Agent: shop/1 build\t7",
|
||||
"X-Shop-Event: order.created",
|
||||
), "headers are sorted by name")
|
||||
assert.NotContains(t, page, "order.paid")
|
||||
assert.NotContains(t, page, billing.Path)
|
||||
}
|
||||
@@ -137,8 +128,10 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||
t.Helper()
|
||||
|
||||
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
||||
assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
|
||||
assert.Contains(t, page, headerLine("X-Note: <b>hi</b>"))
|
||||
assert.Contains(t, page, headerBox(
|
||||
"X-Note: <b>hi</b>",
|
||||
"X-Shop-Event: order.paid",
|
||||
))
|
||||
assert.NotContains(t, page, "<b>hi</b>")
|
||||
assert.NotContains(t, page, "order.created")
|
||||
assert.NotContains(t, page, unnamed.Path)
|
||||
@@ -187,40 +180,58 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
||||
assert.NotContains(t, w.Body.String(), "Retired sender")
|
||||
}
|
||||
|
||||
// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
|
||||
// original arrived at the entrypoint, in the event log and on its own
|
||||
// page, and never that the copy did.
|
||||
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
|
||||
// of that copy each say the request they copy arrived at the
|
||||
// entrypoint, in the event log and on their own pages, and never that
|
||||
// they did.
|
||||
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
ep := f.entrypoint(t, "Billing sender")
|
||||
original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
||||
copied := f.eventAt(t, ep, `{}`, time.Now())
|
||||
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
|
||||
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
||||
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
|
||||
|
||||
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||
"resubmitted_from_id", original.ID,
|
||||
).Error)
|
||||
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
|
||||
"resubmitted_from_id", copied.ID,
|
||||
).Error)
|
||||
|
||||
// The log lists the copy first, and the original's Resubmit form
|
||||
// comes after all of the copy and before the original's
|
||||
// entrypoint.
|
||||
copyPart, originalPart, found := strings.Cut(
|
||||
// The log lists the newest event first, and each event's Resubmit
|
||||
// form comes before its entrypoint, so cutting the page at the
|
||||
// copy's and the original's forms leaves each event's entrypoint
|
||||
// in its own part.
|
||||
copyOfCopyPart, rest, found := strings.Cut(
|
||||
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||
"/events/"+original.ID+"/resubmit",
|
||||
"/events/"+copied.ID+"/resubmit",
|
||||
)
|
||||
require.True(t, found)
|
||||
|
||||
assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
|
||||
assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
|
||||
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
||||
assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
|
||||
copyPart, originalPart, found := strings.Cut(
|
||||
rest, "/events/"+original.ID+"/resubmit",
|
||||
)
|
||||
require.True(t, found)
|
||||
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID)
|
||||
for _, part := range []string{copyOfCopyPart, copyPart} {
|
||||
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
|
||||
assert.NotContains(t, part, arrivedAt("Billing sender"))
|
||||
}
|
||||
|
||||
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
||||
assert.NotContains(t, originalPart,
|
||||
copiedRequestArrivedAt("Billing sender"))
|
||||
|
||||
for _, event := range []*database.Event{copied, copyOfCopy} {
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), originalArrivedAt("Billing sender"))
|
||||
assert.Contains(t, w.Body.String(),
|
||||
copiedRequestArrivedAt("Billing sender"))
|
||||
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
||||
// request headers that hold more than it shows of a body, whether
|
||||
@@ -269,8 +280,53 @@ func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
||||
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), headerLine(tc.line))
|
||||
assert.Contains(t, w.Body.String(), tc.line)
|
||||
assert.NotContains(t, w.Body.String(), "Show the request headers")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventRequest_ManyShortHeaderLines proves that for many short
|
||||
// request header lines the event log writes no more than its limit,
|
||||
// apart from escaping: lines that fill the limit show as one block of
|
||||
// text, and one line more is left out with a link to the event's own
|
||||
// page.
|
||||
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each "A: " line and the newline after it hold four bytes, so
|
||||
// this many lines fill the limit exactly. Each line in its own
|
||||
// element would make the page many times the limit.
|
||||
const fill = bodyCap / len("A: \n")
|
||||
|
||||
tests := map[string]struct {
|
||||
lines int
|
||||
shown bool
|
||||
}{
|
||||
"filling the limit": {lines: fill, shown: true},
|
||||
"one over the limit": {lines: fill + 1, shown: false},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headersJSON, err := json.Marshal(http.Header{
|
||||
"A": slices.Repeat([]string{""}, tc.lines),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
ep := f.entrypoint(t, "Billing sender")
|
||||
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
||||
|
||||
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
|
||||
link := showHeadersLink(f.webhook.ID, event.ID)
|
||||
|
||||
assert.Equal(t, tc.shown, strings.Contains(page, box))
|
||||
assert.Equal(t, !tc.shown, strings.Contains(page, link))
|
||||
assert.Less(t, len(page), 4*bodyCap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1239,8 +1239,9 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
|
||||
// eventLogViews projects loaded events for rendering, each with
|
||||
// its deliveries, how many times it has been resubmitted and the
|
||||
// entrypoint it arrived at, and with its request headers only when
|
||||
// their lines hold at most maxHeaderBytes. Like
|
||||
// entrypoint it arrived at (for a resubmitted copy, the one the
|
||||
// request it copies arrived at), and with its request headers only
|
||||
// when their text holds at most maxHeaderBytes. Like
|
||||
// loadEventsWithDeliveries, it reports false once it has answered
|
||||
// the request with an error.
|
||||
func (h *Handlers) eventLogViews(
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<!-- The entrypoint an event arrived at and its request headers, as
|
||||
handlers.EventLogView carries them: the same in the event log and
|
||||
the event's own page. The entrypoint's URL is never shown. A
|
||||
resubmitted copy did not arrive at an entrypoint; its original
|
||||
did. -->
|
||||
resubmitted copy, even a copy of a copy, did not arrive at an
|
||||
entrypoint; the request it copies did. -->
|
||||
<div class="space-y-2 text-xs">
|
||||
{{if .ResubmittedFrom}}
|
||||
<p class="text-gray-500">Its original arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||
{{else}}
|
||||
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||
{{end}}
|
||||
@@ -14,9 +14,7 @@
|
||||
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
|
||||
{{else if .Headers}}
|
||||
<p class="text-gray-500">Request headers</p>
|
||||
<div class="rounded-md border border-gray-200 bg-white p-2 font-mono text-gray-700 break-all">
|
||||
{{range .Headers}}<div class="whitespace-pre-wrap">{{.}}</div>{{end}}
|
||||
</div>
|
||||
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
|
||||
{{else}}
|
||||
<p class="text-gray-500">No request headers.</p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user