mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
768 lines
23 KiB
Go
768 lines
23 KiB
Go
package agentcli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const fifoTestDeadline = 3 * time.Second
|
|
|
|
func TestDefaultUploadRequestTimeout(t *testing.T) {
|
|
if defaultUploadRequestTimeout != 180*time.Second {
|
|
t.Fatalf("defaultUploadRequestTimeout = %s, want 180s", defaultUploadRequestTimeout)
|
|
}
|
|
}
|
|
|
|
func receiveWithin[T any](ch <-chan T, timeout time.Duration) (T, bool) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case value := <-ch:
|
|
return value, true
|
|
case <-timer.C:
|
|
var zero T
|
|
return zero, false
|
|
}
|
|
}
|
|
|
|
type multipartWriteRecorder struct {
|
|
headerErr error
|
|
cleanupErr error
|
|
source *terminalRecordingReader
|
|
headerAttempted bool
|
|
headerFailed bool
|
|
cleanupAttempted bool
|
|
}
|
|
|
|
func (w *multipartWriteRecorder) Write(p []byte) (int, error) {
|
|
if w.headerFailed || (w.source != nil && w.source.finished) {
|
|
w.cleanupAttempted = true
|
|
if w.cleanupErr != nil {
|
|
return 0, w.cleanupErr
|
|
}
|
|
return len(p), nil
|
|
}
|
|
if !w.headerAttempted {
|
|
w.headerAttempted = true
|
|
if w.headerErr != nil {
|
|
w.headerFailed = true
|
|
return 0, w.headerErr
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
type terminalRecordingReader struct {
|
|
reader io.Reader
|
|
terminalErr error
|
|
finished bool
|
|
}
|
|
|
|
func (r *terminalRecordingReader) Read(p []byte) (int, error) {
|
|
n, err := r.reader.Read(p)
|
|
if errors.Is(err, io.EOF) {
|
|
r.finished = true
|
|
if r.terminalErr != nil {
|
|
return n, r.terminalErr
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
type dataThenErrorReader struct {
|
|
data []byte
|
|
sourceErr error
|
|
delivered bool
|
|
}
|
|
|
|
type closeRecordingBody struct {
|
|
reader io.Reader
|
|
closed bool
|
|
}
|
|
|
|
type gatedUploadSource struct {
|
|
payload []byte
|
|
started chan struct{}
|
|
release chan struct{}
|
|
completed chan struct{}
|
|
releaseOnce sync.Once
|
|
delivered bool
|
|
closed bool
|
|
}
|
|
|
|
func newGatedUploadSource(payload []byte) *gatedUploadSource {
|
|
return &gatedUploadSource{
|
|
payload: payload,
|
|
started: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
completed: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *gatedUploadSource) Read(p []byte) (int, error) {
|
|
if s.delivered {
|
|
return 0, io.EOF
|
|
}
|
|
s.delivered = true
|
|
close(s.started)
|
|
<-s.release
|
|
n := copy(p, s.payload)
|
|
close(s.completed)
|
|
return n, nil
|
|
}
|
|
|
|
func (s *gatedUploadSource) Close() error {
|
|
s.unblock()
|
|
s.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (s *gatedUploadSource) unblock() {
|
|
s.releaseOnce.Do(func() { close(s.release) })
|
|
}
|
|
|
|
type releaseOnReadBody struct {
|
|
reader io.Reader
|
|
release func()
|
|
closed bool
|
|
}
|
|
|
|
func (b *releaseOnReadBody) Read(p []byte) (int, error) {
|
|
b.release()
|
|
return b.reader.Read(p)
|
|
}
|
|
|
|
func (b *releaseOnReadBody) Close() error {
|
|
b.release()
|
|
b.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (b *closeRecordingBody) Read(p []byte) (int, error) {
|
|
return b.reader.Read(p)
|
|
}
|
|
|
|
func (b *closeRecordingBody) Close() error {
|
|
b.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (r *dataThenErrorReader) Read(p []byte) (int, error) {
|
|
if r.delivered {
|
|
return 0, r.sourceErr
|
|
}
|
|
r.delivered = true
|
|
return copy(p, r.data), nil
|
|
}
|
|
|
|
func TestUploadFileStreamsMultipartBody(t *testing.T) {
|
|
payload := bytes.Repeat([]byte("streamed-payload-"), 128*1024)
|
|
filePath := filepath.Join(t.TempDir(), "payload.bin")
|
|
if err := os.WriteFile(filePath, payload, 0o600); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.ContentLength != -1 {
|
|
t.Errorf("content length = %d, want -1 for streamed request", r.ContentLength)
|
|
}
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("create multipart reader: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
part, err := reader.NextPart()
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("read multipart part: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer func() { _ = part.Close() }()
|
|
if part.FormName() != "file" || part.FileName() != "payload.bin" {
|
|
http.Error(w, "unexpected multipart metadata", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if got := part.Header.Get("Content-Type"); got != "application/octet-stream" {
|
|
http.Error(w, "unexpected multipart content type: "+got, http.StatusBadRequest)
|
|
return
|
|
}
|
|
got, err := io.ReadAll(part)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("read multipart content: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
http.Error(w, "multipart content mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"reference":"dify-file-ref:canonical"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewHTTPClient(&Environment{})
|
|
body, err := client.uploadFile(server.URL, filePath, "payload.bin", "application/octet-stream")
|
|
if err != nil {
|
|
t.Fatalf("upload file: %v", err)
|
|
}
|
|
if string(body) != `{"reference":"dify-file-ref:canonical"}` {
|
|
t.Fatalf("response body = %s", body)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileStartsRequestBeforeSourceEOF(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("uses a local HTTP server and FIFO coordination")
|
|
}
|
|
mkfifo, err := exec.LookPath("mkfifo")
|
|
if err != nil {
|
|
t.Skip("mkfifo is unavailable")
|
|
}
|
|
fifoPath := filepath.Join(t.TempDir(), "stream.bin")
|
|
if err := exec.Command(mkfifo, "-m", "600", fifoPath).Run(); err != nil {
|
|
t.Fatalf("create FIFO: %v", err)
|
|
}
|
|
|
|
requestStarted := make(chan struct{})
|
|
continueSource := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(requestStarted)
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
part, err := reader.NextPart()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer func() { _ = part.Close() }()
|
|
body, err := io.ReadAll(part)
|
|
if err != nil || string(body) != "prefix-suffix" {
|
|
http.Error(w, "unexpected streamed body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"reference":"dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ=="}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
writerDone := make(chan error, 1)
|
|
go func() {
|
|
file, err := os.OpenFile(fifoPath, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
writerDone <- err
|
|
return
|
|
}
|
|
if _, err = file.WriteString("prefix-"); err == nil {
|
|
<-continueSource
|
|
_, err = file.WriteString("suffix")
|
|
}
|
|
if closeErr := file.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
writerDone <- err
|
|
}()
|
|
|
|
uploadDone := make(chan error, 1)
|
|
go func() {
|
|
client := NewHTTPClient(&Environment{})
|
|
_, err := client.uploadFile(server.URL, fifoPath, "stream.bin", "application/octet-stream")
|
|
uploadDone <- err
|
|
}()
|
|
|
|
releaseSource := sync.OnceFunc(func() { close(continueSource) })
|
|
writerJoined := false
|
|
uploadJoined := false
|
|
defer func() {
|
|
releaseSource()
|
|
server.CloseClientConnections()
|
|
if !writerJoined {
|
|
if err, ok := receiveWithin(writerDone, fifoTestDeadline); !ok {
|
|
t.Errorf("cleanup: FIFO writer did not finish within %s", fifoTestDeadline)
|
|
} else if err != nil {
|
|
t.Errorf("cleanup: FIFO writer failed: %v", err)
|
|
}
|
|
}
|
|
if !uploadJoined {
|
|
if err, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
|
t.Errorf("cleanup: upload did not finish within %s", fifoTestDeadline)
|
|
} else if err != nil {
|
|
t.Errorf("cleanup: upload failed: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
if _, ok := receiveWithin(requestStarted, fifoTestDeadline); !ok {
|
|
t.Fatalf("HTTP request did not start within %s before the source reached EOF", fifoTestDeadline)
|
|
}
|
|
releaseSource()
|
|
|
|
writerErr, ok := receiveWithin(writerDone, fifoTestDeadline)
|
|
if !ok {
|
|
t.Fatalf("FIFO writer did not finish within %s after source release", fifoTestDeadline)
|
|
}
|
|
writerJoined = true
|
|
if writerErr != nil {
|
|
t.Fatalf("write FIFO: %v", writerErr)
|
|
}
|
|
uploadErr, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
|
if !ok {
|
|
t.Fatalf("upload did not finish within %s after source EOF", fifoTestDeadline)
|
|
}
|
|
uploadJoined = true
|
|
if uploadErr != nil {
|
|
t.Fatalf("upload FIFO: %v", uploadErr)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileTransportFailureDoesNotBlockWriter(t *testing.T) {
|
|
const querySecret = "signed-upload-credential"
|
|
uploadURL := "https://upload.example/path?X-Amz-Credential=" + querySecret
|
|
source := &closeRecordingBody{reader: strings.NewReader("payload")}
|
|
client := NewHTTPClient(&Environment{})
|
|
client.openUploadFile = func(string) (io.ReadCloser, error) {
|
|
return source, nil
|
|
}
|
|
requestBodyReady := make(chan io.ReadCloser, 1)
|
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
|
requestBodyReady <- req.Body
|
|
return nil, errors.New("deterministic transport failure")
|
|
}
|
|
|
|
uploadDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.uploadFile(uploadURL, "source-path", "payload.bin", "application/octet-stream")
|
|
uploadDone <- err
|
|
}()
|
|
|
|
var requestBody io.ReadCloser
|
|
uploadJoined := false
|
|
defer func() {
|
|
_ = source.Close()
|
|
if requestBody == nil {
|
|
requestBody, _ = receiveWithin(requestBodyReady, fifoTestDeadline)
|
|
}
|
|
if requestBody != nil {
|
|
_ = requestBody.Close()
|
|
}
|
|
if !uploadJoined {
|
|
if _, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
|
t.Errorf("cleanup: transport-failure upload did not finish within %s", fifoTestDeadline)
|
|
}
|
|
}
|
|
}()
|
|
|
|
var ok bool
|
|
requestBody, ok = receiveWithin(requestBodyReady, fifoTestDeadline)
|
|
if !ok {
|
|
t.Fatalf("upload request did not start within %s", fifoTestDeadline)
|
|
}
|
|
|
|
err, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
|
if !ok {
|
|
t.Fatalf("transport-failure upload did not finish within %s", fifoTestDeadline)
|
|
}
|
|
uploadJoined = true
|
|
|
|
if err == nil || err.Error() != "upload request failed" {
|
|
t.Fatalf("error = %v, want upload request failure", err)
|
|
}
|
|
if !source.closed {
|
|
t.Fatal("source file was not closed after transport failure")
|
|
}
|
|
if strings.Contains(err.Error(), uploadURL) || strings.Contains(err.Error(), querySecret) {
|
|
t.Fatalf("error leaked signed upload URL credentials: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileInvalidSignedURLDoesNotLeakCredentials(t *testing.T) {
|
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
const querySecret = "signed-upload-credential"
|
|
uploadURL := "http://example.test/upload?X-Amz-Credential=" + querySecret + "\n"
|
|
|
|
client := NewHTTPClient(&Environment{})
|
|
_, err := client.uploadFile(uploadURL, filePath, "payload.txt", "text/plain")
|
|
if err == nil || err.Error() != "create upload request: invalid signed upload URL" {
|
|
t.Fatalf("error = %v, want invalid signed upload URL failure", err)
|
|
}
|
|
if strings.Contains(err.Error(), uploadURL) || strings.Contains(err.Error(), querySecret) {
|
|
t.Fatalf("error leaked signed upload URL credentials: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileRejectsOversizedResponse(t *testing.T) {
|
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = w.Write(bytes.Repeat([]byte("x"), 1024*1024+1))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewHTTPClient(&Environment{})
|
|
_, err := client.uploadFile(server.URL, filePath, "payload.txt", "text/plain")
|
|
if err == nil || !strings.Contains(err.Error(), "upload response exceeds") {
|
|
t.Fatalf("error = %v, want bounded-response failure", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckAgentStubHTTPErrorParsesSupportedResponseShapes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "string detail",
|
|
body: `{"detail":"invalid request"}`,
|
|
wantMessage: "invalid request",
|
|
},
|
|
{
|
|
name: "non JSON body",
|
|
body: "gateway unavailable",
|
|
wantMessage: "gateway unavailable",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := checkAgentStubHTTPError([]byte(test.body), http.StatusUnauthorized)
|
|
var httpErr *agentStubHTTPError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("error = %v, want *agentStubHTTPError", err)
|
|
}
|
|
if httpErr.statusCode != http.StatusUnauthorized || httpErr.code != "" {
|
|
t.Fatalf("parsed error = %#v", httpErr)
|
|
}
|
|
if !strings.Contains(err.Error(), test.wantMessage) {
|
|
t.Fatalf("error = %q, want substring %q", err, test.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAgentStubAuthorizationExpiryErrorExplainsHowToRefresh(t *testing.T) {
|
|
err := checkAgentStubHTTPError(
|
|
[]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`),
|
|
http.StatusUnauthorized,
|
|
)
|
|
var httpErr *agentStubHTTPError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("error = %v, want *agentStubHTTPError", err)
|
|
}
|
|
if httpErr.statusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d", httpErr.statusCode, http.StatusUnauthorized)
|
|
}
|
|
if httpErr.code != agentStubAuthorizationExpiredCode || httpErr.message != "expired" {
|
|
t.Fatalf("parsed error = %#v", httpErr)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"expired after 5 minutes",
|
|
"will not refresh automatically",
|
|
"start a new shell tool call",
|
|
"retry the command",
|
|
} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error = %q, want substring %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolFileUploadURLAloneOptsIntoStructuredExpiration(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("expose_expiration") != "true" {
|
|
t.Errorf("expose_expiration = %q, want true", r.URL.Query().Get("expose_expiration"))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := newHTTPStubClient(&Environment{URL: server.URL, AuthJWE: "token"})
|
|
_, toolFileErr := client.CreateToolFileUploadURL(context.Background(), "report.pdf", "application/pdf")
|
|
|
|
if toolFileErr == nil || !strings.Contains(toolFileErr.Error(), "start a new shell tool call") {
|
|
t.Fatalf("ToolFile error = %v, want structured expiry guidance", toolFileErr)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileReturnsNonSuccessStatus(t *testing.T) {
|
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewHTTPClient(&Environment{})
|
|
_, err := client.uploadFile(server.URL, filePath, "payload.txt", "text/plain")
|
|
if err == nil || !strings.Contains(err.Error(), "upload failed with status 413") {
|
|
t.Fatalf("error = %v, want non-success status", err)
|
|
}
|
|
}
|
|
|
|
func uploadFileWithGatedEarlyResponse(t *testing.T, statusCode int, body string) error {
|
|
t.Helper()
|
|
source := newGatedUploadSource([]byte("payload"))
|
|
responseBody := &releaseOnReadBody{reader: strings.NewReader(body), release: source.unblock}
|
|
requestDone := make(chan error, 1)
|
|
var orderingErr error
|
|
client := NewHTTPClient(&Environment{})
|
|
client.openUploadFile = func(string) (io.ReadCloser, error) {
|
|
return source, nil
|
|
}
|
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
|
go func() {
|
|
_, err := io.Copy(io.Discard, req.Body)
|
|
requestDone <- err
|
|
}()
|
|
if _, ok := receiveWithin(source.started, fifoTestDeadline); !ok {
|
|
orderingErr = errors.New("upload source did not start before the early response")
|
|
source.unblock()
|
|
return nil, orderingErr
|
|
}
|
|
select {
|
|
case <-source.completed:
|
|
orderingErr = errors.New("upload source completed before the early response")
|
|
return nil, orderingErr
|
|
default:
|
|
}
|
|
return &http.Response{StatusCode: statusCode, Body: responseBody}, nil
|
|
}
|
|
|
|
uploadDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.uploadFile("https://upload.example/path", "source-path", "payload.bin", "application/octet-stream")
|
|
uploadDone <- err
|
|
}()
|
|
uploadJoined := false
|
|
requestJoined := false
|
|
defer func() {
|
|
source.unblock()
|
|
if !uploadJoined {
|
|
if _, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
|
t.Errorf("cleanup: early-response upload did not finish within %s", fifoTestDeadline)
|
|
}
|
|
}
|
|
if !requestJoined {
|
|
if _, ok := receiveWithin(requestDone, fifoTestDeadline); !ok {
|
|
t.Errorf("cleanup: early-response request drain did not finish within %s", fifoTestDeadline)
|
|
}
|
|
}
|
|
}()
|
|
|
|
uploadErr, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
|
if !ok {
|
|
t.Fatalf("early-response upload did not finish within %s", fifoTestDeadline)
|
|
}
|
|
uploadJoined = true
|
|
if _, ok := receiveWithin(requestDone, fifoTestDeadline); !ok {
|
|
t.Fatalf("early-response request drain did not finish within %s", fifoTestDeadline)
|
|
}
|
|
requestJoined = true
|
|
if orderingErr != nil {
|
|
t.Fatalf("invalid early-response ordering: %v", orderingErr)
|
|
}
|
|
if _, ok := receiveWithin(source.completed, fifoTestDeadline); !ok {
|
|
t.Fatalf("upload source did not finish within %s after response processing", fifoTestDeadline)
|
|
}
|
|
if !source.closed {
|
|
t.Fatal("upload source was not closed before uploadFile returned")
|
|
}
|
|
if !responseBody.closed {
|
|
t.Fatal("early response body was not closed before uploadFile returned")
|
|
}
|
|
return uploadErr
|
|
}
|
|
|
|
func TestUploadFileReturnsEarlyNonSuccessStatusInsteadOfWriterAbort(t *testing.T) {
|
|
err := uploadFileWithGatedEarlyResponse(
|
|
t,
|
|
http.StatusRequestEntityTooLarge,
|
|
"too large without reading body\n",
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "upload failed with status 413: too large without reading body") {
|
|
t.Fatalf("error = %v, want early HTTP 413 response", err)
|
|
}
|
|
if strings.Contains(err.Error(), "upload request aborted") || strings.Contains(err.Error(), "closed pipe") {
|
|
t.Fatalf("error exposed internal multipart abort: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileRejectsEarlySuccessBeforeMultipartCompletes(t *testing.T) {
|
|
err := uploadFileWithGatedEarlyResponse(t, http.StatusOK, `{"reference":"incomplete"}`)
|
|
if err == nil || err.Error() != "upload request completed before multipart body was fully written" {
|
|
t.Fatalf("error = %v, want incomplete multipart failure", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFilePropagatesSourceReadFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = w.Write([]byte(`{"reference":"unused"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewHTTPClient(&Environment{})
|
|
_, err := client.uploadFile(server.URL, t.TempDir(), "directory", "application/octet-stream")
|
|
if err == nil || !strings.Contains(err.Error(), "copy file content") {
|
|
t.Fatalf("error = %v, want source read failure", err)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileClosesResourcesAndJoinsWriterOnResponseOutcomes(t *testing.T) {
|
|
responseReadErr := errors.New("response read failed")
|
|
tests := []struct {
|
|
name string
|
|
statusCode int
|
|
responseReader io.Reader
|
|
wantError string
|
|
}{
|
|
{
|
|
name: "success",
|
|
statusCode: http.StatusOK,
|
|
responseReader: strings.NewReader(`{"reference":"dify-file-ref:canonical"}`),
|
|
},
|
|
{
|
|
name: "non-2xx",
|
|
statusCode: http.StatusRequestEntityTooLarge,
|
|
responseReader: strings.NewReader("too large"),
|
|
wantError: "upload failed with status 413",
|
|
},
|
|
{
|
|
name: "response body read error",
|
|
statusCode: http.StatusOK,
|
|
responseReader: &dataThenErrorReader{data: []byte("partial"), sourceErr: responseReadErr},
|
|
wantError: "read upload response: response read failed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
source := &closeRecordingBody{reader: strings.NewReader("payload")}
|
|
var responseBody *closeRecordingBody
|
|
client := NewHTTPClient(&Environment{})
|
|
client.openUploadFile = func(path string) (io.ReadCloser, error) {
|
|
if path != "source-path" {
|
|
t.Fatalf("source path = %q", path)
|
|
}
|
|
return source, nil
|
|
}
|
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
|
if _, err := io.Copy(io.Discard, req.Body); err != nil {
|
|
t.Fatalf("drain request body: %v", err)
|
|
}
|
|
responseBody = &closeRecordingBody{reader: tt.responseReader}
|
|
return &http.Response{StatusCode: tt.statusCode, Body: responseBody}, nil
|
|
}
|
|
|
|
_, err := client.uploadFile("https://upload.example/path", "source-path", "payload.txt", "text/plain")
|
|
|
|
if tt.wantError == "" {
|
|
if err != nil {
|
|
t.Fatalf("upload file: %v", err)
|
|
}
|
|
} else if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
|
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
|
|
}
|
|
if !source.closed {
|
|
t.Fatal("source file was not closed before uploadFile returned")
|
|
}
|
|
if responseBody == nil || !responseBody.closed {
|
|
t.Fatal("response body was not closed before uploadFile returned")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteMultipartFileAlwaysAttemptsCloseAndPreservesPrimaryError(t *testing.T) {
|
|
t.Run("create part failure", func(t *testing.T) {
|
|
createErr := errors.New("create part failed")
|
|
closeErr := errors.New("close failed")
|
|
destination := &multipartWriteRecorder{headerErr: createErr, cleanupErr: closeErr}
|
|
writer := multipart.NewWriter(destination)
|
|
|
|
err := writeMultipartFile(
|
|
writer,
|
|
strings.NewReader("payload"),
|
|
"payload.txt",
|
|
"text/plain",
|
|
)
|
|
|
|
if !errors.Is(err, createErr) {
|
|
t.Fatalf("error = %v, want CreatePart failure", err)
|
|
}
|
|
if !destination.headerAttempted || !destination.cleanupAttempted {
|
|
t.Fatal("multipart header and cleanup were not both attempted")
|
|
}
|
|
})
|
|
|
|
t.Run("copy failure", func(t *testing.T) {
|
|
sourceErr := errors.New("source read failed")
|
|
closeErr := errors.New("close failed")
|
|
source := &terminalRecordingReader{reader: strings.NewReader("payload"), terminalErr: sourceErr}
|
|
destination := &multipartWriteRecorder{cleanupErr: closeErr, source: source}
|
|
writer := multipart.NewWriter(destination)
|
|
|
|
err := writeMultipartFile(
|
|
writer,
|
|
source,
|
|
"payload.txt",
|
|
"text/plain",
|
|
)
|
|
|
|
if !errors.Is(err, sourceErr) {
|
|
t.Fatalf("error = %v, want source copy failure", err)
|
|
}
|
|
if !destination.cleanupAttempted {
|
|
t.Fatal("multipart cleanup was not attempted after the source read failure")
|
|
}
|
|
})
|
|
|
|
t.Run("close failure", func(t *testing.T) {
|
|
closeErr := errors.New("close failed")
|
|
source := &terminalRecordingReader{reader: strings.NewReader("payload")}
|
|
destination := &multipartWriteRecorder{cleanupErr: closeErr, source: source}
|
|
writer := multipart.NewWriter(destination)
|
|
|
|
err := writeMultipartFile(
|
|
writer,
|
|
source,
|
|
"payload.txt",
|
|
"text/plain",
|
|
)
|
|
|
|
if !errors.Is(err, closeErr) || !strings.Contains(err.Error(), "close multipart writer") {
|
|
t.Fatalf("error = %v, want multipart Close failure", err)
|
|
}
|
|
if !destination.cleanupAttempted {
|
|
t.Fatal("multipart cleanup was not attempted after source EOF")
|
|
}
|
|
})
|
|
}
|