check / check (push) Waiting to run
The image URL parser must lower-case the host and refuse one holding "@", which a URL reads as a user name and password before the host; ErrInvalidHost is added for that refusal. A host with a port, or a ":" with no port, must match no allowlist entry, so its request needs a signature. All three tests fail until the fix. Model: opus-5-5
870 lines
22 KiB
Go
870 lines
22 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.OtherHost,
|
|
SourcePath: testPathUpload,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.OtherHost,
|
|
SourcePath: testPathUpload,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
|
|
// Generate a valid signature
|
|
signer := signature.New(signingKey)
|
|
req.Expires = time.Now().Add(time.Hour)
|
|
req.Signature = signer.Sign(signatureRequest(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{
|
|
SourceHost: fixtures.OtherHost,
|
|
SourcePath: testPathUpload,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
|
|
// Generate an expired signature
|
|
signer := signature.New(signingKey)
|
|
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
|
req.Signature = signer.Sign(signatureRequest(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{
|
|
SourceHost: fixtures.OtherHost,
|
|
SourcePath: testPathUpload,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: testHostCDN,
|
|
SourcePath: testPathCat,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
Expires: time.Now().Add(time.Hour),
|
|
}
|
|
signedReq.Signature = signer.Sign(signatureRequest(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{
|
|
SourceHost: tt.host,
|
|
SourcePath: signedReq.SourcePath,
|
|
SourceQuery: signedReq.SourceQuery,
|
|
Size: signedReq.Size,
|
|
Format: signedReq.Format,
|
|
Quality: signedReq.Quality,
|
|
FitMode: signedReq.FitMode,
|
|
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.SourceHost)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
SourceHost: tt.host,
|
|
SourcePath: testPathCat,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_Get_InvalidFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
ctx := context.Background()
|
|
|
|
req := &ImageRequest{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: "/images/fake.jpg",
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
|
|
// 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 := &ImageRequest{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: "/images/nonexistent.jpg",
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
|
|
_, 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 ImageFormat
|
|
wantMIME string
|
|
}{
|
|
{
|
|
name: "JPEG to PNG",
|
|
sourcePath: testPathPhoto,
|
|
outFormat: FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
{
|
|
name: "PNG to JPEG",
|
|
sourcePath: "/images/logo.png",
|
|
outFormat: FormatJPEG,
|
|
wantMIME: testContentTypeJPEG,
|
|
},
|
|
{
|
|
name: "GIF to PNG",
|
|
sourcePath: "/images/animation.gif",
|
|
outFormat: FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &ImageRequest{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: tt.sourcePath,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: tt.outFormat,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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 in source_metadata, fetched_at and
|
|
// last_accessed_at in source_content, the fetched_at in its metadata file,
|
|
// and the access and modification times of its file and of its metadata
|
|
// file. For the variant: created_at and last_accessed_at in variant_content,
|
|
// the created_at in its .meta file, and the access and modification times of
|
|
// its file and of its .meta 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 source's stored response headers are empty,
|
|
// as the mock fetcher sends none. expires_at, etag and last_modified in
|
|
// source_metadata and in the metadata file are never written, nor are the
|
|
// request_cache and output_content tables.
|
|
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
variant := photoVariant(fixtures, 85, 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, FitCover))
|
|
if err != nil {
|
|
t.Fatalf("Get() of a new variant error = %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
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, when time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
|
|
|
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash)
|
|
if err != nil {
|
|
t.Fatalf("reading the source's metadata file: %v", err)
|
|
}
|
|
|
|
meta.FetchedAt = when.Unix()
|
|
|
|
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
|
if err != nil {
|
|
t.Fatalf("writing the source's metadata file: %v", err)
|
|
}
|
|
|
|
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
|
|
|
updateOneRow(t, cache, `
|
|
UPDATE source_metadata SET fetched_at = ?
|
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery)
|
|
updateOneRow(t, cache,
|
|
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
|
|
dbTime, meta.ContentHash)
|
|
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
|
|
|
|
for _, path := range []string{
|
|
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
|
|
cache.srcMetadata.metaPath(req.SourceHost, pathHash),
|
|
} {
|
|
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, when time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
key := CacheKey(req)
|
|
|
|
content, size, contentType, err := cache.variants.LoadWithMeta(key)
|
|
if err != nil || contentType == "" {
|
|
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
|
|
contentType, err)
|
|
}
|
|
|
|
_ = content.Close()
|
|
|
|
meta, err := json.Marshal(VariantMeta{
|
|
ContentType: contentType,
|
|
Size: size,
|
|
CreatedAt: when.Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encoding the variant's .meta file: %v", err)
|
|
}
|
|
|
|
path := cache.variants.keyToPath(key)
|
|
|
|
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
|
|
if err != nil {
|
|
t.Fatalf("writing the variant's .meta file: %v", err)
|
|
}
|
|
|
|
updateOneRow(t, cache,
|
|
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
|
when.UTC().Format(sqliteTimestampFormat), string(key))
|
|
setVariantLastAccessed(t, cache, key, when)
|
|
|
|
for _, file := range []string{path, path + variantMetaSuffix} {
|
|
err = os.Chtimes(file, when, when)
|
|
if err != nil {
|
|
t.Fatalf("setting back the times of %s: %v", file, 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 := []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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: size,
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.OtherHost,
|
|
SourcePath: testPathUpload,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 25, Height: 25},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
|
|
req2 := &ImageRequest{
|
|
SourceHost: fixtures.GoodHost,
|
|
SourcePath: testPathPhoto,
|
|
Size: Size{Width: 50, Height: 50},
|
|
Format: FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: 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")
|
|
}
|
|
}
|