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) } }) } }