check / check (push) Successful in 4m45s
The receiver dropped the query string of every request it received, so a sender's URL parameters were silently lost. Each event now keeps it, as sent, in a new `raw_query` column of the per-webhook `events` table; a resubmitted copy carries its original's. The event log and the event's page show it in the shared request block, the event log leaving out one over 32 KiB with a link, as for headers. The archive and log targets carry it. HTTP targets gain "Pass the query string on to this target", off by default: on, deliveries, replays and resubmits append it to the target URL, joined with `&` to one already there. The access log still hides it. Model: opus-5-5
405 lines
13 KiB
Go
405 lines
13 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"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 wrap-anywhere">` + name +
|
|
`</span>`
|
|
}
|
|
|
|
// 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 wrap-anywhere">` + name + `</span>`
|
|
}
|
|
|
|
// 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
|
|
// 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()
|
|
|
|
assert.Contains(t, page, arrivedAt("Billing sender"))
|
|
assert.Contains(t, page, "No query string.")
|
|
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)
|
|
}
|
|
|
|
newerShows := func(t *testing.T, page string) {
|
|
t.Helper()
|
|
|
|
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
|
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)
|
|
}
|
|
|
|
// 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 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(-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 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/"+copied.ID+"/resubmit",
|
|
)
|
|
require.True(t, found)
|
|
|
|
copyPart, originalPart, found := strings.Cut(
|
|
rest, "/events/"+original.ID+"/resubmit",
|
|
)
|
|
require.True(t, found)
|
|
|
|
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(),
|
|
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
|
|
// 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(), 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleWebhook_StoresAndShowsTheQueryString posts to an
|
|
// entrypoint's URL with a query string and proves the event stores it
|
|
// as sent, and shows it escaped in the event log and on its own page,
|
|
// in a box like the one the request headers show in.
|
|
func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
ep := seedEntrypoint(t, f.db, f.webhook.ID)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost,
|
|
"/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"),
|
|
)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("uuid", ep.Path)
|
|
|
|
req = req.WithContext(context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
))
|
|
|
|
w := httptest.NewRecorder()
|
|
f.h.HandleWebhook().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var stored database.Event
|
|
|
|
require.NoError(t, f.webhookDB.First(&stored).Error)
|
|
assert.Equal(t, "a=1&b=2", stored.RawQuery)
|
|
|
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
assert.Contains(t, page, headerBox("a=1&b=2"))
|
|
|
|
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), headerBox("a=1&b=2"))
|
|
}
|
|
|
|
// TestEventRequest_QueryStringOverTheLimit proves the event log leaves
|
|
// out a query string that holds more than it shows of a body, and links
|
|
// to the event's own page, which shows it whole.
|
|
func TestEventRequest_QueryStringOverTheLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
ep := f.entrypoint(t, "Billing sender")
|
|
event := f.eventAt(t, ep, `{}`, time.Now())
|
|
query := "q=" + strings.Repeat("x", bodyCap)
|
|
|
|
require.NoError(t, f.webhookDB.Model(event).Update(
|
|
"raw_query", query,
|
|
).Error)
|
|
|
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
assert.Contains(t, page, `<a href="/hook/`+f.webhook.ID+`/events/`+
|
|
event.ID+`" class="btn-small">Show the query string</a>`)
|
|
assert.NotContains(t, page, "q=x")
|
|
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(), headerBox(query))
|
|
assert.NotContains(t, w.Body.String(), "Show the query string")
|
|
}
|