feat: release managed service generation and contract diagnostics
This commit is contained in:
173
httpx/multipart.go
Normal file
173
httpx/multipart.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
// MultipartReader streams request parts and retains parsing failures, so a
|
||||
// partially consumed or invalid upload cannot later complete successfully.
|
||||
// It belongs to a single request and must be read sequentially.
|
||||
type MultipartReader struct {
|
||||
reader *multipart.Reader
|
||||
input *multipartInput
|
||||
current *MultipartPart
|
||||
err error
|
||||
done bool
|
||||
}
|
||||
|
||||
// NextPart closes the preceding part and returns the next part. Like the
|
||||
// standard library, it decodes quoted-printable content; NextRawPart does not.
|
||||
// Only a valid final boundary yields io.EOF; malformed input yields an error
|
||||
// with status 400. Body limits remain detectable as *http.MaxBytesError.
|
||||
func (r *MultipartReader) NextPart() (*MultipartPart, error) { return r.next(false) }
|
||||
|
||||
func (r *MultipartReader) NextRawPart() (*MultipartPart, error) { return r.next(true) }
|
||||
|
||||
func (r *MultipartReader) next(raw bool) (*MultipartPart, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
if r.done {
|
||||
return nil, io.EOF
|
||||
}
|
||||
if r.current != nil {
|
||||
if err := r.current.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var part *multipart.Part
|
||||
var err error
|
||||
if raw {
|
||||
part, err = r.reader.NextRawPart()
|
||||
} else {
|
||||
part, err = r.reader.NextPart()
|
||||
}
|
||||
if err == io.EOF {
|
||||
// The standard parser also returns bare EOF for truncated MIME
|
||||
// headers. Require an actual closing delimiter before treating EOF
|
||||
// as successful completion.
|
||||
if !r.input.ended() {
|
||||
return nil, r.record(io.ErrUnexpectedEOF)
|
||||
}
|
||||
r.done = true
|
||||
return nil, io.EOF
|
||||
}
|
||||
if err != nil {
|
||||
return nil, r.record(err)
|
||||
}
|
||||
r.current = &MultipartPart{Header: part.Header, part: part, owner: r}
|
||||
return r.current, nil
|
||||
}
|
||||
|
||||
func (r *MultipartReader) record(err error) error {
|
||||
if err == nil || err == io.EOF {
|
||||
return err
|
||||
}
|
||||
if r.err == nil {
|
||||
var limit *http.MaxBytesError
|
||||
if errors.As(err, &limit) {
|
||||
r.err = err
|
||||
} else {
|
||||
r.err = apierrors.New(400, "INVALID_MULTIPART", "invalid multipart request body").WithCause(err)
|
||||
}
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
// MultipartPart is a streaming request part. Close drains unread content and
|
||||
// reports read/parse errors; it never buffers the whole file or creates a file.
|
||||
type MultipartPart struct {
|
||||
Header textproto.MIMEHeader
|
||||
part *multipart.Part
|
||||
owner *MultipartReader
|
||||
closed bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *MultipartPart) FormName() string { return p.part.FormName() }
|
||||
func (p *MultipartPart) FileName() string { return p.part.FileName() }
|
||||
|
||||
func (p *MultipartPart) Read(buf []byte) (int, error) {
|
||||
if p.closed {
|
||||
if p.err != nil {
|
||||
return 0, p.err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
n, err := p.part.Read(buf)
|
||||
return n, p.owner.record(err)
|
||||
}
|
||||
|
||||
func (p *MultipartPart) Close() error {
|
||||
if !p.closed {
|
||||
_, p.err = io.Copy(io.Discard, p)
|
||||
p.closed = true
|
||||
}
|
||||
return p.err
|
||||
}
|
||||
|
||||
// multipartInput observes closing-delimiter lines without buffering file
|
||||
// content or changing bytes. The MIME parser still validates their syntax and
|
||||
// placement. Keep only a prefix offset, including across reader boundaries.
|
||||
type multipartInput struct {
|
||||
io.Reader
|
||||
boundary string
|
||||
matched int
|
||||
carriage, final, eof bool
|
||||
}
|
||||
|
||||
func (r *multipartInput) Read(p []byte) (int, error) {
|
||||
n, err := r.Reader.Read(p)
|
||||
r.observe(p[:n])
|
||||
if err == io.EOF {
|
||||
r.eof = true
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *multipartInput) ended() bool {
|
||||
return r.final || (r.eof && r.matched == len(r.boundary) && !r.carriage)
|
||||
}
|
||||
|
||||
func (r *multipartInput) observe(data []byte) {
|
||||
for len(data) > 0 && !r.final {
|
||||
if r.matched < 0 {
|
||||
newline := bytes.IndexByte(data, '\n')
|
||||
if newline < 0 {
|
||||
return
|
||||
}
|
||||
data, r.matched, r.carriage = data[newline+1:], 0, false
|
||||
continue
|
||||
}
|
||||
c := data[0]
|
||||
data = data[1:]
|
||||
if r.matched < len(r.boundary) {
|
||||
if c == r.boundary[r.matched] {
|
||||
r.matched++
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case c == '\n':
|
||||
r.final = true
|
||||
continue
|
||||
case !r.carriage && (c == ' ' || c == '\t'):
|
||||
continue
|
||||
case !r.carriage && c == '\r':
|
||||
r.carriage = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.matched, r.carriage = -1, false
|
||||
if c == '\n' {
|
||||
r.matched = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
128
httpx/transfer.go
Normal file
128
httpx/transfer.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
// Body borrows the limited request stream for the duration of a Logic call.
|
||||
// Raw contains the exact body bytes (never JSON-decoded). For multipart input,
|
||||
// consume Parts sequentially with NextPart and close each part. No temporary
|
||||
// files are created. Do not retain Body or its readers after Logic returns.
|
||||
type Body struct {
|
||||
Raw io.Reader
|
||||
Parts *MultipartReader
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func NewBody(r *http.Request, mode string) (*Body, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("httpx: request required")
|
||||
}
|
||||
b := &Body{Raw: r.Body, Header: r.Header.Clone()}
|
||||
if b.Raw == nil {
|
||||
b.Raw = http.NoBody
|
||||
}
|
||||
switch mode {
|
||||
case "raw":
|
||||
case "multipart":
|
||||
media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || media != "multipart/form-data" || params["boundary"] == "" {
|
||||
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
|
||||
}
|
||||
input := &multipartInput{Reader: b.Raw, boundary: "--" + params["boundary"] + "--"}
|
||||
request := *r
|
||||
request.Body = io.NopCloser(input)
|
||||
parts, err := request.MultipartReader()
|
||||
if err != nil {
|
||||
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
|
||||
}
|
||||
b.Parts = &MultipartReader{reader: parts, input: input}
|
||||
default:
|
||||
return nil, fmt.Errorf("httpx: unsupported body mode %q", mode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Complete validates remaining multipart parts and checks unread bytes against
|
||||
// the route limit before committing a success response. Slow clients remain
|
||||
// bounded by the server's read timeout. Do not read Raw alongside Parts.
|
||||
func (b *Body) Complete() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if b.Parts != nil {
|
||||
for {
|
||||
part, err := b.Parts.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = part.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Raw == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := io.Copy(io.Discard, b.Raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stream transfers ownership of Content to the generated handler. The handler
|
||||
// closes it even on failure, and writes it without the JSON response envelope.
|
||||
// Name enables an attachment filename; leave it empty for raw callback output.
|
||||
// ContentType may refine the declared type with parameters (such as charset).
|
||||
type Stream struct {
|
||||
Content io.ReadCloser
|
||||
Name string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func (s *Stream) Close() error {
|
||||
if s == nil || s.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Content.Close()
|
||||
}
|
||||
|
||||
func (c *Context) WriteStream(status int, declaredType string, stream *Stream) (err error) {
|
||||
if stream == nil {
|
||||
return errors.New("httpx: nil stream response")
|
||||
}
|
||||
defer func() { err = errors.Join(err, stream.Close()) }()
|
||||
if stream.Content == nil {
|
||||
return errors.New("httpx: stream content required")
|
||||
}
|
||||
if c.Responded() {
|
||||
return errors.New("httpx: response already committed")
|
||||
}
|
||||
if status < 200 || status > 599 || status == 204 || status == 205 || status == 304 {
|
||||
return errors.New("httpx: stream requires body-bearing status")
|
||||
}
|
||||
contentType := stream.ContentType
|
||||
if contentType == "" {
|
||||
contentType = declaredType
|
||||
}
|
||||
media, _, parseErr := mime.ParseMediaType(contentType)
|
||||
if parseErr != nil || media != declaredType || strings.ContainsAny(contentType, "\r\n") {
|
||||
return errors.New("httpx: stream content type differs from API contract")
|
||||
}
|
||||
writer := c.Raw()
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
if stream.Name != "" {
|
||||
writer.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": stream.Name}))
|
||||
}
|
||||
writer.WriteHeader(status)
|
||||
_, err = io.Copy(writer, stream.Content)
|
||||
return err
|
||||
}
|
||||
271
httpx/transfer_test.go
Normal file
271
httpx/transfer_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user