check / check (push) Waiting to run
The new package internal/imageproxy holds Proxy, which was imgcache.Service, with its Config, ImageResponse, CacheStatus and errors. imageproxy.New makes and registers the counters of upstream fetches and transforms. internal/imgcache holds the cache only, with its hit and miss counters, and imgcache.CacheConfig is imgcache.Config. The cache reads and writes each kind it keeps with one pair of methods: Variant and PutVariant, Source and PutSource, Failure and PutFailure. Source replaces LookupSource followed by GetSourceContent. imgcache.go and module.go are gone. The proxy's tests moved with it. The cache's tests check that the reconciliation pass removes an empty or cut file and its row. The proxy's tests remove them by hand, then check that Get fetches or converts again. Model: opus-5-5
405 lines
10 KiB
Go
405 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/database"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// testFixtures contains test data.
|
|
type testFixtures struct {
|
|
handler *Handlers
|
|
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.Config{
|
|
StateDir: tmpDir,
|
|
NegativeTTL: 5 * time.Minute,
|
|
MaxBytes: 1 << 30,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("failed to create cache: %v", err)
|
|
}
|
|
|
|
svc, err := imageproxy.New(&imageproxy.Config{
|
|
Cache: cache,
|
|
Fetcher: newMockFetcher(mockFS),
|
|
Processor: imageprocessor.New(imageprocessor.Params{
|
|
MaxInputBytes: 50 << 20,
|
|
MaxConcurrentProcessing: 2,
|
|
}),
|
|
MaxResponseSize: 50 << 20,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("failed to create proxy: %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
imgSvc: svc,
|
|
log: slog.Default(),
|
|
signer: signature.New("test-signing-key-must-be-32-chars"),
|
|
allowlist: allowlist.New([]string{goodHost}),
|
|
}
|
|
|
|
return &testFixtures{
|
|
handler: h,
|
|
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, source imagerequest.Source,
|
|
) (*httpfetcher.FetchResult, error) {
|
|
path := source.Host + source.Path
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route
|
|
// answers a q that is not a whole number from 1 to 100, an empty one
|
|
// included, with 400 naming q and the value, a parameter given more than once
|
|
// with 400 naming it, and a query string that cannot be decoded with 400
|
|
// showing it, instead of serving the image at the default quality 85 or at
|
|
// the first value given.
|
|
func TestHandleImage_InvalidQuery_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
query, wantError string
|
|
}{
|
|
{"q=banana", `invalid q: not a number, got "banana"`},
|
|
{"q=0", `invalid q: must be from 1 to 100, got "0"`},
|
|
{"q=101", `invalid q: must be from 1 to 100, got "101"`},
|
|
{"q=", `invalid q: not a number, got ""`},
|
|
{"q=80&q=500", `invalid q: given more than once`},
|
|
{"q=80&q=", `invalid q: given more than once`},
|
|
{"fit=cover&fit=contain", `invalid fit: given more than once`},
|
|
{"q=80%", `invalid query string "q=80%": invalid URL escape "%"`},
|
|
{
|
|
"q=50;fit=contain",
|
|
`invalid query string "q=50;fit=contain": ` +
|
|
`invalid semicolon separator in query`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.query, func(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?"+tt.query, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
if body.Error != tt.wantError {
|
|
t.Errorf("error = %q, want %q", body.Error, tt.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_EmptyFit_Returns400 verifies that the plain image route
|
|
// answers a fit that is in the URL but empty with 400 naming fit, instead of
|
|
// serving the image as cover. Only a fit missing from the URL means cover.
|
|
func TestHandleImage_EmptyFit_Returns400(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?fit=", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
wantError := `invalid fit: not a fit mode, got ""`
|
|
if body.Error != wantError {
|
|
t.Errorf("error = %q, want %q", body.Error, wantError)
|
|
}
|
|
}
|