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, 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", ) }