mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-13 10:52:15 +01:00
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// TestRequestCanceled pins the client-abort discriminator: only a
|
|
// context.Canceled that stems from the request context counts, so a server
|
|
// timeout or an unrelated cancellation still surfaces as an internal failure.
|
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
|
func TestRequestCanceled(t *testing.T) {
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
|
defer cancelTimeout()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
reqCtx context.Context
|
|
err error
|
|
want bool
|
|
}{
|
|
{"client abort", canceled, context.Canceled, true},
|
|
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
|
|
{"canceled error but live request", context.Background(), context.Canceled, false},
|
|
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
|
|
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
|
|
{"no error", canceled, nil, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
|
if got := requestCanceled(c, tc.err); got != tc.want {
|
|
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
|
|
// as 499 rather than 500, and that a genuine failure keeps its status.
|
|
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
reqCtx context.Context
|
|
err error
|
|
wantStatus int
|
|
}{
|
|
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
|
|
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
c := echo.New().NewContext(req, rec)
|
|
c.Set("logger", slog.New(slog.DiscardHandler))
|
|
|
|
httpErrorHandler()(tc.err, c)
|
|
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
|
// a Content-Disposition header, so a path separator must never survive it.
|
|
// See https://github.com/gotenberg/gotenberg/issues/1227 and
|
|
// GHSA-hwc4-gmrw-5222.
|
|
func TestOutputFilenameMiddleware(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
header string
|
|
want string
|
|
}{
|
|
{"no header", "", ""},
|
|
{"plain filename", "foo", "foo"},
|
|
{"POSIX path", "/tmp/foo", "foo"},
|
|
{"POSIX traversal", "../../../etc/passwd", "passwd"},
|
|
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
|
|
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
|
|
{"mixed separators", `a/b\c`, "c"},
|
|
{"trailing separator", "/tmp/", ""},
|
|
{"bare dot dot", "..", ".."},
|
|
{"control characters", "fo\x01o\x7f", "foo"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
if tc.header != "" {
|
|
req.Header.Set("Gotenberg-Output-Filename", tc.header)
|
|
}
|
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
|
|
|
err := handler(c)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got, ok := c.Get("outputFilename").(string)
|
|
if !ok {
|
|
t.Fatal("outputFilename is not set as a string")
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("outputFilename = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
|
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
|
handler := mw(func(c echo.Context) error { return nil })
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
// c has no "logger" key, mimicking a pooled context whose store was
|
|
// recycled under a concurrently running webhook goroutine. The
|
|
// middleware must surface an error instead of panicking on the
|
|
// unchecked type assertion the pre-fix code relied on.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
err := handler(c)
|
|
if err == nil {
|
|
t.Fatal("expected an error for missing logger, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "logger") {
|
|
t.Fatalf("error = %q, want a message mentioning logger", err)
|
|
}
|
|
}
|