All checks were successful
check / check (push) Successful in 2m44s
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.
262 lines
6.4 KiB
Go
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),
|
|
)
|
|
})
|
|
}
|
|
}
|