Files
pixa/internal/handlers/imageenc_internal_test.go
T
clawbot 77d55abb7e
check / check (push) Canceled after 0s
Derive every key from the signing key in one place (closes #315)
seal.DeriveKeys derives the session hash and block keys, the encrypted URL
key and the CSRF key from the signing key, with their salts declared beside
it, and returns them as one seal.Keys. internal/providers calls it once at
startup and provides the value to the fx graph. The session manager, the
encrypted URL generator and the CSRF protection each take their own key.
session.NewManager, encurl.NewGenerator, newCSRFProtect and handlers.New
return no error, as nothing in them can fail now. Each key is derived as
before, with seal.DeriveKey and the same salt. The signing key itself stays
the HMAC key of signed URLs and the login password. README.md says under
signing_key what it protects.

Model: opus-5-5
2026-10-26 14:10:20 +01:00

176 lines
5.0 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"
"sneak.berlin/go/pixa/internal/seal"
)
// 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()
keys, err := seal.DeriveKeys(testSigningKey)
if err != nil {
t.Fatalf("seal.DeriveKeys() error = %v", err)
}
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
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())
}
}