Files
EvoBGP/internal/httpapi/request_body_test.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 17:23:59 +07:00

274 lines
8.9 KiB
Go

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)
}
})
}
}