// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package httplimit import ( "bytes" "errors" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // closeRecorder wraps a body and records Close, so a test can assert the // reader closes it on every path. type closeRecorder struct { io.Reader closed bool } func (c *closeRecorder) Close() error { c.closed = true; return nil } func response(body string) (*http.Response, *closeRecorder) { rec := &closeRecorder{Reader: strings.NewReader(body)} u, _ := url.Parse("https://farm.example.test/plugin/farmmanager/sites") return &http.Response{StatusCode: 200, Body: rec, Request: &http.Request{URL: u}}, rec } func TestReadResponseWithinBoundIsWhole(t *testing.T) { resp, rec := response("exactly-32-bytes-of-body-text!!!") body, err := ReadResponse(resp, 32) if err != nil { t.Fatalf("body of bound size must be read whole, got %v", err) } if string(body) != "exactly-32-bytes-of-body-text!!!" { t.Fatalf("body = %q", body) } if !rec.closed { t.Fatal("body must be closed after a successful read") } } func TestReadResponseOneByteOverIsRefusedNamingBoundAndHost(t *testing.T) { resp, rec := response(strings.Repeat("x", 33)) body, err := ReadResponse(resp, 32) if body != nil { t.Fatalf("an over-bound response must return no bytes, got %d", len(body)) } var tl *TooLargeError if !errors.As(err, &tl) { t.Fatalf("want *TooLargeError, got %T %v", err, err) } if tl.Bound != 32 || tl.Host != "farm.example.test" { t.Fatalf("error must name the bound and the host, got %+v", tl) } if !strings.Contains(err.Error(), "farm.example.test") || !strings.Contains(err.Error(), "32 bytes") { t.Fatalf("message must name host and bound: %q", err.Error()) } if !IsTooLarge(err) { t.Fatal("IsTooLarge must recognise its own error") } if !rec.closed { t.Fatal("body must be closed after a refusal") } } // The reader never asks for more than max+1 bytes, so a stream with no end // costs one byte over the bound, not the stream. func TestReadResponseStopsReadingAtTheBound(t *testing.T) { endless := &countingReader{} u, _ := url.Parse("http://forum.example.test/") resp := &http.Response{Body: io.NopCloser(endless), Request: &http.Request{URL: u}} if _, err := ReadResponse(resp, 1024); !IsTooLarge(err) { t.Fatalf("want TooLargeError, got %v", err) } if endless.n > 1024+1 { t.Fatalf("read %d bytes from an endless body; the bound plus one is the ceiling", endless.n) } } type countingReader struct{ n int64 } func (c *countingReader) Read(p []byte) (int, error) { for i := range p { p[i] = 'y' } c.n += int64(len(p)) return len(p), nil } func TestDecodeResponse(t *testing.T) { t.Run("a document within the bound decodes", func(t *testing.T) { resp, _ := response(`{"name":"a"}`) var v struct{ Name string } if err := DecodeResponse(resp, DefaultResponseBytes, &v); err != nil || v.Name != "a" { t.Fatalf("decode: %v, %+v", err, v) } }) t.Run("a document over the bound is refused, not truncated", func(t *testing.T) { resp, _ := response(`{"name":"` + strings.Repeat("a", 64) + `"}`) var v struct{ Name string } err := DecodeResponse(resp, 32, &v) if !IsTooLarge(err) { t.Fatalf("want TooLargeError, got %v", err) } if v.Name != "" { t.Fatal("nothing must be decoded from a refused body") } }) } // Against a live server: the bound holds for a real body on the wire, and // the host in the error is the one the request went to. func TestReadResponseAgainstServer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(bytes.Repeat([]byte("z"), 4096)) })) defer srv.Close() resp, err := http.Get(srv.URL) if err != nil { t.Fatal(err) } _, err = ReadResponse(resp, 1000) var tl *TooLargeError if !errors.As(err, &tl) || tl.Host != strings.TrimPrefix(srv.URL, "http://") { t.Fatalf("want TooLargeError for %s, got %v", srv.URL, err) } }