Files
webhooker/internal/handlers/event_log_view_test.go
clawbot 41ff16a817
All checks were successful
check / check (push) Successful in 2m44s
Serve an event's full stored body over HTTP (closes #157)
The 8 KB render cap from #135 left storage untouched but no route served
the rest, so a body over the cap was reachable only with filesystem
access to the SQLite files — in a product whose purpose is storing
webhooks so they can be inspected.

GET /source/{sourceID}/logs/{eventID}/body serves the whole body to the
webhook's owner, as application/octet-stream with an attachment
disposition and nosniff. Those are a security control, not formatting:
the bytes come from the public receiver and are handed back inside the
operator's authenticated origin, and the existing CSP would not stop a
stored HTML payload executing there. The truncation marker links to it
only when a body was actually cut.

Accepted deviation, documented rather than glossed: #157's definition of
done asks the route to stream from the row. It buffers whole instead,
because database/sql exposes no incremental handle on a SQLite BLOB and
substr range reads re-materialise the entire column per call — an
earlier revision chunked at 64 KiB and was 11-15x slower for a worse
bound. Three independent reviewers confirmed no streaming path exists.

Independently reviewed three times. Two earlier revisions each asserted
a memory bound the code did not have; the final reviewer measured
2.057x at the ingest cap and pinned the two overlapping allocations from
source — the driver's column buffer and database/sql's convertAssign
clone — confirming the stated "roughly two bodies, and 2x is a floor
not a ceiling" is now accurate, since SQLite's own materialisation sits
outside the Go heap.
2026-08-18 00:41:31 +02:00

262 lines
6.4 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 event log page is
// 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 64 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 "+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, "Body truncated for display")
}
// TestEventLogView_CutMidRune proves a multi-byte rune severed
// by the byte-wise cut is dropped rather than surfaced as a
// mojibake tail.
func TestEventLogView_CutMidRune(t *testing.T) {
t.Parallel()
body := strings.Repeat(snowman, 4096)
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.BodyTruncated)
assert.Equal(t, int64(len(body)), view.BodyBytes)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
assert.True(t, utf8.ValidString(view.Body))
assert.LessOrEqual(t, len(view.Body), bodyCap)
}
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
// payload is passed through byte for byte. Its tail is invalid
// UTF-8 however the cut falls, so repairing it would misreport
// what the sender delivered.
func TestEventLogView_BinaryBodyLeftAsStored(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)
}
view := seedAndProject(t, string(raw))
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(raw)), view.BodyBytes)
assert.Equal(t, string(raw[:bodyCap]), view.Body)
assert.False(t, utf8.ValidString(view.Body))
}
// 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),
)
})
}
}