package handlers_test import ( "bytes" "context" "io" "log" "net/http" "net/http/httptest" "net/url" "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<>\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) postLogin(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) } // postLogin submits the login form with an unknown username. The // field is bounded only by the 1 MB body cap, and the lookup behind // it misses by design. func postLogin( t *testing.T, h *handlers.Handlers, username string, ) { t.Helper() form := url.Values{} form.Set("username", username) form.Set("password", "not-the-password") req := httptest.NewRequestWithContext( context.Background(), http.MethodPost, "/pages/login", strings.NewReader(form.Encode()), ) req.Header.Set( "Content-Type", "application/x-www-form-urlencoded", ) w := httptest.NewRecorder() h.HandleLoginSubmit().ServeHTTP(w, req) // 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}, w.Code, ) } // 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, deliberately. // At DEBUG the handlers' own miss lines log the client-chosen // entrypoint and username untruncated — the first carve-out in the // README's ceiling section, and https://git.eeqj.de/sneak/webhooker/issues/176's // to fix, not this one's. // // 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, ) }