check / check (push) Waiting to run
Each row of the recent events on the webhook page links to the
event's own page, /hook/{id}/events/{eventID}, and expands to show its
body; only the newest starts expanded. The event's page shows its
details, its whole body and every delivery with its attempts.
One renderer, newBodyView with templates/event_body.html, shows a body
in all three places: whole up to 32 KiB, cut there in the lists with a
link to the event's page, JSON pretty-printed, a body of more than 200
lines or 32 KiB in a scrolling box, and a body that is not text left
out beside its download link. A resubmitted copy links to its
original's page.
Model: opus-5-5
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"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/session"
|
|
)
|
|
|
|
// statusTitle marks the status column's cell in a recent events
|
|
// row; it is absent from the page when the column is not shown.
|
|
const statusTitle = `title="HTTP status from the HTTP target"`
|
|
|
|
// recentEventsFixture is one started app and a webhook whose
|
|
// recent events list a test fills.
|
|
type recentEventsFixture struct {
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
webhook *database.Webhook
|
|
webhookDB *gorm.DB
|
|
}
|
|
|
|
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
|
t.Helper()
|
|
|
|
f := &recentEventsFixture{}
|
|
|
|
var dbMgr *database.WebhookDBManager
|
|
|
|
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
f.webhook = seedWebhook(t, f.db)
|
|
|
|
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
|
require.NoError(t, err)
|
|
|
|
f.webhookDB = webhookDB
|
|
|
|
return f
|
|
}
|
|
|
|
func (f *recentEventsFixture) render(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
}
|
|
|
|
// event records an event received at receivedAt, with its body's
|
|
// size as the receiver records it.
|
|
func (f *recentEventsFixture) event(
|
|
t *testing.T, contentType, body string, receivedAt time.Time,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
event := &database.Event{
|
|
WebhookID: f.webhook.ID,
|
|
Method: http.MethodPost,
|
|
Body: body,
|
|
BodyBytes: int64(len(body)),
|
|
ContentType: contentType,
|
|
}
|
|
event.CreatedAt = receivedAt
|
|
|
|
require.NoError(t, f.webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
return event
|
|
}
|
|
|
|
// delivery records a delivery of the event to the target, queued
|
|
// when the event was received.
|
|
func (f *recentEventsFixture) delivery(
|
|
t *testing.T,
|
|
event *database.Event,
|
|
targetID string,
|
|
status database.DeliveryStatus,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
return f.deliveryQueuedAt(
|
|
t, event, targetID, status, event.CreatedAt,
|
|
)
|
|
}
|
|
|
|
// deliveryQueuedAt records a delivery of the event to the target,
|
|
// queued at queuedAt, as a replay is.
|
|
func (f *recentEventsFixture) deliveryQueuedAt(
|
|
t *testing.T,
|
|
event *database.Event,
|
|
targetID string,
|
|
status database.DeliveryStatus,
|
|
queuedAt time.Time,
|
|
) *database.Delivery {
|
|
t.Helper()
|
|
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: status,
|
|
}
|
|
dlv.CreatedAt = queuedAt
|
|
|
|
require.NoError(t, f.webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(dlv).Error)
|
|
|
|
return dlv
|
|
}
|
|
|
|
// attempt records one attempt of the delivery that finished took
|
|
// after the delivery was queued, with HTTP status code (0 for no
|
|
// response).
|
|
func (f *recentEventsFixture) attempt(
|
|
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
|
) {
|
|
t.Helper()
|
|
|
|
result := &database.DeliveryResult{
|
|
DeliveryID: dlv.ID,
|
|
AttemptNum: 1,
|
|
StatusCode: code,
|
|
}
|
|
result.CreatedAt = dlv.CreatedAt.Add(took)
|
|
|
|
require.NoError(t, f.webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(result).Error)
|
|
}
|
|
|
|
// statusCell is the status column's cell as the page renders it.
|
|
func statusCell(class, text string) string {
|
|
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
|
`>` + text + `</span>`
|
|
}
|
|
|
|
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
|
|
// headed "50 Most Recent Events" and holds the 50 newest events,
|
|
// newest first, and not one more.
|
|
func TestHandleSourceDetail_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-recent-%02d", i), "{}",
|
|
base.Add(time.Duration(i)*time.Second),
|
|
)
|
|
}
|
|
|
|
body := f.render(t)
|
|
|
|
assert.Contains(t, body, ">50 Most Recent Events</h2>")
|
|
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
|
assert.NotContains(t, body, "application/x-recent-00")
|
|
assert.Contains(t, body, "application/x-recent-01")
|
|
assert.Less(
|
|
t,
|
|
strings.Index(body, "application/x-recent-50"),
|
|
strings.Index(body, "application/x-recent-49"),
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
|
// time relative with the UTC timestamp on hover, its body size,
|
|
// and its processing time once every delivery has finished.
|
|
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
|
|
receivedAt := time.Now().Add(-210 * time.Second).
|
|
UTC().Truncate(time.Second)
|
|
|
|
done := f.event(
|
|
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
|
)
|
|
f.attempt(
|
|
t,
|
|
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
|
0, 1500*time.Millisecond,
|
|
)
|
|
|
|
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
|
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
|
|
|
body := f.render(t)
|
|
|
|
assert.Contains(
|
|
t, body,
|
|
`<span title="`+receivedAt.Format(time.DateTime)+
|
|
` UTC">3 minutes ago</span>`,
|
|
)
|
|
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
|
assert.Contains(t, body, ">1.5s</span>")
|
|
assert.Contains(t, body, ">in progress</span>")
|
|
}
|
|
|
|
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
|
// webhook with exactly one HTTP target shows, colour-coded, what
|
|
// that target answered for each event. The log target beside it
|
|
// does not count against "exactly one".
|
|
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
|
|
|
now := time.Now()
|
|
|
|
for _, code := range []int{204, 302, 404, 503, 0} {
|
|
dlv := f.delivery(
|
|
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
f.attempt(t, dlv, code, time.Second)
|
|
}
|
|
|
|
f.delivery(
|
|
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
f.event(t, contentTypeJSON, "{}", now)
|
|
|
|
// A replay is a newer delivery, and its answer is the one shown.
|
|
replayed := f.event(t, contentTypeJSON, "{}", now)
|
|
f.attempt(t, f.delivery(
|
|
t, replayed, target.ID, database.DeliveryStatusFailed,
|
|
), 502, time.Second)
|
|
f.attempt(t, f.deliveryQueuedAt(
|
|
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
|
now.Add(time.Minute),
|
|
), 200, time.Second)
|
|
|
|
body := f.render(t)
|
|
|
|
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
|
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
|
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
|
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
|
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
|
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
|
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
|
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
|
assert.NotContains(t, body, ">502<")
|
|
}
|
|
|
|
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
|
// status column is absent when the webhook has no HTTP target or
|
|
// more than one.
|
|
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
cases := map[string][]database.TargetType{
|
|
"none": {database.TargetTypeLog},
|
|
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
|
}
|
|
|
|
for name, types := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
|
|
|
for _, tt := range types {
|
|
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
|
f.attempt(t, f.delivery(
|
|
t, event, target.ID,
|
|
database.DeliveryStatusDelivered,
|
|
), 200, time.Second)
|
|
}
|
|
|
|
body := f.render(t)
|
|
|
|
assert.Contains(t, body, `title="Body size"`)
|
|
assert.NotContains(t, body, statusTitle)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
|
|
// links to its event's own page and expands to show its body, and
|
|
// that only the newest row starts expanded.
|
|
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
now := time.Now()
|
|
|
|
older := f.event(
|
|
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
|
|
)
|
|
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
|
|
|
|
body := f.render(t)
|
|
|
|
for _, e := range []*database.Event{older, newer} {
|
|
assert.Contains(
|
|
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
|
|
)
|
|
}
|
|
|
|
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
|
|
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
|
|
|
open := strings.Index(body, " data-open>")
|
|
newerBody := strings.Index(body, ""which": "newer"")
|
|
olderBody := strings.Index(body, ""which": "older"")
|
|
|
|
assert.Less(t, open, newerBody, "the newest row is not the open one")
|
|
assert.Less(t, newerBody, olderBody)
|
|
}
|
|
|
|
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
|
|
// the cap is shown whole and pretty-printed, and a larger one only
|
|
// its first bodyCap bytes, as received, with links to the whole
|
|
// body on the event's page and to the download.
|
|
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
now := time.Now()
|
|
|
|
// A JSON document of n bytes.
|
|
document := func(n int) string {
|
|
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
|
|
}
|
|
|
|
whole := f.event(
|
|
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
|
|
)
|
|
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
|
|
|
|
body := f.render(t)
|
|
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
|
|
|
|
assert.Equal(t, 1, strings.Count(body, "{\n "pad": "))
|
|
assert.Contains(t, body, "{"pad":"xxx")
|
|
assert.Contains(
|
|
t, body,
|
|
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
|
|
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
|
|
)
|
|
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
|
|
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
|
|
}
|
|
|
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
|
// body's size in bytes, not characters, with the event it stores.
|
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
seedEntrypoint(t, f.db, f.webhook.ID)
|
|
|
|
// Two bytes per character.
|
|
body := strings.Repeat("é", 1024)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/h/x",
|
|
strings.NewReader(body),
|
|
)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
|
|
|
req = req.WithContext(context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
))
|
|
|
|
w := httptest.NewRecorder()
|
|
f.h.HandleWebhook().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var stored database.Event
|
|
|
|
require.NoError(t, f.webhookDB.First(&stored).Error)
|
|
assert.Equal(t, int64(2048), stored.BodyBytes)
|
|
}
|
|
|
|
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
|
// list cannot be loaded the page answers with an error, rather than
|
|
// an empty list claiming the webhook has no events.
|
|
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newRecentEventsFixture(t)
|
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
|
|
|
f.attempt(t, f.delivery(
|
|
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
|
database.DeliveryStatusDelivered,
|
|
), 200, time.Second)
|
|
|
|
// The attempts are the list's last query, so its events and
|
|
// deliveries have already loaded when it fails.
|
|
require.NoError(t, f.webhookDB.Exec(
|
|
"DROP TABLE delivery_results",
|
|
).Error)
|
|
|
|
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
|
}
|