check / check (push) Waiting to run
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
173 lines
4.9 KiB
Go
173 lines
4.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
|
// generator seeded by the shared test signing key. The image service is left
|
|
// nil: these tests exercise validation that rejects a token before any image
|
|
// is fetched, so the handler must never reach the service.
|
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
|
t.Helper()
|
|
|
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
log: slog.New(slog.DiscardHandler),
|
|
encGen: encGen,
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
return encGen, r
|
|
}
|
|
|
|
// getEncToken issues a GET for the given token and returns the recorder.
|
|
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
|
// token requesting a dimension beyond MaxDimension is rejected with 400
|
|
// instead of reaching the image processor and libvips.
|
|
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encGen, srv := newEncTestServer(t)
|
|
|
|
token, err := encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: "cdn.example.com",
|
|
Path: "/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
|
},
|
|
}, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
|
|
rec := getEncToken(srv, token)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
|
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
|
// as a 500 from the image processor's default branch.
|
|
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encGen, srv := newEncTestServer(t)
|
|
|
|
token, err := encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: "cdn.example.com",
|
|
Path: "/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
FitMode: imagerequest.Fit("bogus"),
|
|
},
|
|
}, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
|
|
rec := getEncToken(srv, token)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_IfNoneMatch_Returns304 verifies that an image served
|
|
// through an encrypted URL carries an ETag, and that a request whose
|
|
// If-None-Match is that ETag is answered 304 Not Modified with no body.
|
|
func TestHandleImageEnc_IfNoneMatch_Returns304(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
target := encPhotoURL(t, h)
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, target, nil))
|
|
|
|
etag := rec.Header().Get("ETag")
|
|
t.Logf("GET: %d, ETag %q", rec.Code, etag)
|
|
|
|
if rec.Code != http.StatusOK || etag == "" {
|
|
t.Fatalf("GET: status = %d, ETag = %q, want %d and an ETag",
|
|
rec.Code, etag, http.StatusOK)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
req.Header.Set("If-None-Match", etag)
|
|
|
|
rec = httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
t.Logf("GET with If-None-Match: %d, %d body bytes", rec.Code, rec.Body.Len())
|
|
|
|
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
|
t.Errorf("status = %d with %d body bytes, want %d with none",
|
|
rec.Code, rec.Body.Len(), http.StatusNotModified)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_HEAD_ReturnsHeadersOnly verifies that HEAD on an
|
|
// encrypted URL is answered 200 with the headers GET sends and no body.
|
|
func TestHandleImageEnc_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
|
|
r := chi.NewRouter()
|
|
r.Head("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodHead, encPhotoURL(t, h), nil))
|
|
t.Logf("HEAD: %d, headers %v, %d body bytes",
|
|
rec.Code, rec.Header(), rec.Body.Len())
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
for _, name := range []string{
|
|
"Content-Type", "Content-Length", "Cache-Control", "ETag",
|
|
} {
|
|
if rec.Header().Get(name) == "" {
|
|
t.Errorf("HEAD response has no %s", name)
|
|
}
|
|
}
|
|
|
|
if rec.Body.Len() != 0 {
|
|
t.Errorf("HEAD response body has %d bytes, want none", rec.Body.Len())
|
|
}
|
|
}
|