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, "
Arrived at {{.Entrypoint}}
+ {{if .Headers}} +Request headers
+No request headers.
+ {{end}} +