package handlers_test import ( "net/http" "regexp" "strings" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx/fxtest" "gorm.io/gorm" "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" ) // statsEntrypoint adds an entrypoint to a webhook and returns its path. func statsEntrypoint( t *testing.T, db *database.Database, webhookID string, active bool, ) string { t.Helper() ep := &database.Entrypoint{ WebhookID: webhookID, Path: uuid.New().String(), } require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error) require.NoError(t, db.DB().Model(ep).Update("active", active).Error) return ep.Path } // statsDelivery returns an event's delivery to a target. func statsDelivery( t *testing.T, webhookDB *gorm.DB, eventID, targetID string, ) database.Delivery { t.Helper() var d database.Delivery require.NoError(t, webhookDB.Where( "event_id = ? AND target_id = ?", eventID, targetID, ).First(&d).Error) return d } // statsFinish settles a delivery as the delivery engine does: its // final status and the time it finished, and one more on its target's // delivered or failed total, in one transaction. func statsFinish( t *testing.T, webhookDB *gorm.DB, d database.Delivery, status database.DeliveryStatus, at time.Time, ) { t.Helper() add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1} if status == database.DeliveryStatusFailed { add = database.TargetTotals{TargetID: d.TargetID, Failed: 1} } require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error { err := tx.Model(&database.Delivery{}). Where("id = ?", d.ID). Updates(map[string]any{"status": status, "finished_at": at}). Error if err != nil { return err } return database.AddTargetTotals(tx, add) })) } // statsAge moves an event's arrival back to the given time. func statsAge( t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time, ) { t.Helper() require.NoError(t, webhookDB.Model(&database.Event{}). Where("id = ?", eventID). Update("created_at", at).Error) } // statsTargetTotals reads a webhook database's target totals, keyed by // target. func statsTargetTotals( t *testing.T, webhookDB *gorm.DB, ) map[string]database.TargetTotals { t.Helper() var rows []database.TargetTotals require.NoError(t, webhookDB.Find(&rows).Error) byTarget := make(map[string]database.TargetTotals, len(rows)) for _, row := range rows { byTarget[row.TargetID] = row } return byTarget } // statsHistory is the webhook seedStatsHistory builds: its event // database, its newest event, and its two active targets. type statsHistory struct { webhook *database.Webhook webhookDB *gorm.DB newest database.Event first, second string } // seedStatsHistory builds the webhook the statistics test checks: one // day of retention, five entrypoints (one inactive) and three targets // (one inactive). Six events arrive through the receiver, and so each // has a delivery to the two active targets. The oldest event is past // retention, the next six hours old, the other four just in. Five // deliveries are settled as the delivery engine would, the rest left // pending, and a replay adds a pending delivery to the oldest event. // Once retention has removed the oldest event, no two figures in the // pane's top row, or in the same table, are equal. func seedStatsHistory( t *testing.T, h *handlers.Handlers, sess *session.Session, db *database.Database, dbMgr *database.WebhookDBManager, ) statsHistory { t.Helper() wh := &database.Webhook{ UserID: deleteTestUserID, Name: "stats", RetentionDays: 1, } require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) path := statsEntrypoint(t, db, wh.ID, true) for range 3 { statsEntrypoint(t, db, wh.ID, true) } statsEntrypoint(t, db, wh.ID, false) first := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, `{"url":"`+replayTargetURL+`"}`, ) second := seedTarget(t, db, wh.ID, database.TargetTypeLog) inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog) require.NoError(t, db.DB().Model(inactive). Update("active", false).Error) router := receiverRouter(h) for range 6 { require.Equal(t, http.StatusOK, postReceiver(t, router, path)) } webhookDB, err := dbMgr.GetDB(wh.ID) require.NoError(t, err) events := listEvents(t, webhookDB) require.Len(t, events, 6) oldest, middle, newest := events[0], events[1], events[5] now := time.Now() statsAge(t, webhookDB, oldest.ID, now.Add(-50*time.Hour)) statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour)) oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID) statsFinish(t, webhookDB, oldestFailure, database.DeliveryStatusFailed, now.Add(-49*time.Hour)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, oldest.ID, second.ID), database.DeliveryStatusDelivered, now.Add(-49*time.Hour)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, middle.ID, first.ID), database.DeliveryStatusFailed, now.Add(-5*time.Hour)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, middle.ID, second.ID), database.DeliveryStatusFailed, now.Add(-time.Minute)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, newest.ID, first.ID), database.DeliveryStatusDelivered, now.Add(-2*time.Minute)) require.Equal(t, http.StatusSeeOther, postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code) return statsHistory{ webhook: wh, webhookDB: webhookDB, newest: newest, first: first.ID, second: second.ID, } } // statsPrune runs the real retention reaper until it has removed one // event from the webhook's database, then stops it. func statsPrune( t *testing.T, db *database.Database, dbMgr *database.WebhookDBManager, log *logger.Logger, webhookDB *gorm.DB, ) { t.Helper() lc := fxtest.NewLifecycle(t) database.NewRetentionReaper(lc, database.RetentionReaperParams{ Config: &config.Config{ RetentionSweepInterval: 10 * time.Millisecond, }, Database: db, DBManager: dbMgr, Logger: log, }) lc.RequireStart() require.Eventually(t, func() bool { var totals database.EventTotals err := webhookDB.Take(&totals).Error return err == nil && totals.EventsRemoved == 1 }, 10*time.Second, 10*time.Millisecond) lc.RequireStop() } // statsPane returns the text of the statistics pane in a rendered // webhook page, everything from its heading to the next heading on the // page, with the markup taken out and each run of space made one // space. A table then reads header by header and row by row, each // row's label followed by its figures in column order. func statsPane(t *testing.T, page string) string { t.Helper() _, pane, found := strings.Cut(page, ">Statistics") require.True(t, found, "the page has no statistics pane") pane, _, _ = strings.Cut(pane, "]*>`).ReplaceAllString(pane, " ") return strings.Join(strings.Fields(pane), " ") } // assertStatsTargets checks, for the history seedStatsHistory builds, // each target's totals and its deliveries finished in the last 24 // hours. The first target has six deliveries and the replay, the // second six; the inactive target has none and so no row. func assertStatsTargets(t *testing.T, hist statsHistory) { t.Helper() first, second := hist.first, hist.second assert.Equal(t, map[string]database.TargetTotals{ first: {TargetID: first, Deliveries: 7, Delivered: 1, Failed: 2}, second: { TargetID: second, Deliveries: 6, Delivered: 1, Failed: 1, }, }, statsTargetTotals(t, hist.webhookDB)) lastDay, err := handlers.FinishedByTargetForTest( hist.webhookDB, time.Now().Add(-24*time.Hour), ) require.NoError(t, err) assert.ElementsMatch(t, []handlers.TargetFinished{ {TargetID: first, Delivered: 1, Failed: 1}, {TargetID: second, Failed: 1}, }, lastDay) } // assertStatsPaneAfterPrune checks the rendered statistics pane for the // history seedStatsHistory builds, once retention has removed the // oldest event: each figure after its label, in its column. func assertStatsPaneAfterPrune( t *testing.T, h *handlers.Handlers, sess *session.Session, hist statsHistory, ) { t.Helper() pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID)) lastEvent := hist.newest.CreatedAt.Format("2006-01-02 15:04:05 UTC") assert.Contains(t, pane, "Entrypoints 5 (4 active) "+ "Targets 3 (2 active) "+ "Deliveries in progress 7 "+ "Last event "+lastEvent+" "+ "Retention 1 day") assert.Contains(t, pane, "Lifetime Within retention "+ "Events 6 5 "+ "Deliveries 13 10 "+ "Failures 3 2") assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+ "Events 4 5 "+ "Failures 1 2 "+ "Failure percentage 50.0% 66.7%") } // TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure // the statistics pane shows for the history seedStatsHistory builds, // and each target's totals and recent figures, before and after the // real retention reaper removes the oldest event. func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager log *logger.Logger ) app := newTestApp(t, &h, &sess, &db, &dbMgr, &log) app.RequireStart() t.Cleanup(app.RequireStop) hist := seedStatsHistory(t, h, sess, db, dbMgr) first, second := hist.first, hist.second stats := h.WebhookStatsForTest(hist.webhook.ID) require.NotNil(t, stats) assert.Equal(t, 5, stats.Entrypoints) assert.Equal(t, 4, stats.ActiveEntrypoints) assert.Equal(t, 3, stats.Targets) assert.Equal(t, 2, stats.ActiveTargets) assert.Equal(t, handlers.Counts{Events: 6, Deliveries: 13, Failures: 3}, stats.Lifetime) assert.Equal(t, stats.Lifetime, stats.WithinRetention) assert.Equal(t, int64(8), stats.InProgress) require.NotNil(t, stats.LastEventAt) assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt)) assert.Equal(t, handlers.RecentWindow{ Events: 4, Delivered: 1, Failed: 1, }, stats.Last10Minutes) assert.Equal(t, handlers.RecentWindow{ Events: 5, Delivered: 1, Failed: 2, }, stats.Last24Hours) assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent()) assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent()) assertStatsTargets(t, hist) // Retention removes the oldest event with its three deliveries: // the first target's failed one and the pending replay, and the // second target's delivered one. statsPrune(t, db, dbMgr, log, hist.webhookDB) after := h.WebhookStatsForTest(hist.webhook.ID) require.NotNil(t, after) assert.Equal(t, stats.Lifetime, after.Lifetime) assert.Equal(t, handlers.Counts{Events: 5, Deliveries: 10, Failures: 2}, after.WithinRetention) assert.Equal(t, int64(7), after.InProgress) assert.Equal(t, stats.LastEventAt, after.LastEventAt) assert.Equal(t, stats.Last10Minutes, after.Last10Minutes) assert.Equal(t, stats.Last24Hours, after.Last24Hours) assert.Equal(t, map[string]database.TargetTotals{ first: { TargetID: first, Deliveries: 7, Delivered: 1, Failed: 2, DeliveriesRemoved: 2, FailedRemoved: 1, }, second: { TargetID: second, Deliveries: 6, Delivered: 1, Failed: 1, DeliveriesRemoved: 1, }, }, statsTargetTotals(t, hist.webhookDB)) assertStatsPaneAfterPrune(t, h, sess, hist) } // TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once // retention has removed every event, the pane still shows when the last // one arrived rather than "none". func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager log *logger.Logger ) app := newTestApp(t, &h, &sess, &db, &dbMgr, &log) app.RequireStart() t.Cleanup(app.RequireStop) wh := &database.Webhook{ UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1, } require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) path := statsEntrypoint(t, db, wh.ID, true) require.Equal(t, http.StatusOK, postReceiver(t, receiverRouter(h), path)) webhookDB, err := dbMgr.GetDB(wh.ID) require.NoError(t, err) events := listEvents(t, webhookDB) require.Len(t, events, 1) arrived := events[0].CreatedAt statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour)) statsPrune(t, db, dbMgr, log, webhookDB) require.Empty(t, listEvents(t, webhookDB)) stats := h.WebhookStatsForTest(wh.ID) require.NotNil(t, stats) require.NotNil(t, stats.LastEventAt) assert.True(t, arrived.Equal(*stats.LastEventAt)) pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) assert.Contains(t, pane, "Last event "+arrived.Format("2006-01-02 15:04:05 UTC")) } // TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics // pane itself, not only the line at the foot of the page, shows the // webhook's retention period, for a finite one and for forever. func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database ) app := newTestApp(t, &h, &sess, &db) app.RequireStart() t.Cleanup(app.RequireStop) tests := []struct { retentionDays int want string }{ {30, "30 days"}, {database.RetentionForeverDays, "forever"}, } for _, tt := range tests { wh := &database.Webhook{ UserID: deleteTestUserID, Name: "retention", RetentionDays: tt.retentionDays, } require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) assert.Contains(t, pane, "Retention "+tt.want) } } // TestWebhookStats_WebhookWithNoEvents covers a webhook whose event // database has never been opened: every count is zero, the // percentages are a dash, and showing the page does not create the // database. func TestWebhookStats_WebhookWithNoEvents(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) assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID)) assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent()) pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) assert.Contains(t, pane, "Last event none") assert.Contains(t, pane, "Failure percentage — —") assert.False(t, dbMgr.DBExists(wh.ID)) } // TestRecentWindow_FailurePercent pins the percentage: failed // deliveries out of all that finished in the window. func TestRecentWindow_FailurePercent(t *testing.T) { t.Parallel() tests := []struct { window handlers.RecentWindow want string }{ {handlers.RecentWindow{}, "—"}, {handlers.RecentWindow{Events: 4}, "—"}, {handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"}, {handlers.RecentWindow{Failed: 2}, "100.0%"}, {handlers.RecentWindow{Delivered: 2}, "0.0%"}, } for _, tt := range tests { assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window) } }