Files
webhooker/internal/handlers/delivery_replay_test.go
T
clawbot 46debb60d5
check / check (push) Waiting to run
Build every database's schema from embedded SQL files (closes #56)
The main database, each webhook's event database and each archive file
get their schema from SQL files under internal/db/migrations/, one
directory per kind. Opening a file runs, in one transaction, each file
that schema_migrations does not list. GORM stays for queries.
AutoMigrate is gone, tests included.

An event database holds only its own tables now, so the sweep that
removed target rows copied into it is deleted. The association guard
stays: without it a delivery write fails on the missing targets table.

AutoMigrate also had GORM parse every model before a handle was shared.
That is now done on its own: GORM races when two goroutines first use
models that refer to each other.

Model: opus-5-5
2026-10-13 03:05:22 +00:00

650 lines
17 KiB
Go

package handlers_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"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"
// replayEventQuery is the query string a seeded event arrived with.
const replayEventQuery = "a=1&b=2"
// 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,
RawQuery: replayEventQuery,
Headers: `{"X-Test":["yes"]}`,
Body: `{"replay":"me"}`,
ContentType: contentTypeJSON,
}
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(
"/hook/"+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, &notif)
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,
"/hook/"+wh.ID+"/events?notice=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, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
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: 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()
assert.False(
t, webhookDB.Migrator().HasTable(&database.Target{}),
"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, &notif)
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,
"/hook/"+wh.ID+"/events?notice=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,
"/hook/"+wh.ID+"/events?notice=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, &notif)
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,
"/hook/"+wh.ID+"/events?notice=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,
"/hook/"+wh.ID+"/events?notice=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,
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
pending.Header().Get("Location"),
)
}
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
// Replay pressed in the Failed list carries that list in its form and
// returns to it, and that a show value the event log does not know
// returns to the full log.
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
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)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?show=failed",
), `name="show" value="failed"`)
// The second replay is refused, as the first is still queued.
for _, tc := range []struct{ show, location string }{
{"failed", "/hook/" + wh.ID +
"/events?notice=replay-queued&show=failed"},
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
} {
req := postRequest(
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
map[string]string{
paramSourceID: wh.ID,
paramDeliveryID: original.ID,
},
)
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleDeliveryReplay().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
}
}
// 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="/hook/`+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, "?notice=replay-target-deleted",
)
assert.Contains(t, refused, "alert-error")
assert.Contains(
t, refused,
"has been deleted. Use Resubmit to send the event "+
"to the webhook",
)
// An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?notice=made-up",
)
assert.NotContains(t, unknown, "alert-error")
assert.NotContains(t, unknown, "alert-success")
assert.NotContains(t, unknown, "made-up")
}
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
// by Replay is labelled as a replay in the event's summary line in the
// event log, and in the list of the event's deliveries there and on
// the event's page, while the delivery it repeats is not.
func TestHandleDeliveryReplay_LabelsTheReplay(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+`"}`,
)
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
w := postReplay(t, h, sess, wh.ID, original.ID)
require.Equal(t, http.StatusSeeOther, w.Code)
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, eventLog, tgt.Name+": failed")
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
w = serveEventPage(t, h, sess, wh.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
// In each delivery list a row names the target, then the label if
// it is a replay, then its status: the replay is still pending, the
// original failed.
replayRow := tgt.Name + `</span> ` +
`<span class="text-xs text-gray-500">replay</span> ` +
`<span class="text-xs text-gray-400">pending</span>`
originalRow := tgt.Name + `</span> ` +
`<span class="text-xs text-red-600">failed</span>`
for _, page := range []string{eventLog, w.Body.String()} {
page = strings.Join(strings.Fields(page), " ")
assert.Contains(t, page, replayRow)
assert.Contains(t, page, originalRow)
}
}