check / check (push) Successful in 3m18s
The event log had no way to list only the events whose delivery failed, and once it showed only the 50 newest, an older failure could not be found at all. It now has All, Failed (N) and Pending (N) links, carried in a `show` query parameter, so they work without the page's script library. Each filtered list keeps the 50-row limit and newest-first order, and lists an event once. It finds matching deliveries through `idx_deliveries_status` and looks their events up by ID, so its cost follows the matches, not the webhook's size. Replay returns to the list it was pressed in. The heading line says what a filter counts. Model: opus-5-5
358 lines
10 KiB
Go
358 lines
10 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// contentTypeJSON is the content type the seeded events in this
|
|
// package carry. Shared across the seed helpers so the literal
|
|
// appears once.
|
|
const contentTypeJSON = "application/json"
|
|
|
|
// seedDeliveredEvent records an event and a delivery for it in
|
|
// the webhook's own database, so the log page has a delivery
|
|
// to render against the target.
|
|
func seedDeliveredEvent(
|
|
t *testing.T,
|
|
dbMgr *database.WebhookDBManager,
|
|
webhookID, targetID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
Method: http.MethodPost,
|
|
Body: `{"test":true}`,
|
|
ContentType: contentTypeJSON,
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: database.DeliveryStatusDelivered,
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(dlv).Error)
|
|
}
|
|
|
|
// renderSourceLogsPage runs the real event log handler for a
|
|
// webhook and returns the rendered HTML.
|
|
func renderSourceLogsPage(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
return renderSourceLogsPageWithQuery(
|
|
t, h, sess, webhookID, "",
|
|
)
|
|
}
|
|
|
|
// renderSourceLogsPageWithQuery is renderSourceLogsPage over a
|
|
// caller-supplied query string, for the page state a redirect back to
|
|
// the log carries in one.
|
|
func renderSourceLogsPageWithQuery(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID, query string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
"/hook/"+webhookID+"/events"+query,
|
|
nil,
|
|
)
|
|
|
|
for _, c := range authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
) {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add(paramSourceID, webhookID)
|
|
|
|
req = req.WithContext(
|
|
context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
),
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleSourceLogs().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
return w.Body.String()
|
|
}
|
|
|
|
// TestHandleSourceLogs_MasksSlackWebhookURL proves the event
|
|
// log page is handed a display-safe projection of each target
|
|
// rather than the stored row, so the credential cannot be
|
|
// rendered from its template data.
|
|
func TestHandleSourceLogs_MasksSlackWebhookURL(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.TargetTypeSlack,
|
|
`{"webhookUrl":"`+slackWebhookURL+`"}`,
|
|
)
|
|
|
|
seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID)
|
|
|
|
body := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.NotContains(t, body, "T00000000")
|
|
assert.NotContains(t, body, "B00000000")
|
|
assert.NotContains(
|
|
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
)
|
|
assert.NotContains(t, body, "webhookUrl")
|
|
|
|
// The page still identifies the delivery's target.
|
|
assert.Contains(t, body, tgt.Name)
|
|
assert.Contains(t, body, "delivered")
|
|
}
|
|
|
|
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
|
|
// holds the 50 newest events, newest first, and not one more, and says
|
|
// how many events there are in all.
|
|
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
base := time.Now().Add(-time.Hour)
|
|
|
|
for i := range 51 {
|
|
f.event(
|
|
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
|
|
base.Add(time.Duration(i)*time.Second),
|
|
)
|
|
}
|
|
|
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
|
|
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
|
assert.NotContains(t, body, "application/x-log-00")
|
|
assert.Contains(t, body, "application/x-log-01")
|
|
assert.Less(
|
|
t,
|
|
strings.Index(body, "application/x-log-50"),
|
|
strings.Index(body, "application/x-log-49"),
|
|
)
|
|
assert.Contains(t, body, "50 most recent of 51 events")
|
|
}
|
|
|
|
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
|
|
// Failed list holds exactly the events with a failed delivery, the
|
|
// Pending list exactly those with a delivery pending or retrying, each
|
|
// once, and any other show value every event; that each link, and the
|
|
// line beside the heading, counts the events its list holds; and that
|
|
// the shown link is marked.
|
|
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
now := time.Now()
|
|
|
|
const (
|
|
failed = database.DeliveryStatusFailed
|
|
delivered = database.DeliveryStatusDelivered
|
|
pending = database.DeliveryStatusPending
|
|
retrying = database.DeliveryStatusRetrying
|
|
)
|
|
|
|
// Each event is named by its content type. The first failed and
|
|
// was then replayed and delivered. The second failed, and so did
|
|
// its replay, and the fifth has one delivery pending and another
|
|
// retrying: each must still be listed and counted once.
|
|
events := []struct {
|
|
contentType string
|
|
deliveries []database.DeliveryStatus
|
|
}{
|
|
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
|
|
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
|
|
{"application/x-pending", []database.DeliveryStatus{pending}},
|
|
{"application/x-retrying", []database.DeliveryStatus{retrying}},
|
|
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
|
|
{"application/x-delivered", []database.DeliveryStatus{delivered}},
|
|
{"application/x-no-delivery", nil},
|
|
}
|
|
|
|
all := make([]string, len(events))
|
|
|
|
for i, e := range events {
|
|
event := f.event(
|
|
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
|
|
)
|
|
|
|
for _, status := range e.deliveries {
|
|
f.delivery(t, event, target.ID, status)
|
|
}
|
|
|
|
all[i] = e.contentType
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
query string
|
|
current string
|
|
heading string
|
|
listed []string
|
|
}{
|
|
{"", "All", "7 total events", all},
|
|
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
|
|
[]string{"application/x-failed", "application/x-failed-twice"}},
|
|
{"?show=pending", "Pending (3)",
|
|
"3 events with a delivery pending or retrying", []string{
|
|
"application/x-pending", "application/x-retrying",
|
|
"application/x-pending-retrying",
|
|
}},
|
|
{"?show=unknown", "All", "7 total events", all},
|
|
} {
|
|
body := renderSourceLogsPageWithQuery(
|
|
t, f.h, f.sess, f.webhook.ID, tc.query,
|
|
)
|
|
|
|
// One row per listed event, so with each listed event shown
|
|
// no event is listed twice.
|
|
assert.Equal(t, len(tc.listed),
|
|
strings.Count(body, `role="button"`), tc.query)
|
|
|
|
for _, contentType := range all {
|
|
assert.Equal(t,
|
|
slices.Contains(tc.listed, contentType),
|
|
strings.Contains(body, ">"+contentType+"<"),
|
|
tc.query+" "+contentType)
|
|
}
|
|
|
|
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
|
|
assert.Contains(t, body, "Failed (2)", tc.query)
|
|
assert.Contains(t, body, "Pending (3)", tc.query)
|
|
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
|
|
assert.Contains(t, body,
|
|
`aria-current="page">`+tc.current+"</a>", tc.query)
|
|
}
|
|
}
|
|
|
|
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
|
|
// list holds the 50 newest matching events, as the full log does,
|
|
// while its link and heading count every matching event.
|
|
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
base := time.Now().Add(-time.Hour)
|
|
|
|
for i := range 51 {
|
|
event := f.event(
|
|
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
|
|
base.Add(time.Duration(i)*time.Second),
|
|
)
|
|
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
|
|
}
|
|
|
|
// The newest event has no failed delivery.
|
|
f.event(t, "application/x-no-delivery", "{}", time.Now())
|
|
|
|
body := renderSourceLogsPageWithQuery(
|
|
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
|
)
|
|
|
|
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
|
assert.NotContains(t, body, "application/x-failed-00")
|
|
assert.NotContains(t, body, "application/x-no-delivery")
|
|
assert.Contains(t, body, "Failed (51)")
|
|
assert.Contains(t, body,
|
|
"50 most recent of 51 events with a failed delivery")
|
|
}
|
|
|
|
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
|
|
// says that no event matches rather than that none was recorded.
|
|
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
|
|
f.delivery(
|
|
t, f.event(t, contentTypeJSON, "{}", time.Now()),
|
|
target.ID, database.DeliveryStatusDelivered,
|
|
)
|
|
|
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
|
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
|
), "No event has a failed delivery.")
|
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
|
t, f.h, f.sess, f.webhook.ID, "?show=pending",
|
|
), "No event has a delivery pending or retrying.")
|
|
}
|
|
|
|
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
|
|
// events in the log only the newest starts expanded.
|
|
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
now := time.Now()
|
|
|
|
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
|
|
f.event(t, "application/x-newer", "{}", now)
|
|
|
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
|
|
|
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
|
|
|
open := strings.Index(body, " data-open>")
|
|
newer := strings.Index(body, "application/x-newer")
|
|
older := strings.Index(body, "application/x-older")
|
|
|
|
assert.Less(t, open, newer, "the newest event is not the open one")
|
|
assert.Less(t, newer, older)
|
|
}
|