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) } }) } } 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") } }