package cache import ( "bytes" "context" "errors" "io" "net/http" "os" "path/filepath" "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" minio "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "testing" ) func TestCache(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Cache Suite") } var _ = Describe("InMemoryCache", func() { var cache ThumbnailCache BeforeEach(func() { cache = NewInMemoryCache() }) It("should return ErrCacheMiss for missing key", func() { data, err := cache.Get("nonexistent") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) It("should store and retrieve data", func() { err := cache.Put("key1", []byte("thumbnail-data")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("thumbnail-data"))) }) It("should return independent copies on Get", func() { err := cache.Put("key1", []byte("original")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) data[0] = 'X' original, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) Expect(original).To(Equal([]byte("original"))) }) It("should overwrite existing keys", func() { err := cache.Put("key1", []byte("v1")) Expect(err).ToNot(HaveOccurred()) err = cache.Put("key1", []byte("v2")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("v2"))) }) It("should handle empty data", func() { err := cache.Put("empty-key", []byte{}) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("empty-key") Expect(err).ToNot(HaveOccurred()) Expect(data).To(BeEmpty()) }) }) var _ = Describe("NoopCache", func() { var cache ThumbnailCache BeforeEach(func() { cache = NoopCache{} }) It("should always return ErrCacheMiss on Get", func() { data, err := cache.Get("any-key") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) It("should accept Put without error", func() { err := cache.Put("key1", []byte("data")) Expect(err).ToNot(HaveOccurred()) }) It("should still miss after Put", func() { _ = cache.Put("key1", []byte("data")) data, err := cache.Get("key1") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) }) var _ = Describe("NewThumbnailCache", func() { It("should create InMemoryCache for 'memory' backend", func() { cache, err := NewThumbnailCache("memory", "", nil) Expect(err).ToNot(HaveOccurred()) Expect(cache).ToNot(BeNil()) err = cache.Put("k", []byte("v")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("k") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("v"))) }) It("should refuse an unknown backend", func() { _, err := NewThumbnailCache("unknown", "", nil) Expect(err).To(MatchError(ContainSubstring("unknown thumbnail cache backend"))) }) It("should refuse an empty backend", func() { _, err := NewThumbnailCache("", "", nil) Expect(err).To(HaveOccurred()) }) It("should create NoopCache for the 'none' backend", func() { cache, err := NewThumbnailCache("none", "", nil) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("k") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) It("should create S3Cache for 's3' backend with complete config", func() { cfg := &S3CacheConfig{ Bucket: "my-bucket", Region: "us-east-1", Credentials: credentials.NewStaticV4("ak", "sk", ""), } cache, err := NewThumbnailCache("s3", "", cfg) Expect(err).ToNot(HaveOccurred()) s3cache, ok := cache.(*S3Cache) Expect(ok).To(BeTrue()) Expect(s3cache.config.Bucket).To(Equal("my-bucket")) }) It("should refuse an 's3' backend with incomplete config", func() { _, err := NewThumbnailCache("s3", "", &S3CacheConfig{Bucket: "only-bucket"}) Expect(err).To(MatchError(ContainSubstring("s3 thumbnail cache"))) }) It("should refuse an 's3' backend without config", func() { _, err := NewThumbnailCache("s3", "", nil) Expect(err).To(MatchError(ContainSubstring("s3 thumbnail cache"))) }) It("should build S3CacheConfig from env var fields via BuildS3CacheConfig", func() { cfg := BuildS3CacheConfig("my-bucket", "us-east-1", "http://minio:9000", "ak", "sk") Expect(cfg.Bucket).To(Equal("my-bucket")) Expect(cfg.Region).To(Equal("us-east-1")) Expect(cfg.Endpoint).To(Equal("http://minio:9000")) Expect(cfg.Secure).To(BeTrue()) Expect(cfg.Credentials).ToNot(BeNil()) }) It("should build incomplete S3CacheConfig when credentials are missing", func() { cfg := BuildS3CacheConfig("my-bucket", "us-east-1", "", "", "") Expect(cfg.IsComplete()).To(BeFalse()) }) }) // mockS3Object is a closable reader for mock S3 objects. type mockS3Object struct { *bytes.Reader } func (m *mockS3Object) Close() error { return nil } var _ s3Object = (*mockS3Object)(nil) // mockS3FailingObject answers the read with an error, the way minio does. type mockS3FailingObject struct { err error } func (m *mockS3FailingObject) Read([]byte) (int, error) { return 0, m.err } func (m *mockS3FailingObject) Close() error { return nil } var _ s3Object = (*mockS3FailingObject)(nil) // mockS3Client implements s3Client interface for testing. type mockS3Client struct { objects map[string][]byte contentTypes map[string]string capturedOpts minio.PutObjectOptions getErr error readErr error putErr error } func newMockS3Client() *mockS3Client { return &mockS3Client{ objects: make(map[string][]byte), contentTypes: make(map[string]string), } } func (m *mockS3Client) GetObject(_ context.Context, _, objectName string, _ minio.GetObjectOptions) (s3Object, error) { if m.getErr != nil { return nil, m.getErr } if m.readErr != nil { return &mockS3FailingObject{err: m.readErr}, nil } data, ok := m.objects[objectName] if !ok { return &mockS3FailingObject{err: minio.ErrorResponse{ Code: minio.NoSuchKey, Message: "key does not exist", StatusCode: http.StatusNotFound, }}, nil } return &mockS3Object{Reader: bytes.NewReader(data)}, nil } func (m *mockS3Client) PutObject(_ context.Context, _, objectName string, reader io.Reader, _ int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) { if m.putErr != nil { return minio.UploadInfo{}, m.putErr } data, err := io.ReadAll(reader) if err != nil { return minio.UploadInfo{}, err } m.objects[objectName] = data m.contentTypes[objectName] = opts.ContentType m.capturedOpts = opts return minio.UploadInfo{}, nil } var _ = Describe("S3Cache", func() { var ( mockClient *mockS3Client cache *S3Cache ) BeforeEach(func() { mockClient = newMockS3Client() cfg := S3CacheConfig{ Bucket: "test-bucket", Region: "us-east-1", } cache = &S3Cache{config: cfg} cache.newFunc = func(_ S3CacheConfig) (s3Client, error) { return mockClient, nil } }) It("should return ErrCacheMiss for missing key", func() { data, err := cache.Get("nonexistent-key") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) It("should store and retrieve data", func() { err := cache.Put("sha256:abc123-400x400.jpg", []byte("thumbnail-data")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("sha256:abc123-400x400.jpg") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("thumbnail-data"))) }) It("should return independent copies on Get", func() { err := cache.Put("key1", []byte("original")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) data[0] = 'X' original, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) Expect(original).To(Equal([]byte("original"))) }) It("should overwrite existing keys", func() { err := cache.Put("key1", []byte("v1")) Expect(err).ToNot(HaveOccurred()) err = cache.Put("key1", []byte("v2")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("key1") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("v2"))) }) It("should handle empty data", func() { err := cache.Put("empty-key", []byte{}) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("empty-key") Expect(err).ToNot(HaveOccurred()) Expect(data).To(BeEmpty()) }) It("should use flat key format", func() { key := "sha256:abc123-400x400.jpg" err := cache.Put(key, []byte("data")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("data"))) }) It("should initialize client lazily on first access", func() { Expect(cache.client).To(BeNil()) _ = cache.Put("key1", []byte("data")) Expect(cache.client).ToNot(BeNil()) }) It("should handle S3 errors gracefully on Get", func() { mockClient.getErr = errors.New("connection refused") data, err := cache.Get("any-key") Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) It("should keep an error that is not a missing key an error", func() { mockClient.readErr = minio.ErrorResponse{Code: minio.AccessDenied, StatusCode: http.StatusForbidden} _, err := cache.Get("key1") Expect(err).To(HaveOccurred()) Expect(err).ToNot(MatchError(ErrCacheMiss)) }) It("should handle S3 errors gracefully on Put", func() { mockClient.putErr = errors.New("connection refused") err := cache.Put("key1", []byte("data")) Expect(err).To(HaveOccurred()) }) It("should propagate init error from newFunc", func() { cache.newFunc = func(_ S3CacheConfig) (s3Client, error) { return nil, errors.New("init failed") } data, err := cache.Get("any-key") Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) It("should derive Content-Type from .jpg extension", func() { err := cache.Put("sha256:abc123-400x400.jpg", []byte("data")) Expect(err).ToNot(HaveOccurred()) Expect(mockClient.contentTypes["sha256:abc123-400x400.jpg"]).To(Equal("image/jpeg")) }) It("should derive Content-Type from .png extension", func() { err := cache.Put("sha256:def456-800x600.png", []byte("data")) Expect(err).ToNot(HaveOccurred()) Expect(mockClient.contentTypes["sha256:def456-800x600.png"]).To(Equal("image/png")) }) It("should fallback to application/octet-stream for unknown extension", func() { err := cache.Put("key-without-ext", []byte("data")) Expect(err).ToNot(HaveOccurred()) Expect(mockClient.contentTypes["key-without-ext"]).To(Equal("application/octet-stream")) }) }) var _ = Describe("FileCache", func() { var ( tmpdir string cache *FileCache ) BeforeEach(func() { var err error tmpdir, err = os.MkdirTemp("", "filecache-test") Expect(err).ToNot(HaveOccurred()) cache = NewFileCache(tmpdir) }) AfterEach(func() { Expect(os.RemoveAll(tmpdir)).To(Succeed()) }) It("should return ErrCacheMiss for missing key", func() { data, err := cache.Get("ab/cd/ef1234567890/400x400.jpg") Expect(err).To(MatchError(ErrCacheMiss)) Expect(data).To(BeNil()) }) It("should store and retrieve data", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte("thumbnail-data")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("thumbnail-data"))) }) It("should use hierarchical directory layout", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte("thumbnail-data")) Expect(err).ToNot(HaveOccurred()) expectedPath := filepath.Join(tmpdir, "ab", "cd", "ef1234567890", "400x400.jpg") content, err := os.ReadFile(expectedPath) Expect(err).ToNot(HaveOccurred()) Expect(content).To(Equal([]byte("thumbnail-data"))) }) It("should create parent directories with 0700 permissions", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte("thumbnail-data")) Expect(err).ToNot(HaveOccurred()) dirPath := filepath.Join(tmpdir, "ab", "cd") info, err := os.Stat(dirPath) Expect(err).ToNot(HaveOccurred()) Expect(info.IsDir()).To(BeTrue()) Expect(info.Mode().Perm()).To(Equal(os.FileMode(0700))) }) It("should return independent copies on Get", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte("original")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) data[0] = 'X' original, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(original).To(Equal([]byte("original"))) }) It("should be idempotent - Put is no-op if file already exists", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte("v1")) Expect(err).ToNot(HaveOccurred()) info1, err := os.Stat(filepath.Join(tmpdir, "ab", "cd", "ef1234567890", "400x400.jpg")) Expect(err).ToNot(HaveOccurred()) modTime1 := info1.ModTime() time.Sleep(10 * time.Millisecond) err = cache.Put(key, []byte("v2")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("v1"))) info2, err := os.Stat(filepath.Join(tmpdir, "ab", "cd", "ef1234567890", "400x400.jpg")) Expect(err).ToNot(HaveOccurred()) Expect(info2.ModTime()).To(Equal(modTime1)) }) It("should handle empty data", func() { key := "ab/cd/ef1234567890/400x400.jpg" err := cache.Put(key, []byte{}) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(data).To(BeEmpty()) }) It("should use atomic write via temp file + rename", func() { key := "ab/cd/ef1234567890/400x400.jpg" watchDir := filepath.Join(tmpdir, "ab", "cd", "ef1234567890") Expect(os.MkdirAll(watchDir, 0700)).To(Succeed()) done := make(chan struct{}) sawTempFile := false go func() { for { select { case <-done: return default: entries, _ := os.ReadDir(watchDir) for _, e := range entries { if strings.HasPrefix(e.Name(), "tmpthumb") { sawTempFile = true } } time.Sleep(1 * time.Millisecond) } } }() err := cache.Put(key, bytes.Repeat([]byte("x"), 1024*1024)) Expect(err).ToNot(HaveOccurred()) close(done) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(len(data)).To(Equal(1024 * 1024)) _ = sawTempFile }) It("should handle disk write error gracefully", func() { blocker := filepath.Join(tmpdir, "blocker") Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) // The cache dir path passes through a regular file, so MkdirAll cannot // create the target directory for any user (root or not) -> Put must error. rc := NewFileCache(filepath.Join(blocker, "sub")) err := rc.Put("ab/cd/ef1234567890/400x400.jpg", []byte("data")) Expect(err).To(HaveOccurred()) }) It("should handle corrupt/partial files on Get", func() { key := "ab/cd/ef1234567890/400x400.jpg" dirPath := filepath.Join(tmpdir, "ab", "cd", "ef1234567890") Expect(os.MkdirAll(dirPath, 0700)).To(Succeed()) filePath := filepath.Join(dirPath, "400x400.jpg") Expect(os.WriteFile(filePath, []byte("corrupt-data"), 0600)).To(Succeed()) data, err := cache.Get(key) Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("corrupt-data"))) }) }) var _ = Describe("NewThumbnailCache factory", func() { It("should create FileCache for 'file' backend", func() { tmpdir, err := os.MkdirTemp("", "factory-test") Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(tmpdir) cache, err := NewThumbnailCache("file", tmpdir, nil) Expect(err).ToNot(HaveOccurred()) fileCache, ok := cache.(*FileCache) Expect(ok).To(BeTrue()) Expect(fileCache.dir).To(Equal(tmpdir)) err = cache.Put("ab/cd/ef1234567890/400x400.jpg", []byte("file-data")) Expect(err).ToNot(HaveOccurred()) data, err := cache.Get("ab/cd/ef1234567890/400x400.jpg") Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("file-data"))) }) It("should create FileCache with default dir when empty string passed", func() { cache, err := NewThumbnailCache("file", "", nil) Expect(err).ToNot(HaveOccurred()) fileCache, ok := cache.(*FileCache) Expect(ok).To(BeTrue()) Expect(fileCache.dir).To(Equal(DefaultFileCacheDir())) }) }) var _ = Describe("S3CacheConfig validation", func() { It("should detect incomplete config", func() { cfg := S3CacheConfig{Bucket: "test"} Expect(cfg.IsComplete()).To(BeFalse()) }) It("should detect complete config with credentials", func() { cfg := S3CacheConfig{ Bucket: "test-bucket", Region: "us-east-1", Credentials: credentials.NewStaticV4("access-key", "secret-key", ""), } Expect(cfg.IsComplete()).To(BeTrue()) }) It("should be incomplete without bucket", func() { cfg := S3CacheConfig{Credentials: credentials.NewStaticV4("access-key", "secret-key", "")} Expect(cfg.IsComplete()).To(BeFalse()) }) It("should be incomplete without region", func() { cfg := S3CacheConfig{Bucket: "test-bucket", Credentials: credentials.NewStaticV4("access-key", "secret-key", "")} Expect(cfg.IsComplete()).To(BeFalse()) }) })