Files
go-omni/httpx/transfer_test.go

272 lines
9.4 KiB
Go

package httpx_test
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/iotest"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type testStream struct {
io.Reader
closed int
closeErr error
}
func (s *testStream) Close() error { s.closed++; return s.closeErr }
type brokenReader struct{ read bool }
func (r *brokenReader) Read(p []byte) (int, error) {
if r.read {
return 0, errors.New("source failed")
}
r.read = true
return copy(p, "partial"), nil
}
func TestStreamOwnershipAndCommit(t *testing.T) {
for _, tc := range []struct {
name, media string
reader io.Reader
wantStatus int
wantBody string
closeErr error
}{
{name: "download", media: "application/octet-stream", reader: strings.NewReader("bytes"), wantStatus: 200, wantBody: "bytes"},
{name: "invalidType", media: "text/html", reader: strings.NewReader("secret"), wantStatus: 500},
{name: "readError", media: "application/octet-stream", reader: &brokenReader{}, wantStatus: 200, wantBody: "partial"},
{name: "closeError", media: "application/octet-stream", reader: strings.NewReader("done"), wantStatus: 200, wantBody: "done", closeErr: errors.New("close failed")},
} {
t.Run(tc.name, func(t *testing.T) {
stream := &testStream{Reader: tc.reader, closeErr: tc.closeErr}
app := omni.New()
app.GET("/file", func(c *httpx.Context) error {
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: stream, ContentType: tc.media, Name: "文件.txt"})
})
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/file", nil))
if stream.closed != 1 || out.Code != tc.wantStatus {
t.Fatalf("closed=%d status=%d body=%s", stream.closed, out.Code, out.Body.String())
}
if tc.wantBody != "" && out.Body.String() != tc.wantBody {
t.Fatal("error JSON appended to stream", out.Body.String())
}
if tc.wantStatus == 500 && strings.Contains(out.Body.String(), "secret") {
t.Fatal("stream written before validation")
}
})
}
}
func TestBodyCompletionEnforcesUnreadLimit(t *testing.T) {
app := omni.New()
app.HandleWithBodyLimit("POST", "/raw", 4, nil, func(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "raw")
if err != nil {
return err
}
buf := make([]byte, 1)
if _, err = io.ReadFull(body.Raw, buf); err != nil {
return err
}
if err = body.Complete(); err != nil {
return err
}
return c.OK("accepted")
})
req := httptest.NewRequest("POST", "/raw", strings.NewReader("12345"))
req.ContentLength = -1
out := httptest.NewRecorder()
app.ServeHTTP(out, req)
if out.Code != http.StatusRequestEntityTooLarge {
t.Fatal(out.Code, out.Body.String())
}
}
func TestRawBodyPreservesBytesAndHeaders(t *testing.T) {
req := httptest.NewRequest("POST", "/", strings.NewReader(" {\n \"a\":1} "))
req.Header.Set("X-Signature", "original")
body, err := httpx.NewBody(req, "raw")
if err != nil {
t.Fatal(err)
}
body.Header.Set("X-Signature", "changed")
if req.Header.Get("X-Signature") != "original" {
t.Fatal("aliased headers")
}
data, err := io.ReadAll(body.Raw)
if err != nil || string(data) != " {\n \"a\":1} " {
t.Fatal(string(data), err)
}
}
func TestMultipartValidationBeforeSuccess(t *testing.T) {
const prefix = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"x.txt\"\r\n\r\n"
for _, tc := range []struct {
name, wire, action string
status int
limit int64
}{
{"unreadValid", prefix + "hello\r\n--boundary--\r\n", "skip", 200, 1024},
{"finalWithoutNewline", prefix + "hello\r\n--boundary--", "skip", 200, 1024},
{"finalWithWhitespace", prefix + "hello\r\n--boundary-- \t", "skip", 200, 1024},
{"epilogue", prefix + "hello\r\n--boundary--\r\nignored epilogue", "skip", 200, 1024},
{"epilogueOverLimit", prefix + "hello\r\n--boundary--\r\n" + strings.Repeat("x", 1024), "skip", 413, 1024},
{"unreadTruncated", prefix + "private file bytes", "skip", 400, 1024},
{"empty", "", "skip", 400, 1024},
{"emptyMultipart", "--boundary--\r\n", "skip", 200, 1024},
{"unterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "read", 400, 1024},
{"skippedUnterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "skip", 400, 1024},
{"boundaryInsideHeader", "--boundary\r\nX-Header: x\r\n--boundary--", "skip", 400, 1024},
{"readTruncated", prefix + "private file bytes", "read", 400, 1024},
{"closeTruncated", prefix + "private file bytes", "close", 400, 1024},
{"swallowedReadError", prefix + "private file bytes", "ignore", 400, 1024},
{"badHeader", "--boundary\r\nbroken header\r\n\r\nprivate file bytes", "read", 400, 1024},
{"overLimit", prefix + strings.Repeat("x", 150), "read", 413, 128},
{"unreadOverLimit", prefix + strings.Repeat("x", 150), "skip", 413, 128},
{"businessFailure", prefix + "hello\r\n--boundary--\r\n", "business", 500, 1024},
} {
t.Run(tc.name, func(t *testing.T) {
app := omni.New()
app.HandleWithBodyLimit("POST", "/upload", tc.limit, nil, func(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "multipart")
if err != nil {
return err
}
if tc.action == "business" {
return errors.New("private database error")
}
if tc.action != "skip" {
part, err := body.Parts.NextPart()
if err != nil {
return err
}
if tc.action == "close" {
if err = part.Close(); err != nil {
return err
}
} else {
_, err = io.Copy(io.Discard, part)
if err != nil && tc.action != "ignore" {
return err
}
}
}
if err = body.Complete(); err != nil {
return err
}
return c.OK("accepted")
})
req := httptest.NewRequest("POST", "/upload", strings.NewReader(tc.wire))
req.ContentLength = -1
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
out := httptest.NewRecorder()
app.ServeHTTP(out, req)
if out.Code != tc.status {
t.Fatalf("want %d, got %d: %s", tc.status, out.Code, out.Body.String())
}
if strings.Contains(out.Body.String(), "private") {
t.Fatal("internal contents leaked", out.Body.String())
}
})
}
}
func TestMultipartIterationAndRawParts(t *testing.T) {
const wire = "--b\r\nContent-Disposition: form-data; name=\"first\"; filename=\"x.txt\"\r\n\r\nskipped\r\n--b\r\nContent-Disposition: form-data; name=\"second\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\na=3Db\r\n--b--\r\n"
for _, raw := range []bool{false, true} {
req := httptest.NewRequest("POST", "/", strings.NewReader(wire))
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
body, err := httpx.NewBody(req, "multipart")
if err != nil {
t.Fatal(err)
}
first, err := body.Parts.NextPart()
if err != nil {
t.Fatal(err)
}
if first.FormName() != "first" || first.FileName() != "x.txt" {
t.Fatal("part metadata lost")
}
// NextPart must drain/close the unread first part without treating a
// valid part's EOF as the end of the entire request.
var second *httpx.MultipartPart
want := "a=b"
if raw {
second, err = body.Parts.NextRawPart()
want = "a=3Db"
} else {
second, err = body.Parts.NextPart()
}
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(second)
if err != nil || string(data) != want || second.FormName() != "second" {
t.Fatal(string(data), err)
}
if err = body.Complete(); err != nil {
t.Fatal(err)
}
if err = body.Complete(); err != nil {
t.Fatal("second Complete", err)
}
if err = first.Close(); err != nil {
t.Fatal("second Close", err)
}
if _, err = body.Parts.NextPart(); err != io.EOF {
t.Fatal("expected final EOF", err)
}
if _, err = first.Read(make([]byte, 1)); err != io.EOF {
t.Fatal("closed part remained readable", err)
}
}
}
func TestMultipartRequiresFormData(t *testing.T) {
for _, media := range []string{"multipart/mixed; boundary=b", "multipart/form-data", "multipart/form-data; boundary=\"\"", "application/json"} {
req := httptest.NewRequest("POST", "/", strings.NewReader("--b--\r\n"))
req.Header.Set("Content-Type", media)
_, err := httpx.NewBody(req, "multipart")
var public *apierrors.APIError
if !errors.As(err, &public) || public.Status != 415 {
t.Fatalf("media %q accepted: %v", media, err)
}
}
}
func TestMultipartBoundariesAcrossReads(t *testing.T) {
for _, tc := range []struct {
name, wire string
valid bool
}{
{"crlf", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--\r\n", true},
{"lf", "--b\nContent-Disposition: form-data; name=x\n\nabc\n--b--\n", true},
{"noNewline", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--", true},
{"headerEOF", "--b\r\nContent-Disposition: form-data; name=x", false},
{"secondHeaderEOF", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b\r\nContent-Disposition: form-data; name=y", false},
{"partialBoundary", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b-", false},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/", iotest.OneByteReader(strings.NewReader(tc.wire)))
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
body, err := httpx.NewBody(req, "multipart")
if err != nil {
t.Fatal(err)
}
err = body.Complete()
if tc.valid && err != nil {
t.Fatal(err)
}
if !tc.valid && (err == nil || apierrors.From(err).Status != 400) {
t.Fatal("expected malformed upload", err)
}
})
}
}