check / check (push) Successful in 4m30s
The recent events list on a webhook's page now shows the 50 newest events, each with its time relative to now (the full UTC timestamp on hover), its body size, its processing time and, when the webhook has exactly one HTTP target, that target's last HTTP status, colour-coded. Resubmitted copies are marked, as in the event log. Each event now records its body's size in bytes when it is stored, on receipt and on resubmit, and the list reads that instead of the body. Processing time is read from the existing delivery and attempt timestamps. The list loads its deliveries and their attempts in batched queries of its own, reading only the columns it shows. A failed load answers with an error instead of an empty list. Model: opus-5-5
605 lines
16 KiB
Go
605 lines
16 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// resubmitTargetURL is a public destination, so a target configured
|
|
// with it is one the SSRF guard would accept. Nothing in these tests
|
|
// dispatches to it: the notifier is recorded, not run.
|
|
const resubmitTargetURL = "http://93.184.216.34/hook"
|
|
|
|
// resubmitEventHeaders is the stored header JSON a seeded event
|
|
// carries, so a test can prove the copy takes it verbatim.
|
|
const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
|
|
|
|
// seedStoredEvent records one event in a webhook's own database with
|
|
// no deliveries at all, which is the state a captured event is in when
|
|
// the operator has yet to create the target to test.
|
|
func seedStoredEvent(
|
|
t *testing.T,
|
|
dbMgr *database.WebhookDBManager,
|
|
webhookID, body string,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: "entrypoint-" + webhookID,
|
|
Method: http.MethodPost,
|
|
Headers: resubmitEventHeaders,
|
|
Body: body,
|
|
ContentType: contentTypeJSON,
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
return event
|
|
}
|
|
|
|
// postResubmit runs the real resubmit handler for one event.
|
|
func postResubmit(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID, eventID string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := postRequest(
|
|
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
|
authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
),
|
|
map[string]string{
|
|
paramSourceID: webhookID,
|
|
paramEventID: eventID,
|
|
},
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleEventResubmit().ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// listEvents reads every event in a webhook's database, oldest first.
|
|
func listEvents(
|
|
t *testing.T, webhookDB *gorm.DB,
|
|
) []database.Event {
|
|
t.Helper()
|
|
|
|
var events []database.Event
|
|
|
|
require.NoError(t, webhookDB.
|
|
Order("created_at ASC, id ASC").
|
|
Find(&events).Error)
|
|
|
|
return events
|
|
}
|
|
|
|
// theOtherEvent returns the one event that is not excludeID.
|
|
func theOtherEvent(
|
|
t *testing.T, events []database.Event, excludeID string,
|
|
) database.Event {
|
|
t.Helper()
|
|
|
|
var found []database.Event
|
|
|
|
for _, e := range events {
|
|
if e.ID != excludeID {
|
|
found = append(found, e)
|
|
}
|
|
}
|
|
|
|
require.Len(t, found, 1)
|
|
|
|
return found[0]
|
|
}
|
|
|
|
// TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent is the
|
|
// core of the feature and the thing per-delivery replay cannot do: the
|
|
// event was captured before the target existed, so it has no delivery
|
|
// to replay, and the resubmit must still reach the new target.
|
|
func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
original := seedStoredEvent(
|
|
t, dbMgr, wh.ID, `{"captured":"traffic"}`,
|
|
)
|
|
|
|
// The dev backend is registered only now, after the traffic was
|
|
// captured. It has no prior delivery of anything.
|
|
tgt := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(
|
|
t, events, 2,
|
|
"resubmit must create a new event, not mark the old one",
|
|
)
|
|
|
|
fresh := theOtherEvent(t, events, original.ID)
|
|
assertEventCopy(t, original, fresh)
|
|
|
|
// The delivery hangs off the NEW event, and the original event
|
|
// still has none.
|
|
assert.Empty(
|
|
t, listDeliveries(t, webhookDB, original.ID),
|
|
"the original event must be left untouched",
|
|
)
|
|
|
|
deliveries := listDeliveries(t, webhookDB, fresh.ID)
|
|
require.Len(t, deliveries, 1)
|
|
assert.Equal(t, tgt.ID, deliveries[0].TargetID)
|
|
assert.Equal(
|
|
t, database.DeliveryStatusPending, deliveries[0].Status,
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, deliveries[0].ID, tasks[0].DeliveryID)
|
|
assertResubmitTask(t, tasks[0], wh.ID, &fresh, tgt)
|
|
|
|
assertNoLeakedTarget(t, webhookDB)
|
|
}
|
|
|
|
// assertEventCopy proves the resubmitted event copies every stored
|
|
// field of the event it came from, and records the provenance that
|
|
// keeps the log readable. It also pins that a received event carries
|
|
// no source event of its own.
|
|
func assertEventCopy(
|
|
t *testing.T, original *database.Event, fresh database.Event,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, original.Method, fresh.Method)
|
|
assert.Equal(t, original.Headers, fresh.Headers)
|
|
assert.Equal(t, original.Body, fresh.Body)
|
|
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
|
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
|
assert.NotEqual(t, original.ID, fresh.ID)
|
|
|
|
require.NotNil(t, fresh.ResubmittedFromID)
|
|
assert.Equal(t, original.ID, *fresh.ResubmittedFromID)
|
|
|
|
assert.Nil(
|
|
t, original.ResubmittedFromID,
|
|
"a received event records no source event",
|
|
)
|
|
}
|
|
|
|
// assertResubmitTask proves the task handed to the delivery engine is
|
|
// the one the receiver would build for the NEW event and this target.
|
|
func assertResubmitTask(
|
|
t *testing.T,
|
|
task delivery.Task,
|
|
webhookID string,
|
|
fresh *database.Event,
|
|
target *database.Target,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, fresh.ID, task.EventID)
|
|
assert.Equal(t, webhookID, task.WebhookID)
|
|
assert.Equal(t, fresh.EntrypointID, task.EntrypointID)
|
|
assert.Equal(t, target.ID, task.TargetID)
|
|
assert.Equal(t, target.Type, task.TargetType)
|
|
assert.Equal(t, fresh.Method, task.Method)
|
|
assert.Equal(t, fresh.Headers, task.Headers)
|
|
assert.Equal(t, fresh.ContentType, task.ContentType)
|
|
assert.Equal(t, 1, task.AttemptNum)
|
|
|
|
require.NotNil(t, task.Body)
|
|
assert.Equal(t, fresh.Body, *task.Body)
|
|
}
|
|
|
|
// TestHandleEventResubmit_IsRepeatable proves the requirement replay
|
|
// deliberately does not meet: firing the same captured event at a
|
|
// backend over and over must work, with no in-flight refusal, and each
|
|
// press must produce its own event and its own delivery.
|
|
func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
tgt := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`)
|
|
|
|
// Nothing between the presses marks the earlier deliveries
|
|
// finished, so every one of these is submitted while the last is
|
|
// still pending.
|
|
const presses = 5
|
|
|
|
for range presses {
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
"a resubmit must not be refused while an earlier "+
|
|
"one is in flight",
|
|
)
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, presses+1)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, presses)
|
|
|
|
seen := make(map[string]struct{}, presses)
|
|
|
|
for _, task := range tasks {
|
|
assert.Equal(t, tgt.ID, task.TargetID)
|
|
assert.NotEqual(
|
|
t, original.ID, task.EventID,
|
|
"each resubmit delivers its own new event",
|
|
)
|
|
|
|
_, dup := seen[task.EventID]
|
|
assert.False(t, dup, "each resubmit creates its own event")
|
|
|
|
seen[task.EventID] = struct{}{}
|
|
|
|
require.Len(t, listDeliveries(t, webhookDB, task.EventID), 1)
|
|
}
|
|
|
|
// Every copy names the same source event, so twenty presses stay
|
|
// traceable to the one captured request.
|
|
for _, e := range events {
|
|
if e.ID == original.ID {
|
|
continue
|
|
}
|
|
|
|
require.NotNil(t, e.ResubmittedFromID)
|
|
assert.Equal(t, original.ID, *e.ResubmittedFromID)
|
|
}
|
|
}
|
|
|
|
// TestHandleEventResubmit_OversizeBodySurvivesIntact covers the
|
|
// non-inline case: a body above delivery.MaxInlineBodySize is not
|
|
// carried on the task at all, so it has to be copied into the new
|
|
// event row byte-identically for the engine to load it from there.
|
|
func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// Well over the inline limit, and not text: a multibyte rune, a
|
|
// NUL and a byte that is not valid UTF-8, so a copy that went
|
|
// through a re-encode or a truncation is visible in the compare.
|
|
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
|
|
|
stored := strings.Repeat("A", delivery.MaxInlineBodySize) +
|
|
"é\x00\xff" +
|
|
strings.Repeat("B", 4096) + sentinel
|
|
|
|
require.Greater(t, len(stored), delivery.MaxInlineBodySize)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, stored)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
|
|
|
assert.Len(t, fresh.Body, len(stored))
|
|
assert.Equal(
|
|
t, stored, fresh.Body,
|
|
"the stored body must be copied byte for byte",
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Nil(
|
|
t, tasks[0].Body,
|
|
"a body over the inline limit is fetched from the new "+
|
|
"event row rather than carried on the task",
|
|
)
|
|
|
|
// The engine's own read of the body, against the new event id:
|
|
// what it would send is what was stored.
|
|
var loaded database.Event
|
|
|
|
require.NoError(t, webhookDB.Select("body").
|
|
First(&loaded, "id = ?", tasks[0].EventID).Error)
|
|
assert.Equal(t, stored, loaded.Body)
|
|
}
|
|
|
|
// TestHandleEventResubmit_SkipsInactiveTarget proves a deactivated
|
|
// target is skipped exactly as the receiver skips it — not an error,
|
|
// and not a delivery the operator switched off.
|
|
func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
active := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
off := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`/off"}`,
|
|
)
|
|
|
|
require.NoError(t, db.DB().Model(&database.Target{}).
|
|
Where("id = ?", off.ID).
|
|
Update("active", false).Error)
|
|
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"skip":"one"}`)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=queued",
|
|
w.Header().Get("Location"),
|
|
"an inactive target is skipped, not an error",
|
|
)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, active.ID, tasks[0].TargetID)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
fresh := theOtherEvent(t, listEvents(t, webhookDB), original.ID)
|
|
require.Len(t, listDeliveries(t, webhookDB, fresh.ID), 1)
|
|
}
|
|
|
|
// TestHandleEventResubmit_NoActiveTargetsStillStoresEvent proves a
|
|
// source with nothing to deliver to behaves as the receiver does: the
|
|
// event is stored, nothing is queued, and the operator is told so
|
|
// rather than being shown an error.
|
|
func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"no":"targets"}`)
|
|
|
|
w := postResubmit(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
assert.Empty(t, notif.Tasks())
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, listEvents(t, webhookDB), 2)
|
|
}
|
|
|
|
// TestHandleEventResubmit_RefusesEventOfAnotherWebhook proves the
|
|
// route cannot re-inject an event out of a webhook the session's user
|
|
// does not own, and reports the same 404 for an id that names nothing.
|
|
func TestHandleEventResubmit_RefusesEventOfAnotherWebhook(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
notif *recordingNotifier
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
theirs := seedWebhookFor(t, db, otherTestUserID)
|
|
theirEvent := seedStoredEvent(t, dbMgr, theirs.ID, `{"not":"mine"}`)
|
|
|
|
mine := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, mine.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`)
|
|
|
|
// Their webhook, as its owner would address it.
|
|
w := postResubmit(t, h, sess, theirs.ID, theirEvent.ID)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// Their event id, addressed through a webhook the user does own.
|
|
w = postResubmit(t, h, sess, mine.ID, theirEvent.ID)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// An id that names no event at all.
|
|
w = postResubmit(t, h, sess, mine.ID, uuid.NewString())
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// A malformed id never reaches the query.
|
|
w = postResubmit(t, h, sess, mine.ID, "not-a-uuid")
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
assert.Empty(
|
|
t, notif.Tasks(),
|
|
"a refused resubmit must queue nothing",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceLogs_ShowsResubmitProvenance proves the event log
|
|
// reports the relationship in both directions, which is what keeps it
|
|
// readable once one captured event has been fired repeatedly.
|
|
func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+resubmitTargetURL+`"}`,
|
|
)
|
|
original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`)
|
|
|
|
for range 2 {
|
|
require.Equal(
|
|
t,
|
|
http.StatusSeeOther,
|
|
postResubmit(t, h, sess, wh.ID, original.ID).Code,
|
|
)
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 3)
|
|
|
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(
|
|
t, body, "Resubmitted as 2 new events",
|
|
"the source event must show it has been resubmitted",
|
|
)
|
|
assert.Contains(
|
|
t, body, "Resubmitted from event",
|
|
"a copy must show where it came from",
|
|
)
|
|
assert.Contains(
|
|
t, body,
|
|
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
|
"the log must offer the resubmit action per event",
|
|
)
|
|
}
|