package handlers_test import ( "net/http" "net/http/httptest" "regexp" "strings" "testing" "time" "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/handlers" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" ) // failedHighlight is how the list marks a number of failed deliveries // that is not zero. const failedHighlight = `class="font-medium text-red-600"` // listWebhook adds a webhook with the given name, owned by the test // user. func listWebhook( t *testing.T, db *database.Database, name string, ) *database.Webhook { t.Helper() wh := &database.Webhook{UserID: deleteTestUserID, Name: name} require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) return wh } // renderWebhookList runs the real webhook list handler as the test user // and returns the rendered page. func renderWebhookList( t *testing.T, h *handlers.Handlers, sess *session.Session, ) string { t.Helper() cookies := authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ) w := httptest.NewRecorder() h.HandleSourceList().ServeHTTP( w, getRequest(t, "/hooks", cookies, nil), ) require.Equal(t, http.StatusOK, w.Code) return w.Body.String() } // listCard returns one webhook's entry in a rendered webhook list, its // markup as rendered and its text with the markup taken out and each // run of space made one space. func listCard(t *testing.T, page, webhookID string) (string, string) { t.Helper() _, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`) require.True(t, found, "the list has no entry for %s", webhookID) card, _, _ = strings.Cut(card, "") text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ") return card, strings.Join(strings.Fields(text), " ") } // receiveEvents posts the given number of events to an entrypoint // through the real receiver, and returns the webhook's event database // and its events, oldest first. func receiveEvents( t *testing.T, h *handlers.Handlers, dbMgr *database.WebhookDBManager, webhookID, path string, count int, ) (*gorm.DB, []database.Event) { t.Helper() router := receiverRouter(h) for range count { require.Equal(t, http.StatusOK, postReceiver(t, router, path)) } webhookDB, err := dbMgr.GetDB(webhookID) require.NoError(t, err) events := listEvents(t, webhookDB) require.Len(t, events, count) return webhookDB, events } // seedFailingWebhook adds a webhook with two entrypoints, one inactive, // and four targets, one inactive. Three events each reach the three // active targets. Two deliveries failed in the last 24 hours, one 30 // hours ago, and one was delivered. It returns the webhook and its // newest event. func seedFailingWebhook( t *testing.T, h *handlers.Handlers, db *database.Database, dbMgr *database.WebhookDBManager, ) (*database.Webhook, database.Event) { t.Helper() wh := listWebhook(t, db, "failing") path := statsEntrypoint(t, db, wh.ID, true) statsEntrypoint(t, db, wh.ID, false) first := seedTarget(t, db, wh.ID, database.TargetTypeLog) second := seedTarget(t, db, wh.ID, database.TargetTypeLog) 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) webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3) now := time.Now() statsFinish(t, webhookDB, statsDelivery(t, webhookDB, events[0].ID, first.ID), database.DeliveryStatusFailed, now.Add(-30*time.Hour)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, events[1].ID, first.ID), database.DeliveryStatusFailed, now.Add(-time.Hour)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, events[2].ID, first.ID), database.DeliveryStatusFailed, now.Add(-time.Minute)) statsFinish(t, webhookDB, statsDelivery(t, webhookDB, events[2].ID, second.ID), database.DeliveryStatusDelivered, now.Add(-time.Minute)) return wh, events[2] } // seedHealthyWebhook adds a webhook with one entrypoint and one target, // both active, and two events, both delivered. It returns the webhook // and its newest event. func seedHealthyWebhook( t *testing.T, h *handlers.Handlers, db *database.Database, dbMgr *database.WebhookDBManager, ) (*database.Webhook, database.Event) { t.Helper() wh := listWebhook(t, db, "healthy") path := statsEntrypoint(t, db, wh.ID, true) target := seedTarget(t, db, wh.ID, database.TargetTypeLog) webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2) for _, ev := range events { statsFinish(t, webhookDB, statsDelivery(t, webhookDB, ev.ID, target.ID), database.DeliveryStatusDelivered, time.Now()) } return wh, events[1] } // lastEventText is how the list shows the arrival of an event. func lastEventText(ev database.Event) string { return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC") } // TestSourceList_ShowsActivityOfEachWebhook checks the figures the list // shows for a webhook with recent failures, a healthy one, a new one // that has received no event, and one without an event database. func TestSourceList_ShowsActivityOfEachWebhook(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) failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr) healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr) // Creating a webhook creates its event database. fresh := listWebhook(t, db, "fresh") require.NoError(t, dbMgr.CreateDB(fresh.ID)) quiet := listWebhook(t, db, "quiet") page := renderWebhookList(t, h, sess) card, text := listCard(t, page, failing.ID) assert.Contains(t, text, "2 entrypoints, 1 inactive "+ "4 targets, 1 inactive "+ "3 events within retention "+ "Last event "+lastEventText(failingNewest)+" "+ "2 failed deliveries in the last 24 hours") assert.Contains(t, card, failedHighlight+">2 failed deliveries in the last 24 hours<") card, text = listCard(t, page, healthy.ID) assert.Contains(t, text, "1 entrypoint "+ "1 target "+ "2 events within retention "+ "Last event "+lastEventText(healthyNewest)+" "+ "0 failed deliveries in the last 24 hours") assert.NotContains(t, text, "inactive") assert.NotContains(t, card, failedHighlight) card, text = listCard(t, page, fresh.ID) assert.Contains(t, text, "0 entrypoints "+ "0 targets "+ "0 events within retention "+ "No events yet "+ "0 failed deliveries in the last 24 hours") assert.NotContains(t, card, failedHighlight) card, text = listCard(t, page, quiet.ID) assert.Contains(t, text, "0 entrypoints "+ "0 targets "+ "0 events within retention "+ "No events yet "+ "0 failed deliveries in the last 24 hours") assert.NotContains(t, card, failedHighlight) assert.False(t, dbMgr.DBExists(quiet.ID), "showing the list must not create an event database") } // TestSourceList_CountsOnlyEventsWithinRetention checks that once // retention has removed one of a webhook's three events, the list // counts the two still stored. func TestSourceList_CountsOnlyEventsWithinRetention(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: 14, } require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error) path := statsEntrypoint(t, db, wh.ID, true) webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3) statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour)) statsPrune(t, db, dbMgr, log, webhookDB) require.Len(t, listEvents(t, webhookDB), 2) _, text := listCard(t, renderWebhookList(t, h, sess), wh.ID) assert.Contains(t, text, "1 entrypoint 0 targets 2 events within retention") } // TestSourceList_UnreadableEventDatabase checks that a webhook whose // event database cannot be read says so in its entry instead of // showing zeros, and that the rest of the list is still shown. func TestSourceList_UnreadableEventDatabase(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) broken := listWebhook(t, db, "broken") statsEntrypoint(t, db, broken.ID, true) brokenDB, err := dbMgr.GetDB(broken.ID) require.NoError(t, err) require.NoError(t, brokenDB.Migrator().DropTable(&database.EventTotals{})) quiet := listWebhook(t, db, "quiet") page := renderWebhookList(t, h, sess) _, text := listCard(t, page, broken.ID) assert.Contains(t, text, "1 entrypoint 0 targets The event figures could not be read.") assert.NotContains(t, text, "events") assert.NotContains(t, text, "failed") _, text = listCard(t, page, quiet.ID) assert.Contains(t, text, "No events yet") }