Files
pixa/internal/handlers/handlers_internal_test.go
T
sneak 409925dc73 test: cover encrypted-URL and generator validation gaps (#62)
Failing tests for the missing validation on the encrypted-URL route and
the token generator: an encrypted token carrying an over-limit dimension
or an unrecognized fit mode must be rejected with 400; POST /generate
with a bad width, height, quality, ttl or fit must return 400, mint no
token, and name the field on the page; an empty quality takes 85 and the
largest accepted values still give an expiry in the future. Also covers
the shared imgcache validator and the plain image route's invalid fit
mode.

Model: opus-4-8 (implementation); opus-5-5 (rework)
2026-09-28 12:29:42 +00:00

294 lines
6.8 KiB
Go

package handlers
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/jpeg"
"io"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
)
// testFixtures contains test data.
type testFixtures struct {
handler *Handlers
service *imgcache.Service
goodHost string
}
func setupTestHandler(t *testing.T) *testFixtures {
t.Helper()
goodHost := "goodhost.example.com"
// Generate test JPEG
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
mockFS := fstest.MapFS{
goodHost + "/images/photo.jpg": &fstest.MapFile{Data: jpegData},
}
tmpDir := t.TempDir()
db := setupTestDB(t)
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: newMockFetcher(mockFS),
SigningKey: "test-signing-key-must-be-32-chars",
Allowlist: []string{goodHost},
})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
h := &Handlers{
imgSvc: svc,
log: slog.Default(),
}
return &testFixtures{
handler: h,
service: svc,
goodHost: goodHost,
}
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
return db
}
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// mockFetcher fetches from a mock filesystem.
type mockFetcher struct {
fs fs.FS
}
func newMockFetcher(fs fs.FS) *mockFetcher {
return &mockFetcher{fs: fs}
}
func (f *mockFetcher) Fetch(
_ context.Context, url string,
) (*httpfetcher.FetchResult, error) {
// Remove https:// prefix
path := url[8:] // Remove "https://"
data, err := fs.ReadFile(f.fs, path)
if err != nil {
return nil, httpfetcher.ErrUpstreamError
}
return &httpfetcher.FetchResult{
Content: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "image/jpeg",
}, nil
}
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
// Create a chi router to properly handle wildcards
r := chi.NewRouter()
r.Head("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Should return 200 OK
if rec.Code != http.StatusOK {
t.Errorf("HEAD request status = %d, want %d", rec.Code, http.StatusOK)
}
// Should have Content-Type header
if ct := rec.Header().Get("Content-Type"); ct == "" {
t.Error("HEAD response should have Content-Type header")
}
// Should have Content-Length header
if cl := rec.Header().Get("Content-Length"); cl == "" {
t.Error("HEAD response should have Content-Length header")
}
// Body should be empty for HEAD request
if rec.Body.Len() != 0 {
t.Errorf("HEAD response body should be empty, got %d bytes", rec.Body.Len())
}
}
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
// First request to get the ETag
req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec1 := httptest.NewRecorder()
r.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Fatalf("First request status = %d, want %d", rec1.Code, http.StatusOK)
}
etag := rec1.Header().Get("ETag")
if etag == "" {
t.Fatal("Response should have ETag header")
}
// Second request with If-None-Match header
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
req2.Header.Set("If-None-Match", etag)
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, req2)
// Should return 304 Not Modified
if rec2.Code != http.StatusNotModified {
t.Errorf("Conditional request status = %d, want %d",
rec2.Code, http.StatusNotModified)
}
// Body should be empty for 304 response
if rec2.Body.Len() != 0 {
t.Errorf("304 response body should be empty, got %d bytes", rec2.Body.Len())
}
}
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
// Request with non-matching ETag
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
req.Header.Set("If-None-Match", `"different-etag"`)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Should return 200 OK with full response
if rec.Code != http.StatusOK {
t.Errorf("Request with non-matching ETag status = %d, want %d",
rec.Code, http.StatusOK)
}
// Body should not be empty
if rec.Body.Len() == 0 {
t.Error("Response body should not be empty for non-matching ETag")
}
}
func TestHandleImage_ETagHeader(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("Request status = %d, want %d", rec.Code, http.StatusOK)
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Error("Response should have ETag header")
}
// ETag should be quoted
if len(etag) < 2 || etag[0] != '"' || etag[len(etag)-1] != '"' {
t.Errorf("ETag should be quoted, got %q", etag)
}
}
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
// route rejects an unrecognized fit mode with 400.
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
status := getImage(t, fix,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
}
}