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}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
| `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 |
|
| `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}/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`) |
|
| `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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -48,18 +49,20 @@ type EventLogView struct {
|
|||||||
|
|
||||||
Body BodyView
|
Body BodyView
|
||||||
|
|
||||||
// Entrypoint names the entrypoint the event arrived at, or for a
|
// Entrypoint names the entrypoint the event arrived at. A
|
||||||
// resubmitted copy, which did not arrive, the one its original
|
// resubmitted copy, even a copy of a copy, did not arrive; it
|
||||||
// arrived at: its description, "Entrypoint" when it has none, or
|
// names the one the request it copies arrived at. The name is
|
||||||
// "deleted entrypoint". Never its URL, which is the entrypoint's
|
// the entrypoint's description, "Entrypoint" when it has none,
|
||||||
// secret.
|
// or "deleted entrypoint", never its URL, which is the
|
||||||
|
// entrypoint's secret.
|
||||||
Entrypoint string
|
Entrypoint string
|
||||||
|
|
||||||
// Headers is the event's request headers, one "Name: value"
|
// Headers is the event's request headers as text, one
|
||||||
// per value, sorted by name. HeadersCut reports headers left
|
// "Name: value" line per value, sorted by name. HeadersCut
|
||||||
// out because they hold more than maxRenderedBodyBytes, stored
|
// reports headers left out because they hold more than
|
||||||
// or as lines; only the event log leaves them out.
|
// maxRenderedBodyBytes, stored or as text; only the event log
|
||||||
Headers []string
|
// leaves them out.
|
||||||
|
Headers string
|
||||||
HeadersCut bool
|
HeadersCut bool
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// 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
|
// view projects a loaded row of the webhook's events for
|
||||||
// rendering. It shows the request headers when the row holds them
|
// 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(
|
func (r *eventLogRow) view(
|
||||||
webhookID string, maxHeaderBytes int,
|
webhookID string, maxHeaderBytes int,
|
||||||
) EventLogView {
|
) EventLogView {
|
||||||
@@ -120,7 +123,7 @@ func (r *eventLogRow) view(
|
|||||||
Body: newBodyView(
|
Body: newBodyView(
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
),
|
),
|
||||||
Headers: headers,
|
Headers: strings.Join(headers, "\n"),
|
||||||
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
@@ -130,8 +133,9 @@ func (r *eventLogRow) view(
|
|||||||
// JSON the receiver writes, into one "Name: value" line per value,
|
// JSON the receiver writes, into one "Name: value" line per value,
|
||||||
// sorted by name. Headers that do not parse, as when the event log
|
// 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
|
// 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
|
// the lines, each with the newline that follows it, would hold more
|
||||||
// is stored with its name once but shown with it on every line.
|
// 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) {
|
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||||
var headers http.Header
|
var headers http.Header
|
||||||
|
|
||||||
@@ -154,7 +158,7 @@ func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
|||||||
for _, value := range headers[name] {
|
for _, value := range headers[name] {
|
||||||
line := name + ": " + value
|
line := name + ": " + value
|
||||||
|
|
||||||
size += len(line)
|
size += len(line) + len("\n")
|
||||||
if size > maxBytes {
|
if size > maxBytes {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,19 @@ func arrivedAt(name string) string {
|
|||||||
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// originalArrivedAt is how a page names the entrypoint a resubmitted
|
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
|
||||||
// copy's original arrived at.
|
// the entrypoint the request it copies arrived at.
|
||||||
func originalArrivedAt(name string) string {
|
func copiedRequestArrivedAt(name string) string {
|
||||||
return `Its original arrived at <span class="text-gray-900">` +
|
return `The request it copies arrived at <span class="text-gray-900">` +
|
||||||
name + `</span>`
|
name + `</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerLine is how a page shows one request header line.
|
// headerBox is how a page shows an event's request header lines: as
|
||||||
func headerLine(line string) string {
|
// one block of text in a single box.
|
||||||
return `<div class="whitespace-pre-wrap">` + line + `</div>`
|
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
|
// 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) {
|
olderShows := func(t *testing.T, page string) {
|
||||||
t.Helper()
|
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, arrivedAt("Billing sender"))
|
||||||
assert.Contains(t, page, headerLine(accept))
|
assert.Contains(t, page, headerBox(
|
||||||
assert.Contains(t, page, headerLine(userAgent))
|
"Accept: */*",
|
||||||
assert.Contains(t, page, headerLine(shopEvent))
|
"User-Agent: shop/1 build\t7",
|
||||||
assert.Less(t,
|
"X-Shop-Event: order.created",
|
||||||
strings.Index(page, accept), strings.Index(page, userAgent),
|
), "headers are sorted by name")
|
||||||
"headers are sorted by name",
|
|
||||||
)
|
|
||||||
assert.Less(t,
|
|
||||||
strings.Index(page, userAgent), strings.Index(page, shopEvent),
|
|
||||||
"headers are sorted by name",
|
|
||||||
)
|
|
||||||
assert.NotContains(t, page, "order.paid")
|
assert.NotContains(t, page, "order.paid")
|
||||||
assert.NotContains(t, page, billing.Path)
|
assert.NotContains(t, page, billing.Path)
|
||||||
}
|
}
|
||||||
@@ -137,8 +128,10 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
||||||
assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
|
assert.Contains(t, page, headerBox(
|
||||||
assert.Contains(t, page, headerLine("X-Note: <b>hi</b>"))
|
"X-Note: <b>hi</b>",
|
||||||
|
"X-Shop-Event: order.paid",
|
||||||
|
))
|
||||||
assert.NotContains(t, page, "<b>hi</b>")
|
assert.NotContains(t, page, "<b>hi</b>")
|
||||||
assert.NotContains(t, page, "order.created")
|
assert.NotContains(t, page, "order.created")
|
||||||
assert.NotContains(t, page, unnamed.Path)
|
assert.NotContains(t, page, unnamed.Path)
|
||||||
@@ -187,39 +180,57 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
|||||||
assert.NotContains(t, w.Body.String(), "Retired sender")
|
assert.NotContains(t, w.Body.String(), "Retired sender")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
|
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
|
||||||
// original arrived at the entrypoint, in the event log and on its own
|
// of that copy each say the request they copy arrived at the
|
||||||
// page, and never that the copy did.
|
// entrypoint, in the event log and on their own pages, and never that
|
||||||
|
// they did.
|
||||||
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newRecentEventsFixture(t)
|
f := newRecentEventsFixture(t)
|
||||||
ep := f.entrypoint(t, "Billing sender")
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
|
||||||
copied := f.eventAt(t, ep, `{}`, time.Now())
|
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(
|
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||||
"resubmitted_from_id", original.ID,
|
"resubmitted_from_id", original.ID,
|
||||||
).Error)
|
).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
|
// The log lists the newest event first, and each event's Resubmit
|
||||||
// comes after all of the copy and before the original's
|
// form comes before its entrypoint, so cutting the page at the
|
||||||
// entrypoint.
|
// copy's and the original's forms leaves each event's entrypoint
|
||||||
copyPart, originalPart, found := strings.Cut(
|
// in its own part.
|
||||||
|
copyOfCopyPart, rest, found := strings.Cut(
|
||||||
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
"/events/"+original.ID+"/resubmit",
|
"/events/"+copied.ID+"/resubmit",
|
||||||
)
|
)
|
||||||
require.True(t, found)
|
require.True(t, found)
|
||||||
|
|
||||||
assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
|
copyPart, originalPart, found := strings.Cut(
|
||||||
assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
|
rest, "/events/"+original.ID+"/resubmit",
|
||||||
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
)
|
||||||
assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
|
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)
|
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"))
|
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
||||||
@@ -269,8 +280,53 @@ func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
|||||||
|
|
||||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
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")
|
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
|
// eventLogViews projects loaded events for rendering, each with
|
||||||
// its deliveries, how many times it has been resubmitted and the
|
// its deliveries, how many times it has been resubmitted and the
|
||||||
// entrypoint it arrived at, and with its request headers only when
|
// entrypoint it arrived at (for a resubmitted copy, the one the
|
||||||
// their lines hold at most maxHeaderBytes. Like
|
// 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
|
// loadEventsWithDeliveries, it reports false once it has answered
|
||||||
// the request with an error.
|
// the request with an error.
|
||||||
func (h *Handlers) eventLogViews(
|
func (h *Handlers) eventLogViews(
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<!-- The entrypoint an event arrived at and its request headers, as
|
<!-- The entrypoint an event arrived at and its request headers, as
|
||||||
handlers.EventLogView carries them: the same in the event log and
|
handlers.EventLogView carries them: the same in the event log and
|
||||||
the event's own page. The entrypoint's URL is never shown. A
|
the event's own page. The entrypoint's URL is never shown. A
|
||||||
resubmitted copy did not arrive at an entrypoint; its original
|
resubmitted copy, even a copy of a copy, did not arrive at an
|
||||||
did. -->
|
entrypoint; the request it copies did. -->
|
||||||
<div class="space-y-2 text-xs">
|
<div class="space-y-2 text-xs">
|
||||||
{{if .ResubmittedFrom}}
|
{{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}}
|
{{else}}
|
||||||
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||||
{{end}}
|
{{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>
|
<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}}
|
{{else if .Headers}}
|
||||||
<p class="text-gray-500">Request headers</p>
|
<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">
|
<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>
|
||||||
{{range .Headers}}<div class="whitespace-pre-wrap">{{.}}</div>{{end}}
|
|
||||||
</div>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-gray-500">No request headers.</p>
|
<p class="text-gray-500">No request headers.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user