mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-15 00:33:19 +01:00
chore(deps)!: migrate from echo v4 to echo v5
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -9,13 +10,15 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||
@@ -73,7 +76,7 @@ func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||
c := echo.New().NewContext(req, rec)
|
||||
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||
|
||||
httpErrorHandler()(tc.err, c)
|
||||
httpErrorHandler()(c, tc.err)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
@@ -105,7 +108,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
|
||||
{"control characters", "fo\x01o\x7f", "foo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||
handler := outputFilenameMiddleware()(func(c *echo.Context) error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if tc.header != "" {
|
||||
@@ -131,7 +134,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
|
||||
|
||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||
handler := mw(func(c echo.Context) error { return nil })
|
||||
handler := mw(func(c *echo.Context) error { return nil })
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
@@ -220,7 +223,7 @@ func TestOidcAuthMiddleware(t *testing.T) {
|
||||
}
|
||||
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
|
||||
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
|
||||
handler := oidcAuthMiddleware(verifier)(func(c *echo.Context) error {
|
||||
return c.NoContent(http.StatusOK)
|
||||
})
|
||||
|
||||
@@ -243,3 +246,114 @@ func TestOidcAuthMiddleware(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseError_StatusMapping pins the statuses [ParseError] derives from the
|
||||
// errors Echo and Gotenberg produce.
|
||||
//
|
||||
// Echo v5 models the router's ErrNotFound and ErrMethodNotAllowed as an
|
||||
// unexported type rather than [echo.HTTPError], so matching that type alone
|
||||
// would turn every unrouted request into a 500. It also guards the ordering:
|
||||
// Gotenberg's own [SentinelHttpError] carries a client-facing message and must
|
||||
// not be shadowed by the generic status lookup.
|
||||
func TestParseError_StatusMapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
err error
|
||||
wantStatus int
|
||||
wantMessage string
|
||||
}{
|
||||
{"router not found", echo.ErrNotFound, http.StatusNotFound, http.StatusText(http.StatusNotFound)},
|
||||
{"router method not allowed", echo.ErrMethodNotAllowed, http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)},
|
||||
{"explicit HTTP error", echo.NewHTTPError(http.StatusUnauthorized, "nope"), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
|
||||
{"wrapped HTTP error", fmt.Errorf("authenticate request: %w", echo.NewHTTPError(http.StatusUnauthorized, "nope")), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
|
||||
{"sentinel keeps its message", NewSentinelHttpError(http.StatusBadRequest, "Invalid 'foo' form field value"), http.StatusBadRequest, "Invalid 'foo' form field value"},
|
||||
{"unknown error", errors.New("boom"), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
status, message := ParseError(tc.err)
|
||||
if status != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", status, tc.wantStatus)
|
||||
}
|
||||
if message != tc.wantMessage {
|
||||
t.Fatalf("message = %q, want %q", message, tc.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEchoServer_RealIP pins the client IP extraction that the access log's
|
||||
// "remote_ip" field depends on.
|
||||
//
|
||||
// Echo v5.1.0 dropped the X-Forwarded-For and X-Real-IP fallbacks from
|
||||
// Context.RealIP, so without an explicit extractor a Gotenberg behind a reverse
|
||||
// proxy would log the proxy's address for every request. [newEchoServer]
|
||||
// restores the previous behavior.
|
||||
func TestNewEchoServer_RealIP(t *testing.T) {
|
||||
srv := newEchoServer()
|
||||
if srv.IPExtractor == nil {
|
||||
t.Fatal("no IPExtractor configured: remote_ip would report the proxy address")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{"x-forwarded-for keeps the client, not the proxy", map[string]string{"X-Forwarded-For": "203.0.113.7, 70.41.3.18"}, "203.0.113.7"},
|
||||
{"single x-forwarded-for", map[string]string{"X-Forwarded-For": "203.0.113.7"}, "203.0.113.7"},
|
||||
{"bracketed IPv6 is unwrapped", map[string]string{"X-Forwarded-For": "[2001:db8::1], 70.41.3.18"}, "2001:db8::1"},
|
||||
{"x-real-ip when no x-forwarded-for", map[string]string{"X-Real-IP": "203.0.113.9"}, "203.0.113.9"},
|
||||
{"no headers falls back to the remote address", nil, "192.0.2.1"},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
for key, value := range tc.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
c := srv.NewContext(req, httptest.NewRecorder())
|
||||
|
||||
if got := c.RealIP(); got != tc.want {
|
||||
t.Fatalf("RealIP = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEchoServer_AttachmentServesAbsolutePath pins the filesystem that every
|
||||
// conversion response is sent through.
|
||||
//
|
||||
// Echo v5 serves files through Echo.Filesystem, an [fs.FS] rooted at the working
|
||||
// directory, and [fs.FS] rejects absolute names. Gotenberg builds every output
|
||||
// file under the request's temporary directory and hands Context.Attachment an
|
||||
// absolute path, so with the default filesystem every conversion route answers
|
||||
// 404 while still reading the whole upload.
|
||||
func TestNewEchoServer_AttachmentServesAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "output.pdf")
|
||||
want := []byte("%PDF-1.7 not really a PDF")
|
||||
|
||||
err := os.WriteFile(path, want, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
srv := newEchoServer()
|
||||
rec := httptest.NewRecorder()
|
||||
c := srv.NewContext(httptest.NewRequest(http.MethodPost, "/", nil), rec)
|
||||
|
||||
err = c.Attachment(path, "output.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("Attachment(%q) = %v, want nil", path, err)
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, want) {
|
||||
t.Fatalf("body = %q, want %q", got, want)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, `filename="output.pdf"`) {
|
||||
t.Fatalf("Content-Disposition = %q, want it to carry filename=\"output.pdf\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user