364 lines
11 KiB
Go
364 lines
11 KiB
Go
package httpx_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
)
|
|
|
|
func TestLegacyResponseTrackingAndMiddleware(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
closeLog, err := logx.Setup(logx.Config{Writer: &logs, Format: "json"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer closeLog()
|
|
app := omni.New(omni.WithLogger(true))
|
|
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })))
|
|
app.GET("/late", func(c *httpx.Context) error {
|
|
c.Raw().WriteHeader(404)
|
|
_, _ = c.Raw().Write([]byte("original"))
|
|
return errors.New("late error")
|
|
})
|
|
app.GET("/panic", func(c *httpx.Context) error { _, _ = c.Raw().Write([]byte("partial")); panic("late panic") })
|
|
for _, tc := range []struct {
|
|
path string
|
|
status int
|
|
body string
|
|
}{{"/legacy", 404, "404 page not found\n"}, {"/late", 404, "original"}, {"/panic", 200, "partial"}} {
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
|
if w.Code != tc.status || w.Body.String() != tc.body {
|
|
t.Fatalf("%s: %d %q", tc.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
count := 0
|
|
for _, line := range strings.Split(logs.String(), "\n") {
|
|
var e map[string]any
|
|
if json.Unmarshal([]byte(line), &e) != nil || e["msg"] != "http.request" {
|
|
continue
|
|
}
|
|
count++
|
|
if e["path"] != "/panic" && e["status"] != float64(404) {
|
|
t.Fatalf("wrong log: %s", line)
|
|
}
|
|
}
|
|
if count != 3 {
|
|
t.Fatalf("access logs: %d", count)
|
|
}
|
|
|
|
type key struct{}
|
|
app = omni.New()
|
|
app.Use(httpx.WrapFuncMiddleware(func(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Before", "yes")
|
|
next(w, r.WithContext(context.WithValue(r.Context(), key{}, "identity")))
|
|
}
|
|
}))
|
|
app.GET("/context", func(c *httpx.Context) error {
|
|
if c.Context().Value(key{}) != "identity" || c.RequestID() == "" {
|
|
t.Error("lost context")
|
|
}
|
|
return omni.NotFound("MISSING", "missing")
|
|
})
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", "/context", nil))
|
|
if w.Code != 404 || w.Header().Get("X-Before") != "yes" || strings.Count(w.Body.String(), "MISSING") != 1 {
|
|
t.Fatalf("middleware response: %d %s", w.Code, w.Body)
|
|
}
|
|
}
|
|
|
|
type plainWriter struct {
|
|
header http.Header
|
|
status int
|
|
}
|
|
|
|
func (w *plainWriter) Header() http.Header { return w.header }
|
|
func (w *plainWriter) WriteHeader(status int) { w.status = status }
|
|
func (w *plainWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
func TestWriterCapabilitiesAndInformationalStatus(t *testing.T) {
|
|
w := &plainWriter{header: http.Header{}}
|
|
c := httpx.NewContext(httptest.NewRequest("GET", "/", nil), w, nil)
|
|
if _, ok := c.Raw().(http.Flusher); ok {
|
|
t.Fatal("invented Flusher")
|
|
}
|
|
if _, ok := c.Raw().(http.Hijacker); ok {
|
|
t.Fatal("invented Hijacker")
|
|
}
|
|
if err := http.NewResponseController(c.Raw()).Flush(); !errors.Is(err, http.ErrNotSupported) {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
c.Raw().WriteHeader(103)
|
|
if c.Responded() {
|
|
t.Fatal("103 committed final response")
|
|
}
|
|
c.Raw().WriteHeader(201)
|
|
c.Raw().WriteHeader(500)
|
|
if !c.Responded() || c.Status() != 201 || w.status != 201 {
|
|
t.Fatal("first final status not preserved")
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
c = httpx.NewContext(httptest.NewRequest("GET", "/", nil), recorder, nil)
|
|
if err := http.NewResponseController(c.Raw()).Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !c.Responded() || c.Status() != 200 || !recorder.Flushed {
|
|
t.Fatal("flush not tracked")
|
|
}
|
|
}
|
|
|
|
func TestLegacyStreamingAcrossTransports(t *testing.T) {
|
|
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
|
|
t.Run(transport, func(t *testing.T) {
|
|
release := make(chan struct{})
|
|
defer close(release)
|
|
app := omni.New(omni.WithTransport(transport))
|
|
app.GET("/stream", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = io.WriteString(w, "first\n")
|
|
if err := http.NewResponseController(w).Flush(); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
})))
|
|
app.GET("/file", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Disposition", `attachment; filename="data.bin"`)
|
|
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 2<<20)))
|
|
})))
|
|
app.HandleWithBodyLimit("POST", "/bounded", 32, nil, func(c *httpx.Context) error {
|
|
_, err := io.Copy(io.Discard, c.Request().Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.NoContent()
|
|
})
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- app.ServeContext(ctx, listener) }()
|
|
defer func() {
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(15 * time.Second):
|
|
t.Error("shutdown timed out")
|
|
}
|
|
}()
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
defer client.CloseIdleConnections()
|
|
for _, body := range []io.Reader{strings.NewReader(strings.Repeat("x", 33)), io.NopCloser(strings.NewReader(strings.Repeat("x", 33)))} {
|
|
res, err := client.Post("http://"+listener.Addr().String()+"/bounded", "application/octet-stream", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
res.Body.Close()
|
|
if res.StatusCode != 413 {
|
|
t.Fatalf("real transport body limit: %d", res.StatusCode)
|
|
}
|
|
}
|
|
res, err := client.Get("http://" + listener.Addr().String() + "/stream")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := make([]byte, 6)
|
|
_, err = io.ReadFull(res.Body, first)
|
|
res.Body.Close()
|
|
if err != nil || string(first) != "first\n" {
|
|
t.Fatalf("stream was buffered: %s %v", first, err)
|
|
}
|
|
res, err = client.Get("http://" + listener.Addr().String() + "/file")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
size, err := io.Copy(io.Discard, res.Body)
|
|
res.Body.Close()
|
|
if err != nil || size != 2<<20 || res.Header.Get("Content-Disposition") == "" {
|
|
t.Fatalf("file: %d %v", size, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStandardMiddlewareStateAndShortCircuit(t *testing.T) {
|
|
constructions, calls := 0, 0
|
|
app := omni.New()
|
|
defer app.Close()
|
|
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
|
constructions++
|
|
count := 0
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
if count == 2 {
|
|
http.Error(w, "denied", 401)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}))
|
|
app.GET("/", func(c *httpx.Context) error { calls++; return c.NoContent() })
|
|
for _, status := range []int{204, 401, 204} {
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
|
if w.Code != status {
|
|
t.Fatalf("got %d want %d", w.Code, status)
|
|
}
|
|
}
|
|
if constructions != 1 || calls != 2 {
|
|
t.Fatalf("middleware lifecycle: %d constructions, %d calls", constructions, calls)
|
|
}
|
|
}
|
|
|
|
func TestStandardMiddlewareTransformsErrorAndPreservesCancellation(t *testing.T) {
|
|
app := omni.New()
|
|
defer app.Close()
|
|
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// A legacy middleware may buffer its own small response. The bridge must
|
|
// report its eventual physical status, and encode errors before it unwinds.
|
|
temp := httptest.NewRecorder()
|
|
next.ServeHTTP(temp, r)
|
|
if temp.Code != 404 {
|
|
t.Errorf("inner error not encoded: %d", temp.Code)
|
|
}
|
|
w.WriteHeader(409)
|
|
_, _ = io.WriteString(w, strings.ToUpper(temp.Body.String()))
|
|
})
|
|
}))
|
|
app.GET("/", func(c *httpx.Context) error {
|
|
if !errors.Is(c.Context().Err(), context.Canceled) {
|
|
t.Error("cancellation lost")
|
|
}
|
|
return omni.NotFound("missing", "missing")
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
|
|
if w.Code != 409 || !strings.Contains(w.Body.String(), "MISSING") {
|
|
t.Fatalf("%d %s", w.Code, w.Body)
|
|
}
|
|
}
|
|
|
|
func TestRouteBodyLimitBeforeMiddleware(t *testing.T) {
|
|
for _, length := range []int64{-1, 33} {
|
|
t.Run(fmt.Sprint(length), func(t *testing.T) {
|
|
app := omni.New()
|
|
defer app.Close()
|
|
read := 0
|
|
app.Use(func(next httpx.Handler) httpx.Handler {
|
|
return func(c *httpx.Context) error {
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
read = len(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return next(c)
|
|
}
|
|
})
|
|
app.Group("/api").HandleWithBodyLimit("POST", "/upload", 32, nil, func(c *httpx.Context) error { t.Error("called oversized handler"); return nil })
|
|
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader(strings.Repeat("x", 33)))
|
|
r.ContentLength = length
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, r)
|
|
if w.Code != 413 || read > 32 {
|
|
t.Fatalf("limit: status=%d read=%d", w.Code, read)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMultipartCleanupOnPanic(t *testing.T) {
|
|
temp := t.TempDir()
|
|
t.Setenv("TMPDIR", temp)
|
|
var body bytes.Buffer
|
|
form := multipart.NewWriter(&body)
|
|
part, err := form.CreateFormFile("file", "x.bin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = part.Write(bytes.Repeat([]byte("x"), 4096))
|
|
_ = form.Close()
|
|
app := omni.New()
|
|
defer app.Close()
|
|
app.HandleWithBodyLimit("POST", "/", 8192, nil, httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
panic("after disk spill")
|
|
})))
|
|
r := httptest.NewRequest("POST", "/", &body)
|
|
r.Header.Set("Content-Type", form.FormDataContentType())
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, r)
|
|
entries, err := os.ReadDir(temp)
|
|
if w.Code != 500 || err != nil || len(entries) != 0 {
|
|
t.Fatalf("cleanup: %d %v %v", w.Code, entries, err)
|
|
}
|
|
}
|
|
|
|
func TestStandardMiddlewareCommitsBeforeCallingNext(t *testing.T) {
|
|
app := omni.New()
|
|
defer app.Close()
|
|
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(202)
|
|
_, _ = io.WriteString(w, "accepted")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}))
|
|
app.GET("/", func(c *httpx.Context) error {
|
|
if !c.Responded() || c.Status() != 202 {
|
|
t.Error("lost middleware commit")
|
|
}
|
|
return errors.New("late failure")
|
|
})
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
|
if w.Code != 202 || w.Body.String() != "accepted" {
|
|
t.Fatalf("late append: %d %q", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBridgeStandardPathValues(t *testing.T) {
|
|
app := omni.New()
|
|
defer app.Close()
|
|
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.PathValue("itemId") != "abc" {
|
|
t.Error("middleware path value lost")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}))
|
|
app.GET("/items/:itemId", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.PathValue("itemId")) })))
|
|
w := httptest.NewRecorder()
|
|
app.ServeHTTP(w, httptest.NewRequest("GET", "/items/abc", nil))
|
|
if w.Body.String() != "abc" {
|
|
t.Fatal("handler path value lost")
|
|
}
|
|
}
|