An image served through an encrypted URL must carry an ETag, a request whose If-None-Match is that ETag must get 304 with no body, HEAD must get 200 with the headers and no body, and the server must route HEAD on /v1/e/ to its handler instead of answering 405. Model: opus-5-5
166 lines
4.7 KiB
Go
166 lines
4.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// 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(&encurl.Payload{
|
|
SourceHost: "cdn.example.com",
|
|
SourcePath: "/photo.jpg",
|
|
Width: 100000,
|
|
Height: 100000,
|
|
})
|
|
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(&encurl.Payload{
|
|
SourceHost: "cdn.example.com",
|
|
SourcePath: "/photo.jpg",
|
|
Width: 800,
|
|
Height: 600,
|
|
FitMode: imgcache.FitMode("bogus"),
|
|
})
|
|
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())
|
|
}
|
|
}
|