check / check (push) Canceled after 0s
A hit opens the variant file and nothing else: its content type comes from the file's first bytes, and a file in no output format is a miss and is made again. Its access time and hit count are handed, without waiting, to the goroutine that wrote late counts, which now writes every count and the access times once a second; the access times wait in a channel of 10,000 that drops the rest. checkNegativeCache leaves expired rows to CleanExpired. The .meta files, metaCache, Lookup and golang-lru go, with their tests; tests that read counts after Get wait for the background write. make bench runs a parallel cache hit benchmark, outside make check. Model: opus-5-5
915 lines
23 KiB
Go
915 lines
23 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: fetched_at and last_accessed_at in source_content, and the
|
|
// access and modification times of its file. For the variant: created_at and
|
|
// 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. The request_cache and output_content tables are
|
|
// never written.
|
|
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()
|
|
|
|
waitForPendingCountsWritten(t, svc.cache)
|
|
|
|
fetches := readCacheStatsCounters(t, 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()
|
|
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
|
|
|
updateOneRow(t, cache,
|
|
`UPDATE source_content SET fetched_at = ? WHERE source_key = ?`,
|
|
dbTime, string(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)
|
|
|
|
updateOneRow(t, cache,
|
|
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
|
when.UTC().Format(sqliteTimestampFormat), string(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)
|
|
}
|
|
}
|
|
|
|
// updateOneRow runs the UPDATE statement query and fails the test unless it
|
|
// changed exactly one row.
|
|
func updateOneRow(t *testing.T, cache *Cache, query string, args ...any) {
|
|
t.Helper()
|
|
|
|
result, err := cache.db.ExecContext(t.Context(), query, args...)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", query, err)
|
|
}
|
|
|
|
rows, err := result.RowsAffected()
|
|
if err != nil || rows != 1 {
|
|
t.Fatalf("%s: rows = %d, error = %v, want 1 row", query, rows, 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")
|
|
}
|
|
}
|