272 lines
9.4 KiB
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)
|
|
}
|
|
})
|
|
}
|
|
}
|