All checks were successful
check / check (push) Successful in 3m21s
There was no redelivery path anywhere: once a delivery exhausted
max_retries it was failed permanently, even though the event body is
durably stored. Storing an event and being unable to re-send it defeats
the reason it is stored, and the ordinary case is a destination that was
down longer than the backoff ladder.
Adds POST /source/{sourceID}/deliveries/{deliveryID}/replay, inside the
authenticated group so it inherits MaxBodySize, CSRF, NoCache and
RequireAuth. Replay creates a NEW pending delivery against the target's
CURRENT config and hands it to the engine through the same notifier the
receiver uses, so it runs the normal path with the retry ladder, the
SSRF-guarded transport and the circuit breaker. The original delivery's
rows are never touched, and the stored event body is re-sent, never the
recorded response.
Replay is refused, with a distinct message, for a non-terminal delivery, a
deleted target, a deactivated target, and when an earlier replay of the
same event and target is still in flight. Bounded by a per-client rate
limit and by that in-flight check.
The new delivery row is written with Omit(clause.Associations) and with
neither Event nor Target populated, so it cannot upsert a targets row into
the per-webhook event database (#206).
Counted by webhooker_delivery_replays_total on the existing target_type
label. A replay also moves the ordinary attempt, outcome and duration
series, because it is a real delivery.
527 lines
14 KiB
Go
527 lines
14 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// paramDeliveryID is the chi URL parameter name the replay handler
|
|
// reads.
|
|
const paramDeliveryID = "deliveryID"
|
|
|
|
// replayTargetURL 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 replayTargetURL = "http://93.184.216.34/hook"
|
|
|
|
// seedFailedDelivery records an event, a terminally failed delivery of
|
|
// it to the given target, and the attempt that failed.
|
|
func seedFailedDelivery(
|
|
t *testing.T,
|
|
dbMgr *database.WebhookDBManager,
|
|
webhookID, targetID string,
|
|
) (*database.Event, *database.Delivery) {
|
|
t.Helper()
|
|
|
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: "entrypoint-" + webhookID,
|
|
Method: http.MethodPost,
|
|
Headers: `{"X-Test":["yes"]}`,
|
|
Body: `{"replay":"me"}`,
|
|
ContentType: "application/json",
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: database.DeliveryStatusFailed,
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(dlv).Error)
|
|
|
|
result := &database.DeliveryResult{
|
|
DeliveryID: dlv.ID,
|
|
AttemptNum: 1,
|
|
Success: false,
|
|
StatusCode: http.StatusBadGateway,
|
|
Error: "connection refused",
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(result).Error)
|
|
|
|
return event, dlv
|
|
}
|
|
|
|
// loadDelivery reads a delivery back out of a webhook's database.
|
|
func loadDelivery(
|
|
t *testing.T, webhookDB *gorm.DB, deliveryID string,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
var dlv database.Delivery
|
|
|
|
require.NoError(
|
|
t,
|
|
webhookDB.First(&dlv, "id = ?", deliveryID).Error,
|
|
)
|
|
|
|
return dlv
|
|
}
|
|
|
|
// listDeliveries reads every delivery of an event.
|
|
func listDeliveries(
|
|
t *testing.T, webhookDB *gorm.DB, eventID string,
|
|
) []database.Delivery {
|
|
t.Helper()
|
|
|
|
var deliveries []database.Delivery
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"event_id = ?", eventID,
|
|
).Find(&deliveries).Error)
|
|
|
|
return deliveries
|
|
}
|
|
|
|
// theOtherDelivery returns the one delivery in the slice that is not
|
|
// excludeID. Identity is used rather than an ordering because the rows
|
|
// are minted milliseconds apart and their ids are random.
|
|
func theOtherDelivery(
|
|
t *testing.T,
|
|
deliveries []database.Delivery,
|
|
excludeID string,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
var found []database.Delivery
|
|
|
|
for _, d := range deliveries {
|
|
if d.ID != excludeID {
|
|
found = append(found, d)
|
|
}
|
|
}
|
|
|
|
require.Len(t, found, 1)
|
|
|
|
return found[0]
|
|
}
|
|
|
|
// postReplay runs the real replay handler for one delivery.
|
|
func postReplay(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID, deliveryID string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := postRequest(
|
|
"/source/"+webhookID+"/deliveries/"+
|
|
deliveryID+"/replay",
|
|
authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
),
|
|
map[string]string{
|
|
paramSourceID: webhookID,
|
|
paramDeliveryID: deliveryID,
|
|
},
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleDeliveryReplay().ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal is the
|
|
// core requirement: replaying a failed delivery succeeds, appends a
|
|
// new delivery, and leaves the original row and its recorded attempt
|
|
// exactly as they were.
|
|
//
|
|
// It also pins the two things a replay would be wrong to get from the
|
|
// original: the task carries the target's CURRENT configuration, which
|
|
// this test changes between the failure and the replay, and it carries
|
|
// the stored EVENT body rather than anything the failed attempt
|
|
// received back.
|
|
func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|
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":"`+replayTargetURL+`"}`,
|
|
)
|
|
|
|
event, original := seedFailedDelivery(
|
|
t, dbMgr, wh.ID, tgt.ID,
|
|
)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
before := loadDelivery(t, webhookDB, original.ID)
|
|
|
|
// The operator fixes the destination, which is the whole reason
|
|
// to replay. The replay must use this, not the config the
|
|
// original delivery ran against.
|
|
const fixedConfig = `{"url":"http://93.184.216.34/fixed"}`
|
|
|
|
require.NoError(t, db.DB().Model(&database.Target{}).
|
|
Where("id = ?", tgt.ID).
|
|
Update("config", fixedConfig).Error)
|
|
|
|
w := postReplay(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=queued",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
deliveries := listDeliveries(t, webhookDB, event.ID)
|
|
require.Len(
|
|
t, deliveries, 2,
|
|
"replay must append a delivery, not reuse one",
|
|
)
|
|
|
|
replayed := theOtherDelivery(t, deliveries, original.ID)
|
|
assert.Equal(t, tgt.ID, replayed.TargetID)
|
|
assert.Equal(t, event.ID, replayed.EventID)
|
|
assert.Equal(
|
|
t, database.DeliveryStatusPending, replayed.Status,
|
|
)
|
|
|
|
assertDeliveryUntouched(t, webhookDB, before)
|
|
|
|
tasks := notif.Tasks()
|
|
require.Len(t, tasks, 1)
|
|
assertReplayTask(
|
|
t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedConfig,
|
|
)
|
|
assertNoLeakedTarget(t, webhookDB)
|
|
}
|
|
|
|
// assertDeliveryUntouched proves a delivery row is exactly as it was
|
|
// read before: same terminal status, same timestamps, and the same
|
|
// recorded attempts.
|
|
func assertDeliveryUntouched(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
before database.Delivery,
|
|
) {
|
|
t.Helper()
|
|
|
|
after := loadDelivery(t, webhookDB, before.ID)
|
|
assert.Equal(
|
|
t, before.Status, after.Status,
|
|
"replay must not resurrect the original delivery",
|
|
)
|
|
assert.Equal(t, before.UpdatedAt, after.UpdatedAt)
|
|
assert.Equal(t, before.CreatedAt, after.CreatedAt)
|
|
|
|
var attempts int64
|
|
|
|
require.NoError(t, webhookDB.
|
|
Model(&database.DeliveryResult{}).
|
|
Where("delivery_id = ?", before.ID).
|
|
Count(&attempts).Error)
|
|
assert.Equal(
|
|
t, int64(1), attempts,
|
|
"the original delivery's attempt history must stand",
|
|
)
|
|
}
|
|
|
|
// assertReplayTask proves the task handed to the delivery engine is
|
|
// the one the receiver would build for this event and this target, and
|
|
// that it carries wantConfig — the target's configuration as it stands
|
|
// now rather than as the original delivery ran against it.
|
|
func assertReplayTask(
|
|
t *testing.T,
|
|
task delivery.Task,
|
|
webhookID string,
|
|
event *database.Event,
|
|
target *database.Target,
|
|
wantDeliveryID, wantConfig string,
|
|
) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, wantDeliveryID, task.DeliveryID)
|
|
assert.Equal(t, event.ID, task.EventID)
|
|
assert.Equal(t, webhookID, task.WebhookID)
|
|
assert.Equal(t, event.EntrypointID, task.EntrypointID)
|
|
assert.Equal(t, target.ID, task.TargetID)
|
|
assert.Equal(t, target.Type, task.TargetType)
|
|
assert.JSONEq(
|
|
t, wantConfig, task.TargetConfig,
|
|
"replay must use the target's current configuration",
|
|
)
|
|
assert.Equal(t, event.Method, task.Method)
|
|
assert.Equal(t, event.Headers, task.Headers)
|
|
assert.Equal(t, event.ContentType, task.ContentType)
|
|
assert.Equal(t, 1, task.AttemptNum)
|
|
require.NotNil(t, task.Body)
|
|
assert.Equal(
|
|
t, event.Body, *task.Body,
|
|
"replay re-sends the stored event body",
|
|
)
|
|
}
|
|
|
|
// assertNoLeakedTarget proves the per-webhook database holds no target
|
|
// rows. AutoMigrate creates the table there because Delivery declares
|
|
// the relation, so it is a ROW that signals a leak: an association
|
|
// write would have upserted the whole target, plaintext config and
|
|
// all, into the event database. See
|
|
// https://git.eeqj.de/sneak/webhooker/issues/206.
|
|
func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) {
|
|
t.Helper()
|
|
|
|
var leaked int64
|
|
|
|
require.NoError(t, webhookDB.Unscoped().
|
|
Model(&database.Target{}).Count(&leaked).Error)
|
|
assert.Zero(
|
|
t, leaked,
|
|
"replay must not write the target into the event database",
|
|
)
|
|
}
|
|
|
|
// TestHandleDeliveryReplay_RefusesDeletedTarget proves the required
|
|
// refusal: a target deleted since the delivery ran is reported as
|
|
// deleted rather than erroring, and nothing is created or queued.
|
|
func TestHandleDeliveryReplay_RefusesDeletedTarget(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":"`+replayTargetURL+`"}`,
|
|
)
|
|
|
|
event, original := seedFailedDelivery(
|
|
t, dbMgr, wh.ID, tgt.ID,
|
|
)
|
|
|
|
// Deletes are soft, so the delivery history outlives the target.
|
|
require.NoError(t, db.DB().Where(
|
|
"id = ?", tgt.ID,
|
|
).Delete(&database.Target{}).Error)
|
|
|
|
w := postReplay(t, h, sess, wh.ID, original.ID)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=target-deleted",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(
|
|
t, listDeliveries(t, webhookDB, event.ID), 1,
|
|
"a refused replay must create no delivery",
|
|
)
|
|
assert.Empty(
|
|
t, notif.Tasks(),
|
|
"a refused replay must queue nothing",
|
|
)
|
|
|
|
// The refusal is specific, which is why the target is looked up
|
|
// including soft-deleted rows: an id that never named a target
|
|
// is a different outcome, and a different message, from one the
|
|
// operator deleted.
|
|
_, orphan := seedFailedDelivery(
|
|
t, dbMgr, wh.ID, "target-that-never-existed",
|
|
)
|
|
|
|
missing := postReplay(t, h, sess, wh.ID, orphan.ID)
|
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=target-missing",
|
|
missing.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
// TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight proves
|
|
// the replay-storm guard: a second replay of the same event to the
|
|
// same target is refused while the first is still queued, so repeated
|
|
// submissions cannot stack copies of work the engine has not done.
|
|
func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|
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":"`+replayTargetURL+`"}`,
|
|
)
|
|
|
|
event, original := seedFailedDelivery(
|
|
t, dbMgr, wh.ID, tgt.ID,
|
|
)
|
|
|
|
first := postReplay(t, h, sess, wh.ID, original.ID)
|
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
|
require.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=queued",
|
|
first.Header().Get("Location"),
|
|
)
|
|
|
|
second := postReplay(t, h, sess, wh.ID, original.ID)
|
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=in-flight",
|
|
second.Header().Get("Location"),
|
|
)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(
|
|
t, listDeliveries(t, webhookDB, event.ID), 2,
|
|
"the refused second replay must add nothing",
|
|
)
|
|
assert.Len(
|
|
t, notif.Tasks(), 1,
|
|
"only the first replay reaches the delivery engine",
|
|
)
|
|
|
|
// A delivery the engine has not finished is not replayable
|
|
// either, which is the same rule seen from the other side.
|
|
queued := theOtherDelivery(
|
|
t, listDeliveries(t, webhookDB, event.ID), original.ID,
|
|
)
|
|
|
|
pending := postReplay(t, h, sess, wh.ID, queued.ID)
|
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
|
assert.Equal(
|
|
t,
|
|
"/source/"+wh.ID+"/logs?replay=not-terminal",
|
|
pending.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
|
// reaches the page it belongs on: a finished delivery renders a POST
|
|
// form carrying a CSRF token, and the outcome code a refusal redirects
|
|
// with becomes a readable message.
|
|
func TestHandleSourceLogs_RendersReplayControlAndBanner(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)
|
|
tgt := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+replayTargetURL+`"}`,
|
|
)
|
|
|
|
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
|
|
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(
|
|
t, body,
|
|
`action="/source/`+wh.ID+`/deliveries/`+
|
|
original.ID+`/replay"`,
|
|
)
|
|
assert.Contains(t, body, `method="POST"`)
|
|
assert.Contains(t, body, `name="csrf_token"`)
|
|
assert.Contains(t, body, ">Replay<")
|
|
|
|
refused := renderSourceLogsPageWithQuery(
|
|
t, h, sess, wh.ID, "?replay=target-deleted",
|
|
)
|
|
|
|
assert.Contains(t, refused, "alert-error")
|
|
assert.Contains(t, refused, "has been deleted")
|
|
|
|
// An outcome code nobody issued renders no banner at all.
|
|
unknown := renderSourceLogsPageWithQuery(
|
|
t, h, sess, wh.ID, "?replay=made-up",
|
|
)
|
|
|
|
assert.NotContains(t, unknown, "alert-error")
|
|
assert.NotContains(t, unknown, "alert-success")
|
|
assert.NotContains(t, unknown, "made-up")
|
|
}
|