Files
go-omni/httpx/bridge_test.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")
}
}