From 6d380fbf9829a55fece1ab4b522ad5ec9be219b4 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 20:17:08 +0000 Subject: [PATCH] test: cover encrypted-URL and generator validation gaps (#62) Failing tests for the missing validation on the encrypted-URL path and the token generator: an encrypted token carrying an over-limit dimension or an unrecognized fit mode must be rejected with 400, and POST /generate with a non-numeric or over-limit width must return 400 rather than minting a token. Also covers the shared imgcache validator directly. Model: opus-4-8 --- .../handlers/auth_generate_internal_test.go | 66 +++++++++++++ internal/handlers/imageenc_internal_test.go | 98 +++++++++++++++++++ internal/imgcache/validate_internal_test.go | 64 ++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 internal/handlers/auth_generate_internal_test.go create mode 100644 internal/handlers/imageenc_internal_test.go create mode 100644 internal/imgcache/validate_internal_test.go diff --git a/internal/handlers/auth_generate_internal_test.go b/internal/handlers/auth_generate_internal_test.go new file mode 100644 index 0000000..63d3c59 --- /dev/null +++ b/internal/handlers/auth_generate_internal_test.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// generatePost submits the /generate form with a valid session and CSRF token +// plus the caller's extra fields, returning the recorder. +func generatePost( + t *testing.T, extra url.Values, +) *httptest.ResponseRecorder { + t.Helper() + + h, srv := newCSRFTestRouter(t) + + sessionCookie := newSessionCookie(t, h) + cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie}) + cookies = append(cookies, sessionCookie) + + form := url.Values{ + sourceURLField: {testSourceURL}, + csrfTokenField: {token}, + } + maps.Copy(form, extra) + + return postForm(srv, "/generate", cookies, form) +} + +// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is +// rejected with 400 naming the field rather than being coerced to 0 and +// minting a 0-width token. +func TestGeneratePostRejectsNonNumericWidth(t *testing.T) { + t.Parallel() + + rec := generatePost(t, url.Values{"width": {"abc"}}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + if strings.Contains(rec.Body.String(), "/v1/e/") { + t.Error("a token was generated for non-numeric width") + } +} + +// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond +// MaxDimension is rejected at generation time so an unusable token cannot be +// minted. +func TestGeneratePostRejectsOverLimitWidth(t *testing.T) { + t.Parallel() + + rec := generatePost(t, url.Values{"width": {"100000"}}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + if strings.Contains(rec.Body.String(), "/v1/e/") { + t.Error("a token was generated for an over-limit width") + } +} diff --git a/internal/handlers/imageenc_internal_test.go b/internal/handlers/imageenc_internal_test.go new file mode 100644 index 0000000..be2db22 --- /dev/null +++ b/internal/handlers/imageenc_internal_test.go @@ -0,0 +1,98 @@ +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) + } +} diff --git a/internal/imgcache/validate_internal_test.go b/internal/imgcache/validate_internal_test.go new file mode 100644 index 0000000..99ecf36 --- /dev/null +++ b/internal/imgcache/validate_internal_test.go @@ -0,0 +1,64 @@ +package imgcache + +import ( + "errors" + "testing" +) + +func TestValidateImageRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req ImageRequest + wantErr error + }{ + { + name: "within bounds", + req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover}, + }, + { + name: "original size and empty fit", + req: ImageRequest{Size: Size{Width: 0, Height: 0}}, + }, + { + name: "width over limit", + req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}}, + wantErr: ErrDimensionTooLarge, + }, + { + name: "height over limit", + req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}}, + wantErr: ErrDimensionTooLarge, + }, + { + name: "negative width", + req: ImageRequest{Size: Size{Width: -1, Height: 600}}, + wantErr: ErrInvalidSize, + }, + { + name: "invalid fit mode", + req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"}, + wantErr: ErrInvalidFitMode, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateImageRequest(&tt.req) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateImageRequest() error = %v, want nil", err) + } + + return + } + + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr) + } + }) + } +}