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
272 lines
6.6 KiB
Go
272 lines
6.6 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"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"
|
|
)
|
|
|
|
// bodyCap is the number of body bytes the lists of events are
|
|
// allowed to render for one event.
|
|
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
|
|
|
// snowman is a three-byte rune, so a body of them straddles the
|
|
// byte-wise cut: bodyCap is not a multiple of three.
|
|
const snowman = "☃"
|
|
|
|
// seedEventWithBody records one event with the given body in the
|
|
// webhook's own database and returns it, so a caller that needs
|
|
// the generated event id can have it.
|
|
func seedEventWithBody(
|
|
t *testing.T,
|
|
dbMgr *database.WebhookDBManager,
|
|
webhookID string,
|
|
body string,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
Method: http.MethodPost,
|
|
Body: body,
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Omit(
|
|
clause.Associations,
|
|
).Create(event).Error)
|
|
|
|
return event
|
|
}
|
|
|
|
// seedAndProject stores one body and returns the projection the
|
|
// event log page would be handed for it.
|
|
func seedAndProject(
|
|
t *testing.T,
|
|
body string,
|
|
) handlers.EventLogView {
|
|
t.Helper()
|
|
|
|
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)
|
|
seedEventWithBody(t, dbMgr, wh.ID, body)
|
|
|
|
views := h.LoadEventLogViewsForTest(
|
|
httptest.NewRecorder(), *wh, 1,
|
|
)
|
|
require.Len(t, views, 1)
|
|
|
|
return views[0]
|
|
}
|
|
|
|
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
|
// page is bounded by the cap rather than by the stored payload:
|
|
// the body here is 16 times the cap, and the ingest path would
|
|
// accept twice as much again.
|
|
func TestHandleSourceLogs_BoundsOversizeBody(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)
|
|
|
|
const (
|
|
sentinel = "TAIL-SENTINEL-1f4a9c"
|
|
storedBytes = 512 * 1024
|
|
)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedEventWithBody(
|
|
t, dbMgr, wh.ID,
|
|
strings.Repeat("A", storedBytes-len(sentinel))+sentinel,
|
|
)
|
|
|
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
// Nothing past the cap reaches the page, and the whole page
|
|
// stays far below the stored body it is reporting on.
|
|
assert.NotContains(t, page, sentinel)
|
|
assert.Less(t, len(page), 4*bodyCap)
|
|
|
|
// The marker states the true stored size, not the cut one.
|
|
assert.Contains(
|
|
t, page,
|
|
"Showing the first "+strconv.Itoa(bodyCap)+
|
|
" of "+strconv.Itoa(storedBytes)+" bytes",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceLogs_SmallBodyRendersWhole guards the other
|
|
// side of the cap: a body under it is shown in full and carries
|
|
// no truncation marker.
|
|
func TestHandleSourceLogs_SmallBodyRendersWhole(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)
|
|
seedEventWithBody(t, dbMgr, wh.ID, `{"kept":"whole"}`)
|
|
|
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(t, page, ""kept"")
|
|
assert.NotContains(t, page, "Showing the first")
|
|
}
|
|
|
|
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
|
// by the byte-wise cut is dropped rather than surfaced as a
|
|
// mojibake tail, which would also make the text look binary.
|
|
func TestEventLogView_CutMidRune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := strings.Repeat(snowman, bodyCap)
|
|
view := seedAndProject(t, body)
|
|
|
|
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
|
// of the next one; those two are dropped.
|
|
whole := bodyCap / len(snowman)
|
|
|
|
assert.True(t, view.Body.Cut)
|
|
assert.False(t, view.Body.Binary)
|
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
|
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
|
|
assert.True(t, utf8.ValidString(view.Body.Text))
|
|
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
|
|
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
|
|
}
|
|
|
|
// TestEventLogView_BinaryBodyNotShown proves a body that is not
|
|
// text is left out rather than shown as replacement characters,
|
|
// whether it is cut or not.
|
|
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := make([]byte, bodyCap+808)
|
|
for i := range raw {
|
|
// 0x80..0xBF: continuation bytes, never a rune start.
|
|
raw[i] = 0x80 | byte(i%0x40)
|
|
}
|
|
|
|
for name, body := range map[string][]byte{
|
|
"cut": raw,
|
|
"whole": raw[:2048],
|
|
"NUL": []byte("text\x00text"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
view := seedAndProject(t, string(body))
|
|
|
|
assert.True(t, view.Body.Binary)
|
|
assert.Empty(t, view.Body.Text)
|
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTrimPartialRune covers the distinction the cut repair
|
|
// turns on: an incomplete but valid sequence is dropped, while
|
|
// bytes that are merely invalid UTF-8 are left alone.
|
|
func TestTrimPartialRune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
in []byte
|
|
want []byte
|
|
}{{
|
|
name: "complete ascii",
|
|
in: []byte("abc"),
|
|
want: []byte("abc"),
|
|
}, {
|
|
name: "complete multibyte",
|
|
in: []byte("ab" + snowman),
|
|
want: []byte("ab" + snowman),
|
|
}, {
|
|
name: "two byte rune cut",
|
|
in: []byte{'a', 0xC3},
|
|
want: []byte{'a'},
|
|
}, {
|
|
name: "three byte rune cut after one",
|
|
in: []byte{'a', 0xE2},
|
|
want: []byte{'a'},
|
|
}, {
|
|
name: "three byte rune cut after two",
|
|
in: []byte{'a', 0xE2, 0x98},
|
|
want: []byte{'a'},
|
|
}, {
|
|
name: "four byte rune cut",
|
|
in: []byte{'a', 0xF0, 0x9F, 0x92}, // U+1F4A9 cut
|
|
want: []byte{'a'},
|
|
}, {
|
|
name: "invalid start byte kept",
|
|
in: []byte{'a', 0xFF},
|
|
want: []byte{'a', 0xFF},
|
|
}, {
|
|
name: "orphan continuation bytes kept",
|
|
in: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
|
want: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
|
}, {
|
|
name: "truncated sequence followed by junk kept",
|
|
in: []byte{0xE2, 0x98, 0xFF},
|
|
want: []byte{0xE2, 0x98, 0xFF},
|
|
}, {
|
|
name: "empty",
|
|
in: []byte{},
|
|
want: []byte{},
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(
|
|
t, tc.want,
|
|
handlers.TrimPartialRuneForTest(tc.in),
|
|
)
|
|
})
|
|
}
|
|
}
|