package httpapi import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) func TestRequestBodyKnownAndUnknownBoundsBeforeMutation(t *testing.T) { const limit = 32 for _, unknown := range []bool{false, true} { for _, contentType := range []string{"", "application/json", "text/plain"} { for _, extra := range []int{0, 1} { label := "known" if unknown { label = "unknown" } t.Run(label+"/"+contentType+"/"+string(rune('0'+extra)), func(t *testing.T) { mutations := 0 h := withBodyLimit(limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var input map[string]any if err := json.NewDecoder(r.Body).Decode(&input); err != nil { t.Error(err) } mutations++ w.WriteHeader(http.StatusCreated) })) body := `{"x":1}` + strings.Repeat(" ", limit-len(`{"x":1}`)+extra) req := httptest.NewRequest(http.MethodPost, "/v1/modules", strings.NewReader(body)) if unknown { req.ContentLength = -1 } if contentType != "" { req.Header.Set("Content-Type", contentType) } w := httptest.NewRecorder() h.ServeHTTP(w, req) if extra == 0 { if w.Code != http.StatusCreated || mutations != 1 { t.Fatalf("exact limit rejected: %d mutations=%d", w.Code, mutations) } } else if w.Code != http.StatusRequestEntityTooLarge || mutations != 0 { t.Fatalf("oversized tail mutated: %d mutations=%d", w.Code, mutations) } }) } } } } type checkedRequestBody struct { reads int closed bool reader io.Reader } func (b *checkedRequestBody) Read(p []byte) (int, error) { b.reads++; return b.reader.Read(p) } func (b *checkedRequestBody) Close() error { b.closed = true; return nil } type failingRequestReader struct{} func (failingRequestReader) Read([]byte) (int, error) { return 0, errors.New("untrusted detailed read failure") } func TestRequestBodyErrorsDoNotMutate(t *testing.T) { for _, cancelled := range []bool{false, true} { calls := 0 b := &checkedRequestBody{reader: io.MultiReader(strings.NewReader(`{"x":1}`), failingRequestReader{})} req := httptest.NewRequest(http.MethodPost, "/v1/modules", nil) req.Body = b req.ContentLength = -1 if cancelled { ctx, cancel := context.WithCancel(req.Context()) cancel() req = req.WithContext(ctx) } w := httptest.NewRecorder() withRequestBodyLimit(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })).ServeHTTP(w, req) want := http.StatusBadRequest if cancelled { want = http.StatusRequestTimeout } if w.Code != want || calls != 0 || !b.closed || strings.Contains(w.Body.String(), "untrusted detailed") { t.Fatalf("error handling status=%d calls=%d body=%s", w.Code, calls, w.Body.String()) } if cancelled && b.reads != 0 { t.Fatal("read body after cancellation") } } } func TestRequestBodyAuthenticationRunsFirst(t *testing.T) { b := &checkedRequestBody{reader: failingRequestReader{}} req := httptest.NewRequest(http.MethodPost, "/v1/modules", nil) req.Body = b req.ContentLength = requestBodyLimit + 1 s := &Server{} called := false h := s.authMiddleware(withRequestBodyLimit(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized || b.reads != 0 || called { t.Fatalf("body processed before auth: %d reads=%d", w.Code, b.reads) } } func TestRequestBodyAppliesToGETAndIgnoresClaimedZeroLength(t *testing.T) { for _, length := range []int64{-1, 0, requestBodyLimit + 1} { req := httptest.NewRequest(http.MethodGet, "/v1/jobs", strings.NewReader(strings.Repeat("x", int(requestBodyLimit)+1))) req.ContentLength = length called := false w := httptest.NewRecorder() withRequestBodyLimit(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })).ServeHTTP(w, req) if w.Code != http.StatusRequestEntityTooLarge || called { t.Fatalf("GET size bypass length=%d code=%d", length, w.Code) } } } func TestRequestBodyHandlerWiringAndNoMutation(t *testing.T) { s, err := New(Options{SeedDemo: true, InsecureDev: true, BundleSeedHex: testBundleSeed}) if err != nil { t.Fatal(err) } defer s.Close() tenant, _, _, _, _ := s.store.DemoIDs() before := len(s.store.ListModules(tenant)) h := s.Handler() prefix := `{"type":"IP_RANGES","name":"bounded-create","enabled":false}` for _, extra := range []int{1, 0} { body := prefix + strings.Repeat(" ", int(requestBodyLimit)-len(prefix)+extra) req := httptest.NewRequest(http.MethodPost, "/v1/modules", strings.NewReader(body)) req.ContentLength = -1 req.Header.Set("Authorization", "Bearer dev") w := httptest.NewRecorder() h.ServeHTTP(w, req) if extra == 1 { if w.Code != http.StatusRequestEntityTooLarge || len(s.store.ListModules(tenant)) != before { t.Fatalf("oversized JSON committed: %d %s", w.Code, w.Body.String()) } } else if w.Code != http.StatusCreated || len(s.store.ListModules(tenant)) != before+1 { t.Fatalf("8MiB body rejected: %d %s", w.Code, w.Body.String()) } } } func TestRequestBodyLeavesStreamingResponseUnbuffered(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/stream", nil) withRequestBodyLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f, ok := w.(http.Flusher) if !ok { t.Fatal("response lost Flusher") } w.Header().Set("Content-Type", "text/event-stream") _, _ = io.WriteString(w, "data: ready\n\n") f.Flush() })).ServeHTTP(w, req) if !w.Flushed || w.Body.String() != "data: ready\n\n" { t.Fatal("stream response buffered") } } type blockedAdmissionBody struct { entered chan struct{} resume chan struct{} sent bool } func (b *blockedAdmissionBody) Read(p []byte) (int, error) { if b.sent { return 0, io.EOF } b.sent = true close(b.entered) <-b.resume p[0] = 'x' return 1, nil } func (*blockedAdmissionBody) Close() error { return nil } func TestRequestBodyAdmissionRejectsWithoutWaitingAndHoldsThroughHandler(t *testing.T) { readEntered, readResume := make(chan struct{}), make(chan struct{}) handlerEntered, handlerResume := make(chan struct{}), make(chan struct{}) var once sync.Once h := withBodyLimitAndConcurrency(32, 1, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/empty" { once.Do(func() { close(handlerEntered) }) <-handlerResume } w.WriteHeader(http.StatusNoContent) })) first := httptest.NewRequest(http.MethodPost, "/first", nil) first.Body = &blockedAdmissionBody{entered: readEntered, resume: readResume} first.ContentLength = 0 // A claimed zero must not bypass body admission. done := make(chan struct{}) go func() { defer close(done); h.ServeHTTP(httptest.NewRecorder(), first) }() select { case <-readEntered: case <-time.After(3 * time.Second): t.Fatal("first body did not start") } reject := func() { t.Helper() w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/second", strings.NewReader("x")) h.ServeHTTP(w, req) if w.Code != http.StatusTooManyRequests || w.Header().Get("Retry-After") != "1" { t.Fatalf("admission did not reject: %d %s", w.Code, w.Body.String()) } } reject() // An empty legacy GET remains available while all body slots are occupied. empty := httptest.NewRecorder() h.ServeHTTP(empty, httptest.NewRequest(http.MethodGet, "/empty", nil)) if empty.Code != http.StatusNoContent { t.Fatal("empty GET consumed body slot") } close(readResume) select { case <-handlerEntered: case <-time.After(3 * time.Second): t.Fatal("handler did not start") } reject() // The fully read buffer is still retained by the running handler. close(handlerResume) select { case <-done: case <-time.After(3 * time.Second): t.Fatal("first request did not finish") } w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/third", strings.NewReader("x"))) if w.Code != http.StatusNoContent { t.Fatalf("body slot not released: %d", w.Code) } } func TestRequestBodyAdmissionReleasesOnReadError(t *testing.T) { h := withBodyLimitAndConcurrency(32, 1, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("read error reached handler") })) for range 2 { req := httptest.NewRequest(http.MethodPost, "/bad", nil) req.Body = io.NopCloser(failingRequestReader{}) req.ContentLength = -1 w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("read failure retained slot: %d", w.Code) } } } func TestRequestBodyAdmissionEnvironment(t *testing.T) { for _, tc := range []struct { raw string want int }{{"", 4}, {"1", 1}, {"64", 64}, {"65", 4}, {"0", 4}, {"-1", 4}, {"invalid", 4}} { t.Run(tc.raw, func(t *testing.T) { t.Setenv("EVOBGP_API_BODY_MAX_CONCURRENT", tc.raw) if got := requestBodyConcurrency(); got != tc.want { t.Fatalf("concurrency=%d want%d", got, tc.want) } }) } }