Compare commits
1
Commits
69587febfc
...
ce13e42eda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce13e42eda |
@@ -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, 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 its original 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`) |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page shows every request header.
|
||||||
views, ok := h.eventLogViews(
|
views, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targets,
|
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
@@ -12,16 +16,20 @@ import (
|
|||||||
// bytes rather than characters, so the cap bounds the page in
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||||
// rather than in Go is the point of the projection — an
|
// rather than in Go is the point of the projection — an
|
||||||
// oversized body never becomes a Go string at all.
|
// oversized body or set of request headers never becomes a Go
|
||||||
|
// string at all.
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, " +
|
||||||
|
"substr(cast(headers as blob), 1, ?) AS headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
// eventColumns is eventLogColumns for the event's own page, which
|
// eventColumns is eventLogColumns for the event's own page, which
|
||||||
// shows the whole body.
|
// shows the whole body and every request header.
|
||||||
const eventColumns = "id, created_at, method, content_type, " +
|
const eventColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"cast(body as blob) AS body, " +
|
"cast(body as blob) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
@@ -40,6 +48,20 @@ type EventLogView struct {
|
|||||||
|
|
||||||
Body BodyView
|
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 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
|
||||||
|
HeadersCut bool
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
ResubmittedFromID string
|
ResubmittedFromID string
|
||||||
@@ -60,27 +82,35 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection, or of
|
// eventLogRow is one row of the event log projection, or of
|
||||||
// eventColumns. In the event log its body column arrives
|
// eventColumns. In the event log its headers and body columns
|
||||||
// already cut to the cap by SQLite, with the true size beside
|
// arrive already cut to the cap by SQLite, each with its true
|
||||||
// it.
|
// size beside it.
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
|
EntrypointID string
|
||||||
|
Headers string
|
||||||
|
HeadersBytes int64
|
||||||
Body []byte
|
Body []byte
|
||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row of the webhook's events for
|
// view projects a loaded row of the webhook's events for
|
||||||
// rendering.
|
// rendering. It shows the request headers when the row holds them
|
||||||
func (r *eventLogRow) view(webhookID string) EventLogView {
|
// whole and their lines hold at most maxHeaderBytes.
|
||||||
|
func (r *eventLogRow) view(
|
||||||
|
webhookID string, maxHeaderBytes int,
|
||||||
|
) EventLogView {
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
@@ -90,10 +120,82 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
|
|||||||
Body: newBodyView(
|
Body: newBodyView(
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
),
|
),
|
||||||
|
Headers: headers,
|
||||||
|
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||||
ResubmittedFromID: from,
|
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, 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.
|
||||||
|
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||||
|
var headers http.Header
|
||||||
|
|
||||||
|
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(headers))
|
||||||
|
for name := range headers {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
size := 0
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
for _, value := range headers[name] {
|
||||||
|
line := name + ": " + value
|
||||||
|
|
||||||
|
size += len(line)
|
||||||
|
if size > maxBytes {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// trimPartialRune drops a trailing UTF-8 sequence that the
|
||||||
// byte-wise cut left incomplete, so a multi-byte rune severed
|
// byte-wise cut left incomplete, so a multi-byte rune severed
|
||||||
// at the cap does not surface as a mojibake tail.
|
// at the cap does not surface as a mojibake tail.
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"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 <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">` +
|
||||||
|
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>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHeadersLink is the event log's link to an event's own page for
|
||||||
|
// request headers it leaves out.
|
||||||
|
func showHeadersLink(webhookID, eventID string) string {
|
||||||
|
return `<a href="/hook/` + webhookID + `/events/` + eventID +
|
||||||
|
`" class="btn-small">Show the request headers</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 JSON as the receiver stores them.
|
||||||
|
func (f *recentEventsFixture) eventAt(
|
||||||
|
t *testing.T,
|
||||||
|
ep *database.Entrypoint,
|
||||||
|
headersJSON string,
|
||||||
|
receivedAt time.Time,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
EntrypointID: ep.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: 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, escaped and keeping their
|
||||||
|
// whitespace, 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, "")
|
||||||
|
|
||||||
|
// Stored in reverse name order.
|
||||||
|
older := f.eventAt(t, billing,
|
||||||
|
`{"X-Shop-Event":["order.created"],`+
|
||||||
|
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
|
||||||
|
time.Now().Add(-time.Minute))
|
||||||
|
newer := f.eventAt(t, unnamed,
|
||||||
|
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
|
||||||
|
time.Now())
|
||||||
|
|
||||||
|
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.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, headerLine("X-Shop-Event: order.paid"))
|
||||||
|
assert.Contains(t, page, headerLine("X-Note: <b>hi</b>"))
|
||||||
|
assert.NotContains(t, page, "<b>hi</b>")
|
||||||
|
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, `{}`, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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())
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||||
|
"resubmitted_from_id", original.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(
|
||||||
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
|
"/events/"+original.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"))
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), originalArrivedAt("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
|
||||||
|
// stored or as lines, and links to the event's own page, which shows
|
||||||
|
// them all.
|
||||||
|
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The receiver stores each "<" as six bytes of JSON, so this
|
||||||
|
// header is over the limit stored but not as a line.
|
||||||
|
const lessThans = bodyCap/6 + 1
|
||||||
|
|
||||||
|
// A header sent many times is stored with its name once, and
|
||||||
|
// shown with it on every line.
|
||||||
|
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
headers http.Header
|
||||||
|
line string
|
||||||
|
}{
|
||||||
|
"stored": {
|
||||||
|
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
|
||||||
|
line: "X-Long: " + strings.Repeat("<", lessThans),
|
||||||
|
},
|
||||||
|
"as lines": {
|
||||||
|
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
|
||||||
|
line: repeatedName + ": ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
headersJSON, err := json.Marshal(tc.headers)
|
||||||
|
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)
|
||||||
|
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
|
||||||
|
assert.NotContains(t, page, tc.line)
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
|
||||||
|
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.NotContains(t, w.Body.String(), "Show the request headers")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,12 +167,12 @@ func New(
|
|||||||
),
|
),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate(
|
"source_logs.html": parsePageTemplate(
|
||||||
"source_logs.html", "event_body.html", "delivery_row.html",
|
"source_logs.html", "event_request.html", "event_body.html",
|
||||||
"delivery_attempts.html",
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"event_detail.html": parsePageTemplate(
|
"event_detail.html": parsePageTemplate(
|
||||||
"event_detail.html", "event_body.html", "delivery_row.html",
|
"event_detail.html", "event_request.html", "event_body.html",
|
||||||
"delivery_attempts.html",
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
"error.html": parsePageTemplate("error.html"),
|
||||||
|
|||||||
@@ -1231,13 +1231,16 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
|
|
||||||
result, ok := h.eventLogViews(
|
result, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targetMap,
|
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||||
|
maxRenderedBodyBytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result, totalEvents, ok
|
return result, totalEvents, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventLogViews projects loaded events for rendering, each with
|
// 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, and with its request headers only when
|
||||||
|
// their lines hold 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(
|
||||||
@@ -1247,6 +1250,7 @@ func (h *Handlers) eventLogViews(
|
|||||||
webhookID string,
|
webhookID string,
|
||||||
rows []eventLogRow,
|
rows []eventLogRow,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
|
maxHeaderBytes int,
|
||||||
) ([]EventLogView, bool) {
|
) ([]EventLogView, bool) {
|
||||||
result := make([]EventLogView, len(rows))
|
result := make([]EventLogView, len(rows))
|
||||||
eventDeliveries := make([][]database.Delivery, len(rows))
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
@@ -1256,7 +1260,7 @@ func (h *Handlers) eventLogViews(
|
|||||||
eventIDs := make([]string, len(rows))
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
result[i] = rows[i].view(webhookID)
|
result[i] = rows[i].view(webhookID, maxHeaderBytes)
|
||||||
eventIDs[i] = rows[i].ID
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
@@ -1290,11 +1294,25 @@ func (h *Handlers) eventLogViews(
|
|||||||
return nil, false
|
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 {
|
for i := range rows {
|
||||||
result[i].Deliveries = h.newDeliveryViews(
|
result[i].Deliveries = h.newDeliveryViews(
|
||||||
eventDeliveries[i], targetMap, attempts,
|
eventDeliveries[i], targetMap, attempts,
|
||||||
)
|
)
|
||||||
result[i].ResubmitCount = resubmits[rows[i].ID]
|
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
|
return result, true
|
||||||
@@ -1315,7 +1333,7 @@ func loadEventLogRows(
|
|||||||
var rows []eventLogRow
|
var rows []eventLogRow
|
||||||
|
|
||||||
webhookDB.Model(&database.Event{}).Select(
|
webhookDB.Model(&database.Event{}).Select(
|
||||||
eventLogColumns, maxRenderedBodyBytes,
|
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
|
||||||
).Where(
|
).Where(
|
||||||
"webhook_id = ?", webhookID,
|
"webhook_id = ?", webhookID,
|
||||||
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
|
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Request</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200">
|
<div class="p-4 border-b border-gray-200">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "event_request"}}
|
||||||
|
<!-- 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. -->
|
||||||
|
<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>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .HeadersCut}}
|
||||||
|
<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>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">No request headers.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
{{template "event_body" .Body}}
|
{{template "event_body" .Body}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
|
|||||||
Reference in New Issue
Block a user