package api import ( "bytes" "context" "errors" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/dlclark/regexp2" "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) func TestOsPathRename_Rename(t *testing.T) { dirPath, err := gotenberg.NewFileSystem().MkdirAll() if err != nil { t.Fatalf("create working directory: %v", err) } path := "/tests/test/testdata/api/sample1.txt" copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) in, err := os.Open(path) if err != nil { t.Fatalf("open file: %v", err) } defer func() { err := in.Close() if err != nil { t.Fatalf("close file: %v", err) } }() out, err := os.Create(copyPath) if err != nil { t.Fatalf("create new file: %v", err) } defer func() { err := out.Close() if err != nil { t.Fatalf("close new file: %v", err) } }() _, err = io.Copy(out, in) if err != nil { t.Fatalf("copy file to new file: %v", err) } rename := new(osPathRename) newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString())) err = rename.Rename(copyPath, newPath) if err != nil { t.Errorf("expected no error but got: %v", err) } err = os.RemoveAll(dirPath) if err != nil { t.Fatalf("remove working directory: %v", err) } } func TestNewContext(t *testing.T) { defaultAllowList, err := regexp2.Compile("", 0) if err != nil { t.Fatalf("expected no error but got: %v", err) } defaultDenyList, err := regexp2.Compile("", 0) if err != nil { t.Fatalf("expected no error but got: %v", err) } defaultDownloadFromCfg := downloadFromConfig{ allowList: defaultAllowList, denyList: defaultDenyList, maxRetry: 1, disable: false, } for _, tc := range []struct { scenario string request *http.Request downloadFromCfg downloadFromConfig downloadFromSrv *echo.Echo expectContext *Context expectError bool expectHttpError bool expectHttpStatus int }{ { scenario: "http.ErrNotMultipart", request: httptest.NewRequest(http.MethodPost, "/", nil), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusUnsupportedMediaType, }, { scenario: "http.ErrMissingBoundary", request: func() *http.Request { req := httptest.NewRequest(http.MethodPost, "/", nil) req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm) return req }(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusUnsupportedMediaType, }, { scenario: "malformed body", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("foo", "foo") if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", nil) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: cannot unmarshal", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", "foo") if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: no URL", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: filtered URL", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromCfg: func() downloadFromConfig { denyList, err := regexp2.Compile("https://foo.bar", 0) if err != nil { t.Fatalf("expected no error but got: %v", err) } return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false} }(), expectError: true, }, { scenario: "invalid downloadFrom form field: unreachable URL", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: invalid status code", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromSrv: func() *echo.Echo { srv := echo.New() srv.HideBanner = true srv.GET("/", func(c echo.Context) error { return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound)) }) return srv }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: no 'Content-Disposition' header", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromSrv: func() *echo.Echo { srv := echo.New() srv.HideBanner = true srv.GET("/", func(c echo.Context) error { return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }) return srv }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromSrv: func() *echo.Echo { srv := echo.New() srv.HideBanner = true srv.GET("/", func(c echo.Context) error { c.Response().Header().Set(echo.HeaderContentDisposition, ";;") return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }) return srv }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromSrv: func() *echo.Echo { srv := echo.New() srv.HideBanner = true srv.GET("/", func(c echo.Context) error { c.Response().Header().Set(echo.HeaderContentDisposition, "inline;") return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }) return srv }(), downloadFromCfg: defaultDownloadFromCfg, expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, }, { scenario: "success", request: func() *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer func() { err := writer.Close() if err != nil { t.Fatalf("expected no error but got: %v", err) } }() err := writer.WriteField("foo", "foo") if err != nil { t.Fatalf("expected no error but got: %v", err) } part, err := writer.CreateFormFile("foo.txt", "foo.txt") if err != nil { t.Fatalf("expected no error but got: %v", err) } _, err = part.Write([]byte("foo")) if err != nil { t.Fatalf("expected no error but got: %v", err) } err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`) if err != nil { t.Fatalf("expected no error but got: %v", err) } req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) return req }(), downloadFromSrv: func() *echo.Echo { srv := echo.New() srv.HideBanner = true srv.GET("/", func(c echo.Context) error { if c.Request().Header.Get("User-Agent") != "Gotenberg" { t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent")) } if c.Request().Header.Get("X-Foo") != "Bar" { t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo")) } if c.Request().Header.Get("Gotenberg-Trace") != "123" { t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace")) } c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`) c.Response().Header().Set(echo.HeaderContentType, "text/plain") return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }) return srv }(), downloadFromCfg: defaultDownloadFromCfg, expectContext: &Context{ values: map[string][]string{ "foo": {"foo"}, "downloadFrom": { `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`, }, }, files: map[string]string{ "foo.txt": "foo.txt", "bar.txt": "bar.txt", // downloadFrom. }, }, expectError: false, expectHttpError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { if tc.downloadFromSrv != nil { go func() { err := tc.downloadFromSrv.Start(":80") if !errors.Is(err, http.ErrServerClosed) { t.Error(err) return } }() defer func() { err := tc.downloadFromSrv.Shutdown(context.TODO()) if err != nil { t.Error(err) } }() } handler := func(c echo.Context) error { ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.downloadFromCfg, "Gotenberg-Trace", "123") defer cancel() // Context already cancelled. defer cancel() if err != nil { return err } if tc.expectContext != nil { if !reflect.DeepEqual(tc.expectContext.values, ctx.values) { t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values) } if len(tc.expectContext.files) != len(ctx.files) { t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files)) } for key, value := range tc.expectContext.files { if !strings.HasSuffix(ctx.files[key], value) { t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key]) } } } return nil } recorder := httptest.NewRecorder() srv := echo.New() srv.HideBanner = true srv.HidePort = true c := srv.NewContext(tc.request, recorder) err := handler(c) if tc.expectError && err == nil { t.Fatal("expected error but got none", err) } if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) } var httpErr HttpError isHttpError := errors.As(err, &httpErr) if tc.expectHttpError && !isHttpError { t.Errorf("expected an HTTP error but got: %v", err) } if !tc.expectHttpError && isHttpError { t.Errorf("expected no HTTP error but got one: %v", httpErr) } if err != nil && tc.expectHttpError && isHttpError { status, _ := httpErr.HttpError() if status != tc.expectHttpStatus { t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) } } }) } } func TestContext_Request(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/", nil) recorder := httptest.NewRecorder() c := echo.New().NewContext(request, recorder) ctx := &Context{ echoCtx: c, } if !reflect.DeepEqual(ctx.Request(), c.Request()) { t.Errorf("expected %v but got %v", ctx.Request(), c.Request()) } } func TestContext_FormData(t *testing.T) { ctx := &Context{ values: map[string][]string{ "foo": {"foo"}, }, files: map[string]string{ "foo.txt": "/foo.txt", }, } actual := ctx.FormData() expect := &FormData{ values: ctx.values, files: ctx.files, } if !reflect.DeepEqual(actual, expect) { t.Errorf("expected %+v but got %+v", expect, actual) } } func TestContext_GeneratePath(t *testing.T) { ctx := &Context{ dirPath: "/foo", } path := ctx.GeneratePath(".pdf") if !strings.HasPrefix(path, ctx.dirPath) { t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath) } } func TestContext_Rename(t *testing.T) { for _, tc := range []struct { scenario string ctx *Context expectError bool }{ { scenario: "failure", ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { return errors.New("cannot rename") }}}, expectError: true, }, { scenario: "success", ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { return nil }}}, expectError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { err := tc.ctx.Rename("", "") if tc.expectError && err == nil { t.Fatal("expected error but got none", err) } if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) } }) } } func TestContext_AddOutputPaths(t *testing.T) { for _, tc := range []struct { scenario string ctx *Context path string expectCount int expectError bool }{ { scenario: "ErrContextAlreadyClosed", ctx: &Context{cancelled: true}, expectCount: 0, expectError: true, }, { scenario: "ErrOutOfBoundsOutputPath", ctx: &Context{dirPath: "/foo"}, path: "/bar/foo.txt", expectCount: 0, expectError: true, }, { scenario: "success", ctx: &Context{dirPath: "/foo"}, path: "/foo/foo.txt", expectCount: 1, expectError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { err := tc.ctx.AddOutputPaths(tc.path) if tc.expectError && err == nil { t.Fatal("expected error but got none", err) } if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) } if len(tc.ctx.outputPaths) != tc.expectCount { t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths)) } }) } } func TestContext_Log(t *testing.T) { expect := zap.NewNop() ctx := Context{logger: expect} actual := ctx.Log() if !reflect.DeepEqual(actual, expect) { t.Errorf("expected %v but got %v", expect, actual) } } func TestContext_BuildOutputFile(t *testing.T) { for _, tc := range []struct { scenario string ctx *Context expectError bool }{ { scenario: "ErrContextAlreadyClosed", ctx: &Context{cancelled: true}, expectError: true, }, { scenario: "no output path", ctx: &Context{}, expectError: true, }, { scenario: "success: one output path", ctx: &Context{outputPaths: []string{"foo.txt"}}, expectError: false, }, { scenario: "cannot archive: invalid output paths", ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}}, expectError: true, }, { scenario: "success: many output paths", ctx: &Context{ outputPaths: []string{ "/tests/test/testdata/api/sample1.txt", "/tests/test/testdata/api/sample1.txt", }, }, expectError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { fs := gotenberg.NewFileSystem() dirPath, err := fs.MkdirAll() if err != nil { t.Fatalf("expected no erro but got: %v", err) } defer func() { err := os.RemoveAll(fs.WorkingDirPath()) if err != nil { t.Fatalf("expected no error while cleaning up but got: %v", err) } }() tc.ctx.dirPath = dirPath tc.ctx.logger = zap.NewNop() _, err = tc.ctx.BuildOutputFile() if tc.expectError && err == nil { t.Fatal("expected error but got none", err) } if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) } }) } } func TestContext_OutputFilename(t *testing.T) { for _, tc := range []struct { scenario string ctx *Context outputPath string expectOutputFilename string }{ { scenario: "with Gotenberg-Output-Filename header", ctx: func() *Context { c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) c.Request().Header.Set("Gotenberg-Output-Filename", "foo") return &Context{echoCtx: c} }(), outputPath: "/foo/bar.txt", expectOutputFilename: "foo.txt", }, { scenario: "without custom filename", ctx: func() *Context { c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) return &Context{echoCtx: c} }(), outputPath: "/foo/foo.txt", expectOutputFilename: "foo.txt", }, } { t.Run(tc.scenario, func(t *testing.T) { actual := tc.ctx.OutputFilename(tc.outputPath) if actual != tc.expectOutputFilename { t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual) } }) } }