package delivery_test import ( "net/http" "strings" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // storeTestTarget creates an active http target of the setup's webhook // with every one of a target's settings set, so a task that drops one // of them differs from the target. func storeTestTarget( t *testing.T, s iSetup, name string, ) database.Target { t.Helper() target := database.Target{ WebhookID: s.WebhookID, Name: name, Type: database.TargetTypeHTTP, Active: true, URL: "http://93.184.216.34/" + name, Headers: map[string]string{"X-Api-Key": "k"}, TimeoutSeconds: 7, ForwardQuery: true, ArchiveExpiry: time.Hour, ArchiveRotation: rotationDaily, MaxAttempts: 3, } require.NoError(t, s.MainDB.Create(&target).Error) return target } // newStoreTestEvent returns an event of the setup's webhook as the // receiver builds one, not yet stored. func newStoreTestEvent(s iSetup, body string) *database.Event { return &database.Event{ WebhookID: s.WebhookID, EntrypointID: uuid.New().String(), Method: http.MethodPost, RawQuery: "a=1&b=2", Headers: `{"X-Test":["yes"]}`, ContentType: testContentType, Body: body, } } // queuedTasks takes every task the engine has queued so far. The // engine in these tests is never started, so nothing else takes them. func queuedTasks(e *delivery.Engine) []delivery.Task { var tasks []delivery.Task for { select { case task := <-e.ExportDeliveryCh(): tasks = append(tasks, task) default: return tasks } } } // assertTaskFor proves task carries delivery deliveryID of event to // target: every one of the target's settings and every event field. // They are copied into the task one field at a time, and a field left // out is sent empty without any error. func assertTaskFor( t *testing.T, task delivery.Task, deliveryID string, event *database.Event, target *database.Target, ) { t.Helper() assert.Equal(t, deliveryID, task.DeliveryID) assert.Equal(t, event.ID, task.EventID) assert.Equal(t, event.WebhookID, task.WebhookID) assert.Equal(t, event.EntrypointID, task.EntrypointID) assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.Name, task.TargetName) assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, target.URL, task.TargetURL) assert.Equal(t, target.Headers, task.TargetHeaders) assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds) assert.Equal(t, target.ForwardQuery, task.ForwardQuery) assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry) assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation) assert.Equal(t, target.MaxAttempts, task.MaxAttempts) assert.Equal(t, event.Method, task.Method) assert.Equal(t, event.RawQuery, task.RawQuery) assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.ContentType, task.ContentType) require.NotNil(t, task.Body) assert.Equal(t, event.Body, *task.Body) assert.Equal(t, 1, task.AttemptNum) } // storedDeliveries reads every delivery of an event. func storedDeliveries( t *testing.T, db *gorm.DB, eventID string, ) []database.Delivery { t.Helper() var deliveries []database.Delivery require.NoError(t, db.Where("event_id = ?", eventID). Find(&deliveries).Error) return deliveries } // storedEventTotals reads the webhook's event totals. func storedEventTotals(t *testing.T, db *gorm.DB) database.EventTotals { t.Helper() var totals database.EventTotals require.NoError(t, db.First(&totals).Error) return totals } // countRows counts the rows of model in db. func countRows(t *testing.T, db *gorm.DB, model any) int64 { t.Helper() var n int64 require.NoError(t, db.Model(model).Count(&n).Error) return n } // TestStoreEvent_StoresDeliveriesToActiveTargets proves StoreEvent // stores the event with one pending delivery to each active target and // none to an inactive one, adds them to the totals, and queues one task // per delivery. func TestStoreEvent_StoresDeliveriesToActiveTargets(t *testing.T) { t.Parallel() s := newISetup(t) targets := []database.Target{ storeTestTarget(t, s, "first"), storeTestTarget(t, s, "second"), } off := storeTestTarget(t, s, "off") require.NoError(t, s.MainDB.Model(&database.Target{}). Where("id = ?", off.ID).Update("active", false).Error) event := newStoreTestEvent(s, `{"hello":"world"}`) created, err := s.Engine.StoreEvent(event) require.NoError(t, err) assert.Equal(t, 2, created) var stored database.Event require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error) assert.Equal(t, event.Body, stored.Body) assert.Equal(t, int64(len(event.Body)), stored.BodyBytes) rows := storedDeliveries(t, s.WebhookDB, event.ID) queued := queuedTasks(s.Engine) require.Len(t, rows, 2) require.Len(t, queued, 2) deliveries := map[string]database.Delivery{} for _, dlv := range rows { deliveries[dlv.TargetID] = dlv } tasks := map[string]delivery.Task{} for _, task := range queued { tasks[task.TargetID] = task } for i := range targets { dlv, ok := deliveries[targets[i].ID] require.True(t, ok, "no delivery to %s", targets[i].Name) assert.Equal(t, database.DeliveryStatusPending, dlv.Status) assert.False(t, dlv.Replay) assert.Equal(t, int64(1), targetTotals(t, s.WebhookDB, targets[i].ID).Deliveries) assertTaskFor(t, tasks[targets[i].ID], dlv.ID, event, &targets[i]) } totals := storedEventTotals(t, s.WebhookDB) assert.Equal(t, int64(1), totals.Events) require.NotNil(t, totals.LastEventAt) assert.True(t, totals.LastEventAt.Equal(stored.CreatedAt)) var entrypoint database.EntrypointTotals require.NoError(t, s.WebhookDB.First( &entrypoint, "entrypoint_id = ?", event.EntrypointID, ).Error) assert.True(t, entrypoint.LastEventAt.Equal(stored.CreatedAt)) } // TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint proves a // resubmitted copy is counted among the webhook's events but leaves its // entrypoint's last event as it is: it did not arrive on that URL. func TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint(t *testing.T) { t.Parallel() s := newISetup(t) storeTestTarget(t, s, "only") sourceID := uuid.New().String() event := newStoreTestEvent(s, `{"copy":true}`) event.ResubmittedFromID = &sourceID created, err := s.Engine.StoreEvent(event) require.NoError(t, err) assert.Equal(t, 1, created) assert.Len(t, queuedTasks(s.Engine), 1) assert.Equal(t, int64(1), storedEventTotals(t, s.WebhookDB).Events) assert.Zero(t, countRows(t, s.WebhookDB, &database.EntrypointTotals{})) } // TestStoreEvent_NoActiveTarget proves an event of a webhook with no // active target is stored with no delivery and nothing queued. func TestStoreEvent_NoActiveTarget(t *testing.T) { t.Parallel() s := newISetup(t) event := newStoreTestEvent(s, `{"to":"nobody"}`) created, err := s.Engine.StoreEvent(event) require.NoError(t, err) assert.Zero(t, created) assert.Equal(t, int64(1), countRows(t, s.WebhookDB, &database.Event{})) assert.Empty(t, storedDeliveries(t, s.WebhookDB, event.ID)) assert.Empty(t, queuedTasks(s.Engine)) } // TestStoreEvent_LargeBodyIsNotCarried proves a body at the inline // limit is stored whole but left off the task, for the attempt to read // from the event row. func TestStoreEvent_LargeBodyIsNotCarried(t *testing.T) { t.Parallel() s := newISetup(t) storeTestTarget(t, s, "only") event := newStoreTestEvent( s, strings.Repeat("x", delivery.MaxInlineBodySize), ) _, err := s.Engine.StoreEvent(event) require.NoError(t, err) tasks := queuedTasks(s.Engine) require.Len(t, tasks, 1) assert.Nil(t, tasks[0].Body) var stored database.Event require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error) assert.Equal(t, event.Body, stored.Body) } // TestStoreEvent_RollsBackWhenAWriteFails proves the event, its // deliveries and its totals are one transaction: when the entrypoint // totals write fails, the last one, after the event, its delivery, the // target totals and the event totals were written, none of them is left // and nothing is queued. func TestStoreEvent_RollsBackWhenAWriteFails(t *testing.T) { t.Parallel() s := newISetup(t) storeTestTarget(t, s, "only") require.NoError(t, s.WebhookDB.Migrator().DropTable(&database.EntrypointTotals{})) _, err := s.Engine.StoreEvent(newStoreTestEvent(s, `{"lost":true}`)) require.Error(t, err) assert.Zero(t, countRows(t, s.WebhookDB, &database.Event{})) assert.Zero(t, countRows(t, s.WebhookDB, &database.Delivery{})) assert.Zero(t, countRows(t, s.WebhookDB, &database.TargetTotals{})) totals := storedEventTotals(t, s.WebhookDB) assert.Zero(t, totals.Events) assert.Nil(t, totals.LastEventAt) assert.Empty(t, queuedTasks(s.Engine)) assert.Zero(t, s.Engine.ExportInflightHeld()) } // TestCreateReplay_CreatesAndQueuesAReplay proves CreateReplay adds a // new pending delivery marked as a replay, counts it in the target's // totals, queues its task built from the target it was given and the // stored event, and leaves the delivery it repeats as it was. The // stored event has every event field set, so a task that drops one of // them differs from the event. func TestCreateReplay_CreatesAndQueuesAReplay(t *testing.T) { t.Parallel() s := newISetup(t) target := storeTestTarget(t, s, "replayed") event := newStoreTestEvent(s, `{"replay":"me"}`) require.NoError(t, s.WebhookDB.Create(event).Error) original := iSeedDelivery( t, s.WebhookDB, event.ID, target.ID, database.DeliveryStatusFailed, ) deliveryID, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target) require.NoError(t, err) replay := storedDelivery(t, s.WebhookDB, deliveryID) assert.Equal(t, event.ID, replay.EventID) assert.Equal(t, target.ID, replay.TargetID) assert.Equal(t, database.DeliveryStatusPending, replay.Status) assert.True(t, replay.Replay) assert.Equal(t, database.DeliveryStatusFailed, storedDelivery(t, s.WebhookDB, original.ID).Status) assert.Equal(t, int64(1), targetTotals(t, s.WebhookDB, target.ID).Deliveries) tasks := queuedTasks(s.Engine) require.Len(t, tasks, 1) assertTaskFor(t, tasks[0], deliveryID, event, &target) } // TestCreateReplay_RollsBackWhenAWriteFails proves the replay delivery // and its target totals are one transaction: when the totals write // fails, after the delivery was written, no new delivery is left and // nothing is queued. func TestCreateReplay_RollsBackWhenAWriteFails(t *testing.T) { t.Parallel() s := newISetup(t) target := storeTestTarget(t, s, "replayed") event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"replay":"me"}`) iSeedDelivery( t, s.WebhookDB, event.ID, target.ID, database.DeliveryStatusFailed, ) require.NoError(t, s.WebhookDB.Migrator().DropTable(&database.TargetTotals{})) _, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target) require.Error(t, err) assert.Len(t, storedDeliveries(t, s.WebhookDB, event.ID), 1) assert.Empty(t, queuedTasks(s.Engine)) assert.Zero(t, s.Engine.ExportInflightHeld()) } // storedDelivery reads one delivery back. func storedDelivery( t *testing.T, db *gorm.DB, deliveryID string, ) database.Delivery { t.Helper() var dlv database.Delivery require.NoError(t, db.First(&dlv, "id = ?", deliveryID).Error) return dlv }