463 lines
12 KiB
Go
463 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
gormlogger "gorm.io/gorm/logger"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
// gormBoundTailMarker sits at the far end of every client-chosen value
|
|
// this file sends. Its presence in the log means the whole value
|
|
// reached the log, so a value that merely happened to be short cannot
|
|
// pass for a truncated one.
|
|
const gormBoundTailMarker = "ENDOFCLIENTVALUE"
|
|
|
|
// gormBoundFills are the characters a client can drive through the
|
|
// receiver path segment and the login username, chosen for what a log
|
|
// handler charges for them.
|
|
//
|
|
// The bare C0 control is the one that matters: both handlers spell
|
|
// U+0001 as a six-byte escape for the one byte it costs to send, the
|
|
// widest multiplier available below U+10000 and the case a raw-byte
|
|
// budget breaks on first. GORM's default logger applies no budget at
|
|
// all, so under the mutation every one of these arrives whole.
|
|
func gormBoundFills() []struct {
|
|
name string
|
|
fill string
|
|
} {
|
|
return []struct {
|
|
name string
|
|
fill string
|
|
}{
|
|
{"plain", "x"},
|
|
{"quote", `"`},
|
|
{"backslash", `\`},
|
|
{"tab", "\t"},
|
|
{"newline", "\n"},
|
|
{"c0_control", "\x01"},
|
|
{"astral_nonprintable", "\U0001000C"},
|
|
}
|
|
}
|
|
|
|
// syncBuf collects captured output from the goroutine draining the
|
|
// pipe.
|
|
type syncBuf struct {
|
|
mu sync.Mutex
|
|
b bytes.Buffer
|
|
}
|
|
|
|
func (s *syncBuf) Write(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.b.Write(p)
|
|
}
|
|
|
|
func (s *syncBuf) String() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.b.String()
|
|
}
|
|
|
|
func (s *syncBuf) reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.b.Reset()
|
|
}
|
|
|
|
// stdoutCapture redirects os.Stdout for the duration of a test.
|
|
//
|
|
// internal/logger builds its handler over os.Stdout at construction
|
|
// time, so redirecting the variable before the application is built
|
|
// captures everything the service logger — and therefore the GORM
|
|
// adapter, which writes through it — emits.
|
|
type stdoutCapture struct {
|
|
buf *syncBuf
|
|
r *os.File
|
|
w *os.File
|
|
orig *os.File
|
|
done chan struct{}
|
|
seq int
|
|
}
|
|
|
|
func captureStdout(t *testing.T) *stdoutCapture {
|
|
t.Helper()
|
|
|
|
r, w, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
|
|
c := &stdoutCapture{
|
|
buf: &syncBuf{},
|
|
r: r,
|
|
w: w,
|
|
orig: os.Stdout,
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
os.Stdout = w
|
|
|
|
go func() {
|
|
defer close(c.done)
|
|
|
|
_, _ = io.Copy(c.buf, r)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
os.Stdout = c.orig
|
|
_ = w.Close()
|
|
|
|
<-c.done
|
|
|
|
_ = r.Close()
|
|
})
|
|
|
|
return c
|
|
}
|
|
|
|
// drain returns everything written since the previous drain and
|
|
// clears the buffer.
|
|
//
|
|
// A sentinel is pushed through the same pipe and waited for, so the
|
|
// draining goroutine is known to have caught up before the buffer is
|
|
// read. Without it the comparison below would race the reader rather
|
|
// than measure the writers.
|
|
func (c *stdoutCapture) drain(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
c.seq++
|
|
|
|
sentinel := "\n<<drain-" + strconv.Itoa(c.seq) + ">>\n"
|
|
|
|
_, err := c.w.WriteString(sentinel)
|
|
require.NoError(t, err)
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for !strings.Contains(c.buf.String(), sentinel) {
|
|
require.False(
|
|
t, time.Now().After(deadline),
|
|
"timed out waiting for captured output",
|
|
)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
out := strings.Replace(c.buf.String(), sentinel, "", 1)
|
|
|
|
c.buf.reset()
|
|
|
|
return out
|
|
}
|
|
|
|
// teeStdout writes to a buffer and to whatever os.Stdout is at the
|
|
// moment of the write.
|
|
//
|
|
// The second half is the point. GORM's package-level default logger
|
|
// resolves os.Stdout once, at package init, so a logger built over the
|
|
// variable would keep writing to the real terminal no matter what a
|
|
// test redirects. Resolving it per write puts the bytes a defaulted
|
|
// gorm.Config would cost in production into the same capture as
|
|
// everything else internal/logger emits, which is what lets the volume
|
|
// assertions below measure the whole writer set rather than one member
|
|
// of it.
|
|
type teeStdout struct {
|
|
buf *syncBuf
|
|
}
|
|
|
|
func (w teeStdout) Write(p []byte) (int, error) {
|
|
_, _ = os.Stdout.Write(p)
|
|
|
|
return w.buf.Write(p)
|
|
}
|
|
|
|
// captureGORMDefault replaces GORM's package-level default logger with
|
|
// one configured exactly as GORM configures its own, writing to a
|
|
// buffer and to os.Stdout.
|
|
//
|
|
// This is the mutation detector. gormlogger.Default is what a bare
|
|
// &gorm.Config{} installs, and its config here is GORM's verbatim —
|
|
// Warn, IgnoreRecordNotFoundError false — so a reverted call site
|
|
// behaves as it would in production rather than as a test dialed it.
|
|
// With every gorm.Open in this service naming its own logger, nothing
|
|
// consults this value and the buffer stays empty; revert any one of
|
|
// the three and the interpolated SQL lands here.
|
|
func captureGORMDefault(t *testing.T) *syncBuf {
|
|
t.Helper()
|
|
|
|
buf := &syncBuf{}
|
|
orig := gormlogger.Default
|
|
|
|
gormlogger.Default = gormlogger.New(
|
|
log.New(teeStdout{buf: buf}, "", log.LstdFlags),
|
|
gormlogger.Config{
|
|
SlowThreshold: 200 * time.Millisecond,
|
|
LogLevel: gormlogger.Warn,
|
|
IgnoreRecordNotFoundError: false,
|
|
Colorful: false,
|
|
},
|
|
)
|
|
|
|
t.Cleanup(func() { gormlogger.Default = orig })
|
|
|
|
return buf
|
|
}
|
|
|
|
// floodUnauthenticated drives reps requests at each of the two
|
|
// unauthenticated lookups that miss by design, for every fill, with a
|
|
// client-chosen value of size raw bytes.
|
|
func floodUnauthenticated(
|
|
t *testing.T, h *handlers.Handlers, size, reps int,
|
|
) int {
|
|
t.Helper()
|
|
|
|
requests := 0
|
|
|
|
for _, f := range gormBoundFills() {
|
|
var b strings.Builder
|
|
|
|
for b.Len() < size {
|
|
b.WriteString(f.fill)
|
|
}
|
|
|
|
b.WriteString(gormBoundTailMarker)
|
|
|
|
value := b.String()
|
|
|
|
for range reps {
|
|
postWebhook(t, h, value)
|
|
postUnknownLogin(t, h, value)
|
|
|
|
requests += 2
|
|
}
|
|
}
|
|
|
|
return requests
|
|
}
|
|
|
|
// floodPerWebhook drives the same client-chosen values at the second
|
|
// gorm.Open site, the per-webhook database internal/database's
|
|
// WebhookDBManager opens.
|
|
//
|
|
// That site is behind authentication in production, so this is not
|
|
// part of the unauthenticated flood above and is counted separately.
|
|
// It is here because the ceiling the README states covers every
|
|
// writer, and the manager is one of them: with nothing driving it, a
|
|
// bare &gorm.Config{} could be restored at
|
|
// internal/database/webhook_db_manager.go and the whole suite would
|
|
// stay green.
|
|
func floodPerWebhook(
|
|
t *testing.T, mgr *database.WebhookDBManager, size, reps int,
|
|
) int {
|
|
t.Helper()
|
|
|
|
requests := 0
|
|
|
|
for _, f := range gormBoundFills() {
|
|
var b strings.Builder
|
|
|
|
for b.Len() < size {
|
|
b.WriteString(f.fill)
|
|
}
|
|
|
|
b.WriteString(gormBoundTailMarker)
|
|
|
|
value := b.String()
|
|
|
|
db, err := mgr.GetDB("pin-" + f.name)
|
|
require.NoError(t, err)
|
|
|
|
for range reps {
|
|
var got database.Event
|
|
|
|
err = db.Where("id = ?", value).First(&got).Error
|
|
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
|
|
|
requests++
|
|
}
|
|
}
|
|
|
|
return requests
|
|
}
|
|
|
|
// postWebhook drives the receiver with an invented entrypoint path.
|
|
// The route pattern matches any single segment, so every byte of the
|
|
// value is the client's, and the lookup behind it misses by design.
|
|
func postWebhook(
|
|
t *testing.T, h *handlers.Handlers, entrypoint string,
|
|
) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/webhook/x",
|
|
strings.NewReader("{}"),
|
|
)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("uuid", entrypoint)
|
|
|
|
req = req.WithContext(context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
))
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleWebhook().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// postUnknownLogin submits the login form with an unknown username,
|
|
// through the postLogin helper in logbound_test.go. The field is
|
|
// bounded only by the 1 MB body cap, and the lookup behind it misses
|
|
// by design.
|
|
func postUnknownLogin(
|
|
t *testing.T, h *handlers.Handlers, username string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// 401 while the client still has failure budget against this
|
|
// username, 429 once the login guard has taken it away. Both
|
|
// outcomes sit behind the user lookup, which is the query this
|
|
// test is here to drive.
|
|
require.Contains(
|
|
t,
|
|
[]int{http.StatusUnauthorized, http.StatusTooManyRequests},
|
|
postLogin(t, h, username),
|
|
)
|
|
}
|
|
|
|
// assertFloodBounded holds every captured line to the stated ceiling
|
|
// and proves nothing carried a whole client value.
|
|
func assertFloodBounded(t *testing.T, label, out string) {
|
|
t.Helper()
|
|
|
|
assert.NotContains(
|
|
t, out, gormBoundTailMarker,
|
|
"%s: the far end of a client-chosen value reached the "+
|
|
"log, so nothing truncated it", label,
|
|
)
|
|
|
|
for line := range strings.SplitSeq(
|
|
strings.TrimRight(out, "\n"), "\n",
|
|
) {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
assert.LessOrEqual(
|
|
t, len(line), middleware.MaxAccessLogLineBytes,
|
|
"%s: log line exceeded its bound: %s",
|
|
label, line[:min(len(line), 300)],
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestFlood_NoWriterGrowsWithTheInput is the definition of done for
|
|
// the GORM logger defect, stated over every writer at once, for two of
|
|
// this service's three gorm.Open sites: the main database behind the
|
|
// two unauthenticated lookups, and the per-webhook database the
|
|
// WebhookDBManager opens. The third, the archive writer, is pinned in
|
|
// internal/delivery, where its type lives.
|
|
//
|
|
// What each assertion is worth, since two of the three would pass
|
|
// against a service that had never been fixed if the capture were set
|
|
// up differently:
|
|
//
|
|
// - The gormDefault check is the sharp one. It fires the moment any
|
|
// gorm.Open in this service goes back to a bare &gorm.Config{}.
|
|
// - The volume and per-line checks bite only because the replaced
|
|
// default logger tees into os.Stdout, so a reverted call site
|
|
// shows up in the same capture as everything internal/logger
|
|
// writes — the way it would in production. Without that tee both
|
|
// were vacuous: at INFO the two handler misses log at DEBUG and
|
|
// the adapter drops the record-not-found, so the capture holds
|
|
// nothing but fixed-string warnings.
|
|
//
|
|
// The level is left where newTestApp leaves it, at INFO: the level an
|
|
// operator runs at by default, and the one the defect was visible at.
|
|
// The handlers' own miss lines sit at DEBUG and spend the same
|
|
// logfield budget as everything else, so they are not what makes
|
|
// either assertion above bite at any level.
|
|
//
|
|
// It is deliberately not parallel: it redirects os.Stdout and replaces
|
|
// gormlogger.Default, both of which are process-global. Go runs every
|
|
// non-parallel top-level test to completion before it resumes the
|
|
// parallel ones, so nothing else in this package is running while the
|
|
// capture is installed.
|
|
//
|
|
//nolint:paralleltest // Deliberately sequential; see above.
|
|
func TestFlood_NoWriterGrowsWithTheInput(t *testing.T) {
|
|
const (
|
|
smallBytes = 128
|
|
bigBytes = 8 << 10
|
|
reps = 5
|
|
)
|
|
|
|
gormDefault := captureGORMDefault(t)
|
|
capture := captureStdout(t)
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
mgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &mgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// Startup chatter is not what this test measures.
|
|
capture.drain(t)
|
|
|
|
floodUnauthenticated(t, h, smallBytes, reps)
|
|
floodPerWebhook(t, mgr, smallBytes, reps)
|
|
|
|
small := capture.drain(t)
|
|
|
|
requests := floodUnauthenticated(t, h, bigBytes, reps)
|
|
requests += floodPerWebhook(t, mgr, bigBytes, reps)
|
|
big := capture.drain(t)
|
|
|
|
assertFloodBounded(t, "small flood", small)
|
|
assertFloodBounded(t, "big flood", big)
|
|
|
|
// GORM's default logger is what the defect was. Nothing in this
|
|
// service may reach it.
|
|
got := gormDefault.String()
|
|
assert.Empty(
|
|
t, got,
|
|
"GORM's default logger wrote %d bytes; the first of them: %s",
|
|
len(got), got[:min(len(got), 300)],
|
|
)
|
|
|
|
// The same flood, with 64 times the client-chosen input, must not
|
|
// buy 64 times the log. A few bytes of slack covers a latency
|
|
// field changing width; the input grew by roughly half a megabyte.
|
|
const slackPerRequest = 64
|
|
|
|
assert.LessOrEqual(
|
|
t, len(big), len(small)+slackPerRequest*requests,
|
|
"log volume tracked the size of the client's input: "+
|
|
"%d bytes at %d bytes of input per request, %d bytes "+
|
|
"at %d",
|
|
len(small), smallBytes, len(big), bigBytes,
|
|
)
|
|
}
|