diff --git a/README.md b/README.md index 2b2f6bc..ea433b3 100644 --- a/README.md +++ b/README.md @@ -3051,7 +3051,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, 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, 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`) | diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 3079493..4a235f7 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -1,8 +1,13 @@ package handlers import ( + "encoding/json" + "net/http" + "slices" "time" "unicode/utf8" + + "sneak.berlin/go/webhooker/internal/database" ) // eventLogColumns is the event log's projection. The casts to @@ -12,14 +17,14 @@ import ( // rather than in Go is the point of the projection — an // oversized body never becomes a Go string at all. const eventLogColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, " + + "resubmitted_from_id, entrypoint_id, headers, " + "substr(cast(body as blob), 1, ?) AS body, " + "length(cast(body as blob)) AS body_bytes" // eventColumns is eventLogColumns for the event's own page, which // shows the whole body. const eventColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, " + + "resubmitted_from_id, entrypoint_id, headers, " + "cast(body as blob) AS body, " + "length(cast(body as blob)) AS body_bytes" @@ -34,6 +39,15 @@ type EventLogView struct { Body BodyView + // Entrypoint names the entrypoint the event arrived at: its + // 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. + Headers []string + // ResubmittedFromID names the event this one was copied // from, empty for an event that arrived on the receiver. ResubmittedFromID string @@ -63,6 +77,8 @@ type eventLogRow struct { Method string ContentType string ResubmittedFromID *string + EntrypointID string + Headers string Body []byte BodyBytes int64 } @@ -83,10 +99,69 @@ func (r *eventLogRow) view(webhookID string) EventLogView { Body: newBodyView( "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, ), + Headers: requestHeaderLines(r.Headers), ResubmittedFromID: from, } } +// requestHeaderLines turns an event's stored request headers, the +// JSON the receiver writes, into one "Name: value" line per value, +// sorted by name. Headers that do not parse show as none. +func requestHeaderLines(headersJSON string) []string { + var headers http.Header + + if json.Unmarshal([]byte(headersJSON), &headers) != nil { + return nil + } + + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + + slices.Sort(names) + + var lines []string + + for _, name := range names { + for _, value := range headers[name] { + lines = append(lines, name+": "+value) + } + } + + return lines +} + +// entrypointNames maps each of the webhook's entrypoints to the name +// an event that arrived at it shows: its description, or "Entrypoint" +// when it has none, as the webhook page names it. A deleted +// entrypoint is left out. +func (h *Handlers) entrypointNames( + webhookID string, +) (map[string]string, error) { + var entrypoints []database.Entrypoint + + err := h.db.DB().Where( + "webhook_id = ?", webhookID, + ).Find(&entrypoints).Error + if err != nil { + return nil, err + } + + names := make(map[string]string, len(entrypoints)) + + for i := range entrypoints { + name := entrypoints[i].Description + if name == "" { + name = "Entrypoint" + } + + names[entrypoints[i].ID] = name + } + + return names, nil +} + // trimPartialRune drops a trailing UTF-8 sequence that the // byte-wise cut left incomplete, so a multi-byte rune severed // at the cap does not surface as a mojibake tail. diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go new file mode 100644 index 0000000..381b8e1 --- /dev/null +++ b/internal/handlers/event_request_test.go @@ -0,0 +1,161 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" +) + +// arrivedAt is how a page names the entrypoint an event arrived at. +func arrivedAt(name string) string { + return `Arrived at ` + name + `` +} + +// entrypoint records one of the fixture webhook's entrypoints. +func (f *recentEventsFixture) entrypoint( + t *testing.T, description string, +) *database.Entrypoint { + t.Helper() + + ep := &database.Entrypoint{ + WebhookID: f.webhook.ID, + Path: uuid.NewString(), + Description: description, + Active: true, + } + + require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error) + + return ep +} + +// eventAt records an event that arrived at the entrypoint with the +// given request headers, stored as the receiver stores them. +func (f *recentEventsFixture) eventAt( + t *testing.T, + ep *database.Entrypoint, + headers http.Header, + receivedAt time.Time, +) *database.Event { + t.Helper() + + headersJSON, err := json.Marshal(headers) + require.NoError(t, err) + + event := &database.Event{ + WebhookID: f.webhook.ID, + EntrypointID: ep.ID, + Method: http.MethodPost, + Headers: string(headersJSON), + Body: "{}", + BodyBytes: 2, + ContentType: contentTypeJSON, + } + event.CreatedAt = receivedAt + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(event).Error) + + return event +} + +// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two +// events that arrived at two entrypoints each show their own +// entrypoint and request headers, in the event log and on their own +// pages, with the headers sorted by name and escaped, and never the +// entrypoint's URL. +func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders( + t *testing.T, +) { + t.Parallel() + + f := newRecentEventsFixture(t) + billing := f.entrypoint(t, "Billing sender") + unnamed := f.entrypoint(t, "") + + older := f.eventAt(t, billing, http.Header{ + "X-Shop-Event": {"order.created"}, + "User-Agent": {"shop/1"}, + }, time.Now().Add(-time.Minute)) + newer := f.eventAt(t, unnamed, http.Header{ + "X-Shop-Event": {"order.paid"}, + "X-Note": {"hi"}, + }, time.Now()) + + olderShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Billing sender")) + assert.Contains(t, page, "
User-Agent: shop/1
") + assert.Contains(t, page, "
X-Shop-Event: order.created
") + assert.Less(t, + strings.Index(page, "User-Agent: shop/1"), + strings.Index(page, "X-Shop-Event: order.created"), + "headers are sorted by name", + ) + assert.NotContains(t, page, "order.paid") + assert.NotContains(t, page, billing.Path) + } + + newerShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Entrypoint")) + assert.Contains(t, page, "
X-Shop-Event: order.paid
") + assert.Contains(t, page, "
X-Note: <b>hi</b>
") + assert.NotContains(t, page, "hi") + assert.NotContains(t, page, "order.created") + assert.NotContains(t, page, unnamed.Path) + } + + // The log lists the newer event first, so everything between + // the two events' first mentions belongs to the newer one. + _, rest, found := strings.Cut(renderSourceLogsPage( + t, f.h, f.sess, f.webhook.ID, + ), newer.ID) + require.True(t, found) + + newerPart, olderPart, found := strings.Cut(rest, older.ID) + require.True(t, found) + + newerShows(t, newerPart) + olderShows(t, olderPart) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID) + require.Equal(t, http.StatusOK, w.Code) + newerShows(t, w.Body.String()) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID) + require.Equal(t, http.StatusOK, w.Code) + olderShows(t, w.Body.String()) +} + +// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint +// has since been deleted says so in the event log and on its own page. +func TestEventRequest_DeletedEntrypoint(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Retired sender") + event := f.eventAt(t, ep, nil, time.Now()) + + require.NoError(t, f.db.DB().Delete(ep).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, arrivedAt("deleted entrypoint")) + assert.NotContains(t, page, "Retired sender") + + 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(), arrivedAt("deleted entrypoint")) + assert.NotContains(t, w.Body.String(), "Retired sender") +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c16173c..064900f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -166,10 +166,12 @@ func New( ), "source_edit.html": parsePageTemplate("source_edit.html"), "source_logs.html": parsePageTemplate( - "source_logs.html", "event_body.html", "delivery_attempts.html", + "source_logs.html", "event_request.html", "event_body.html", + "delivery_attempts.html", ), "event_detail.html": parsePageTemplate( - "event_detail.html", "event_body.html", "delivery_attempts.html", + "event_detail.html", "event_request.html", "event_body.html", + "delivery_attempts.html", ), "target_edit.html": parsePageTemplate("target_edit.html"), "error.html": parsePageTemplate("error.html"), diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 01dcb50..6b69db0 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1240,7 +1240,8 @@ func (h *Handlers) loadEventsWithDeliveries( } // eventLogViews projects loaded events for rendering, each with -// its deliveries and how many times it has been resubmitted. Like +// its deliveries, how many times it has been resubmitted and the +// entrypoint it arrived at. Like // loadEventsWithDeliveries, it reports false once it has answered // the request with an error. func (h *Handlers) eventLogViews( @@ -1293,11 +1294,25 @@ func (h *Handlers) eventLogViews( return nil, false } + entrypoints, err := h.entrypointNames(webhookID) + if err != nil { + h.serverError(w, r, "failed to load entrypoints", err) + + return nil, false + } + for i := range rows { result[i].Deliveries = h.newDeliveryViews( eventDeliveries[i], targetMap, attempts, ) result[i].ResubmitCount = resubmits[rows[i].ID] + + name, ok := entrypoints[rows[i].EntrypointID] + if !ok { + name = "deleted entrypoint" + } + + result[i].Entrypoint = name } return result, true diff --git a/templates/event_detail.html b/templates/event_detail.html index 14241b1..614a6e6 100644 --- a/templates/event_detail.html +++ b/templates/event_detail.html @@ -50,6 +50,15 @@ +
+
+

Request

+
+
+ {{template "event_request" .}} +
+
+

Body

diff --git a/templates/event_request.html b/templates/event_request.html new file mode 100644 index 0000000..9bfe02e --- /dev/null +++ b/templates/event_request.html @@ -0,0 +1,16 @@ +{{define "event_request"}} + +
+

Arrived at {{.Entrypoint}}

+ {{if .Headers}} +

Request headers

+
+ {{range .Headers}}
{{.}}
{{end}} +
+ {{else}} +

No request headers.

+ {{end}} +
+{{end}} diff --git a/templates/source_logs.html b/templates/source_logs.html index d18ae87..1ca8951 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -55,6 +55,9 @@
+
+ {{template "event_request" .}} +
{{template "event_body" .Body}} {{if .Deliveries}}