check / check (push) Waiting to run
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
585 lines
14 KiB
Go
585 lines
14 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
)
|
|
|
|
// Test data literals used repeatedly in this file (goconst).
|
|
const (
|
|
testPathPhoto = "/images/photo.jpg"
|
|
testPathUpload = "/uploads/image.jpg"
|
|
)
|
|
|
|
func TestService_Get_GoodHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
// Verify we got content
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("expected non-empty response")
|
|
}
|
|
|
|
if resp.ContentType != testContentTypeJPEG {
|
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
|
}
|
|
}
|
|
|
|
// TestService_Get_OtherHost checks that the image service serves a second host
|
|
// as it serves GoodHost.
|
|
func TestService_Get_OtherHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.OtherHost,
|
|
Path: testPathUpload,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// Should fetch successfully
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("expected non-empty response")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_InvalidFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
req.Source.Path = "/images/fake.jpg"
|
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
|
|
|
// Should fail because magic bytes don't match
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for invalid file")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_NotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
req.Source.Path = "/images/nonexistent.jpg"
|
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
|
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_FormatConversion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sourcePath string
|
|
outFormat imagerequest.Format
|
|
wantMIME string
|
|
}{
|
|
{
|
|
name: "JPEG to PNG",
|
|
sourcePath: testPathPhoto,
|
|
outFormat: imagerequest.FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
{
|
|
name: "PNG to JPEG",
|
|
sourcePath: "/images/logo.png",
|
|
outFormat: imagerequest.FormatJPEG,
|
|
wantMIME: testContentTypeJPEG,
|
|
},
|
|
{
|
|
name: "GIF to PNG",
|
|
sourcePath: "/images/animation.gif",
|
|
outFormat: imagerequest.FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
if resp.ContentType != tt.wantMIME {
|
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
|
}
|
|
|
|
// Verify magic bytes match the expected format
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
detectedMIME, err := magic.DetectFormat(data)
|
|
if err != nil {
|
|
t.Fatalf("failed to detect format: %v", err)
|
|
}
|
|
|
|
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
|
|
if !ok {
|
|
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
|
}
|
|
|
|
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
|
|
if !ok {
|
|
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
|
}
|
|
|
|
if detectedFormat != expectedFormat {
|
|
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_Get_Caching(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// First request - should be a cache miss
|
|
resp1, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
if resp1.CacheStatus != CacheMiss {
|
|
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
|
|
}
|
|
|
|
data1, err := io.ReadAll(resp1.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read first response: %v", err)
|
|
}
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
// Second request - should be a cache hit
|
|
resp2, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
if resp2.CacheStatus != CacheHit {
|
|
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
|
|
}
|
|
|
|
data2, err := io.ReadAll(resp2.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read second response: %v", err)
|
|
}
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// Content should be identical
|
|
if len(data1) != len(data2) {
|
|
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
|
|
}
|
|
}
|
|
|
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
|
// a variant of it and sets back two days every time pixa stored for them.
|
|
// For the source: last_accessed_at in source_content, and the access and
|
|
// modification times of its file. For the variant: last_accessed_at in
|
|
// variant_content, and the access and modification times of its file. It
|
|
// then checks that the variant is still served from the cache and that a new
|
|
// variant is made from the cached source without fetching it again.
|
|
//
|
|
// Left at the present: the files' status change and birth times, which a
|
|
// program cannot set, and the times of the directories holding the files,
|
|
// which other entries share.
|
|
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
|
|
resp, err := svc.Get(t.Context(), variant)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
|
backdateSource(t, svc.cache, variant, twoDaysAgo)
|
|
backdateVariant(t, svc.cache, variant, twoDaysAgo)
|
|
|
|
resp, err = svc.Get(t.Context(), variant)
|
|
if err != nil {
|
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
if resp.CacheStatus != CacheHit {
|
|
t.Errorf("cached variant CacheStatus = %q, want %q",
|
|
resp.CacheStatus, CacheHit)
|
|
}
|
|
|
|
// Another quality is a new variant, made from the cached source
|
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
|
|
if err != nil {
|
|
t.Fatalf("Get() of a new variant error = %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
fetches := readCounters(svc.cache).upstreamFetchCount
|
|
if fetches != 1 {
|
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
|
}
|
|
}
|
|
|
|
// backdateSource sets every time pixa stored for req's cached source to when,
|
|
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
|
func backdateSource(
|
|
t *testing.T, cache *Cache, req *imagerequest.Request, when time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
key := req.Source.Key()
|
|
setSourceLastAccessed(t, cache, key, when)
|
|
|
|
path := cache.srcContent.keyToPath(key)
|
|
|
|
err := os.Chtimes(path, when, when)
|
|
if err != nil {
|
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// backdateVariant sets every time pixa stored for the cached variant req asks
|
|
// for to when, as listed on
|
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
|
func backdateVariant(
|
|
t *testing.T, cache *Cache, req *imagerequest.Request, when time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
key := imagerequest.CacheKey(req)
|
|
path := cache.variants.keyToPath(key)
|
|
|
|
setVariantLastAccessed(t, cache, key, when)
|
|
|
|
err := os.Chtimes(path, when, when)
|
|
if err != nil {
|
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Request same image at different sizes
|
|
sizes := []imagerequest.Size{
|
|
{Width: 25, Height: 25},
|
|
{Width: 50, Height: 50},
|
|
{Width: 75, Height: 75},
|
|
}
|
|
|
|
responses := make([][]byte, 0, len(sizes))
|
|
|
|
for _, size := range sizes {
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: size,
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error for size %v = %v", size, err)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
responses = append(responses, data)
|
|
}
|
|
|
|
// All responses should be different sizes (different cache entries)
|
|
for i := range len(responses) - 1 {
|
|
if len(responses[i]) == len(responses[i+1]) {
|
|
// Not necessarily an error, but worth noting
|
|
t.Logf("responses %d and %d have same size: %d bytes",
|
|
i, i+1, len(responses[i]))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestService_Get_ContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_ReturnsETag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
// ETag should be set
|
|
if resp.ETag == "" {
|
|
t.Error("ETag should be set in response")
|
|
}
|
|
|
|
// ETag should be properly quoted
|
|
if len(resp.ETag) < 2 || resp.ETag[0] != '"' || resp.ETag[len(resp.ETag)-1] != '"' {
|
|
t.Errorf("ETag should be quoted, got %q", resp.ETag)
|
|
}
|
|
}
|
|
|
|
func TestService_Get_ETagConsistency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// First request
|
|
resp1, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
etag1 := resp1.ETag
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
// Second request (from cache)
|
|
resp2, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
etag2 := resp2.ETag
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// ETags should be identical for the same content
|
|
if etag1 != etag2 {
|
|
t.Errorf("ETags should match: %q != %q", etag1, etag2)
|
|
}
|
|
}
|
|
|
|
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
// Request same image at different sizes - should get different ETags
|
|
req1 := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 25, Height: 25},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
req2 := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp1, err := svc.Get(ctx, req1)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
etag1 := resp1.ETag
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
resp2, err := svc.Get(ctx, req2)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
etag2 := resp2.ETag
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// ETags should be different for different content
|
|
if etag1 == etag2 {
|
|
t.Error("ETags should differ for different sizes")
|
|
}
|
|
}
|