check / check (push) Waiting to run
The five counts the cache_stats table held are now Prometheus counters in memory, as fields of the cache. NewCache makes them and registers them on the registry it is given. pixad gives it Prometheus' default registry, which /metrics serves; each test gives it a new one. The Increment methods add to their cache's counters, so counting makes no database call. cache_stats, Cache.Stats, Service.Stats, CacheStats and the pending counts are gone, with their tests. The background goroutine now writes only access times, and its names say so: StartPendingAccessTimeWrites, StopPendingAccessTimeWrites and pendingaccesstimes.go. The two stop tests whose write under way was a count now use access times. Model: opus-5-5
887 lines
22 KiB
Go
887 lines
22 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// Test data literals used repeatedly in this file (goconst).
|
|
const (
|
|
testPathPhoto = "/images/photo.jpg"
|
|
testPathUpload = "/uploads/image.jpg"
|
|
testSigningKey = "test-signing-key-12345"
|
|
)
|
|
|
|
func TestService_Get_AllowlistedHost(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)
|
|
}
|
|
}
|
|
|
|
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
|
|
|
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 fail validation - not allowlisted and no signature
|
|
err := svc.ValidateRequest(req)
|
|
if err == nil {
|
|
t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signingKey := testSigningKey
|
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
|
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,
|
|
},
|
|
}
|
|
|
|
// Generate a valid signature
|
|
signer := signature.New(signingKey)
|
|
req.Expires = time.Now().Add(time.Hour)
|
|
req.Signature = signer.Sign(req)
|
|
|
|
// Should pass validation
|
|
err := svc.ValidateRequest(req)
|
|
if err != nil {
|
|
t.Errorf("ValidateRequest() error = %v", err)
|
|
}
|
|
|
|
// 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_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signingKey := testSigningKey
|
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
|
|
|
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,
|
|
},
|
|
}
|
|
|
|
// Generate an expired signature
|
|
signer := signature.New(signingKey)
|
|
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
|
req.Signature = signer.Sign(req)
|
|
|
|
// Should fail validation
|
|
err := svc.ValidateRequest(req)
|
|
if err == nil {
|
|
t.Error("ValidateRequest() expected error for expired signature")
|
|
}
|
|
}
|
|
|
|
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signingKey := testSigningKey
|
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
|
|
|
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,
|
|
},
|
|
}
|
|
|
|
// Set an invalid signature
|
|
req.Signature = "invalid-signature"
|
|
req.Expires = time.Now().Add(time.Hour)
|
|
|
|
// Should fail validation
|
|
err := svc.ValidateRequest(req)
|
|
if err == nil {
|
|
t.Error("ValidateRequest() expected error for invalid signature")
|
|
}
|
|
}
|
|
|
|
// TestService_ValidateRequest_SignatureExactHostMatch verifies that
|
|
// ValidateRequest enforces exact host matching for signatures. A
|
|
// signature for one host must not verify for a different host, even
|
|
// if they share a domain suffix.
|
|
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signingKey := "test-signing-key-must-be-32-chars"
|
|
svc, _ := SetupTestService(t,
|
|
WithSigningKey(signingKey),
|
|
WithNoAllowlist(),
|
|
)
|
|
|
|
signer := signature.New(signingKey)
|
|
|
|
// Sign a request for "cdn.example.com"
|
|
signedReq := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: testHostCDN,
|
|
Path: testPathCat,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
Expires: time.Now().Add(time.Hour),
|
|
}
|
|
signedReq.Signature = signer.Sign(signedReq)
|
|
|
|
// The original request should pass validation
|
|
t.Run("exact host passes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := svc.ValidateRequest(signedReq)
|
|
if err != nil {
|
|
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
|
}
|
|
})
|
|
|
|
// Try to reuse the signature with different hosts
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
}{
|
|
{"parent domain", testHostExample},
|
|
{"sibling subdomain", "images.example.com"},
|
|
{"deeper subdomain", "a.cdn.example.com"},
|
|
{"evil suffix domain", "cdn.example.com.evil.com"},
|
|
{"prefixed host", "evilcdn.example.com"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: tt.host,
|
|
Path: signedReq.Source.Path,
|
|
Query: signedReq.Source.Query,
|
|
},
|
|
Transform: signedReq.Transform,
|
|
Expires: signedReq.Expires,
|
|
Signature: signedReq.Signature,
|
|
}
|
|
|
|
err := svc.ValidateRequest(req)
|
|
if err == nil {
|
|
t.Errorf(
|
|
"ValidateRequest() should reject signature for host %q (signed for %q)",
|
|
tt.host, signedReq.Source.Host)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestService_ValidateRequest_HostWithPortNeedsSignature verifies that a host
|
|
// followed by a port, or by a colon with no port, matches no allowlist entry,
|
|
// so that a request for it without a signature is refused, while the same
|
|
// host with no port is allowlisted.
|
|
func TestService_ValidateRequest_HostWithPortNeedsSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, _ := SetupTestService(t,
|
|
WithSigningKey(testSigningKey),
|
|
WithAllowlist(testHostCDN, "203.0.113.5", "2001:db8::1"),
|
|
)
|
|
|
|
tests := []struct {
|
|
host string
|
|
allowlisted bool
|
|
}{
|
|
{testHostCDN, true},
|
|
{"cdn.example.com:8443", false},
|
|
{"cdn.example.com:443", false},
|
|
{"cdn.example.com:", false},
|
|
{"203.0.113.5", true},
|
|
{"203.0.113.5:8080", false},
|
|
{"[2001:db8::1]", true},
|
|
{"[2001:db8::1]:8443", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: tt.host,
|
|
Path: testPathCat,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
err := svc.ValidateRequest(req)
|
|
t.Logf("ValidateRequest() for host %q with no signature: %v",
|
|
tt.host, err)
|
|
|
|
if allowlisted := err == nil; allowlisted != tt.allowlisted {
|
|
t.Errorf("host %q served without a signature = %v, want %v",
|
|
tt.host, allowlisted, tt.allowlisted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature verifies that
|
|
// an allowlisted IPv6 address written without brackets and followed by a
|
|
// port, or by a colon with no port, needs a signature. Go's URL parser, which
|
|
// the fetcher uses, reads 2001:db8::1:8443 as the host 2001:db8::1 with the
|
|
// port 8443.
|
|
func TestService_ValidateRequest_UnbracketedIPv6PortNeedsSignature(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
svc, _ := SetupTestService(t,
|
|
WithSigningKey(testSigningKey),
|
|
WithAllowlist("2001:db8::1"),
|
|
)
|
|
|
|
for _, host := range []string{"2001:db8::1:8443", "2001:db8::1:"} {
|
|
t.Run(host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: host,
|
|
Path: testPathCat,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
err := svc.ValidateRequest(req)
|
|
t.Logf("ValidateRequest() for host %q with no signature: %v",
|
|
host, err)
|
|
|
|
if err == nil {
|
|
t.Errorf("host %q served without a signature, want refused",
|
|
host)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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_ValidateRequest_NoSigningKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Service with no signing key - all non-allowlisted requests should fail
|
|
svc, fixtures := SetupTestService(t, WithNoAllowlist())
|
|
|
|
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,
|
|
},
|
|
}
|
|
|
|
err := svc.ValidateRequest(req)
|
|
if err == nil {
|
|
t.Error(
|
|
"ValidateRequest() expected error when no signing key and host not allowlisted",
|
|
)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|