diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx
index d82b9aa23d2..7180f8468db 100644
--- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx
+++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx
@@ -1,3 +1,4 @@
+import { ReactNode } from 'react';
import * as _ from 'lodash';
import {
useResolvedExtensions,
@@ -259,10 +260,16 @@ export const sortCatalogItems = (
}
};
-export const getIconProps = (item: CatalogItem) => {
+interface IconProps {
+ iconImg?: string | null;
+ iconClass?: string | null;
+ icon?: ReactNode;
+}
+
+export const getIconProps = (item: CatalogItem): IconProps => {
const { icon } = item;
if (!icon) {
- return {};
+ return { iconImg: catalogImg, iconClass: null };
}
if (icon.url) {
return { iconImg: icon.url, iconClass: null };
diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx
index ff6dfeafd52..6d81a76d089 100644
--- a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx
+++ b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx
@@ -99,9 +99,9 @@ export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (item) => {
descriptions: [{ value: }],
},
displayName,
- // Remove icon until we have an endpoint to lazy load cached icons.
- // TODO Add icon back once https://issues.redhat.com/browse/CONSOLE-4728 is completed.
- // icon: { url: '/api/olm/catalog-icons// },
+ icon: {
+ url: `/api/olm/catalog-icons/${encodeURIComponent(catalog)}/${encodeURIComponent(name)}`,
+ },
name: displayName || name,
supportUrl: support,
provider,
diff --git a/pkg/olm/catalog.go b/pkg/olm/catalog.go
index dab9c1abf52..dfcc4e500ee 100644
--- a/pkg/olm/catalog.go
+++ b/pkg/olm/catalog.go
@@ -12,6 +12,14 @@ import (
"k8s.io/klog/v2"
)
+// CachedIcon represents a cached operator icon.
+type CachedIcon struct {
+ Data []byte
+ MediaType string
+ LastModified string
+ ETag string
+}
+
// ConsoleCatalogItem represents a single item in the catalog.
type ConsoleCatalogItem struct {
ID string `json:"id"`
diff --git a/pkg/olm/catalog_client.go b/pkg/olm/catalog_client.go
index 667f3f70d6b..6cd6d27551a 100644
--- a/pkg/olm/catalog_client.go
+++ b/pkg/olm/catalog_client.go
@@ -13,6 +13,7 @@ import (
type CatalogdClientInterface interface {
FetchAll(catalog, baseUrl, ifModifiedSince string, maxAge time.Duration) (*http.Response, error)
FetchMetas(catalogName string, baseURL string, r *http.Request) (*http.Response, error)
+ FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error)
}
// CatalogFetcher is responsible for fetching catalog data.
@@ -89,3 +90,30 @@ func (c *CatalogdClient) buildCatalogdURL(catalog, baseURL, endpoint string) (st
}
return url.JoinPath(baseURL, endpoint)
}
+
+// FetchPackageIcon fetches package metadata from catalogd to extract icon data.
+// It queries the metas endpoint with schema=olm.package and name=packageName filters.
+func (c *CatalogdClient) FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error) {
+ requestURL, err := c.buildCatalogdURL(catalog, baseURL, CatalogdMetasEndpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ // Add query parameters to filter for the specific package
+ parsedURL, err := url.Parse(requestURL)
+ if err != nil {
+ return nil, err
+ }
+ query := parsedURL.Query()
+ query.Set("schema", "olm.package")
+ query.Set("name", packageName)
+ parsedURL.RawQuery = query.Encode()
+
+ req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ klog.V(4).Infof("Fetching package icon: %s %s", req.Method, req.URL.String())
+ return c.httpClient.Do(req)
+}
diff --git a/pkg/olm/catalog_service.go b/pkg/olm/catalog_service.go
index db71f181e4c..f3b725b68ad 100644
--- a/pkg/olm/catalog_service.go
+++ b/pkg/olm/catalog_service.go
@@ -1,8 +1,12 @@
package olm
import (
+ "bytes"
+ "crypto/md5"
+ "encoding/hex"
"encoding/json"
"fmt"
+ "io"
"net/http"
"time"
@@ -48,6 +52,10 @@ func getCatalogBaseURLKey(catalog string) string {
return keyPrefix + catalog + ":baseURL"
}
+func getCatalogIconKey(catalog, packageName string) string {
+ return keyPrefix + catalog + ":icon:" + packageName
+}
+
func NewCatalogService(serviceClient *http.Client, proxyConfig *proxy.Config, cache *cache.Cache) *CatalogService {
c := &CatalogService{
cache: cache,
@@ -68,6 +76,106 @@ func (s *CatalogService) GetMetas(catalog string, r *http.Request) (*http.Respon
return s.client.FetchMetas(catalog, baseURL, r)
}
+// GetPackageIcon retrieves the icon for a package from the cache or fetches it from catalogd.
+// Returns the cached icon data, or nil if the package or icon is not found.
+func (s *CatalogService) GetPackageIcon(catalog, packageName string) (*CachedIcon, error) {
+ iconKey := getCatalogIconKey(catalog, packageName)
+
+ // Check cache first
+ if cachedIcon, ok := s.cache.Get(iconKey); ok {
+ if icon, ok := cachedIcon.(*CachedIcon); ok {
+ klog.V(4).Infof("Cache hit for icon: %s/%s", catalog, packageName)
+ return icon, nil
+ }
+ // Malformed cache entry, remove it
+ s.cache.Delete(iconKey)
+ }
+
+ // Cache miss - fetch from catalogd
+ klog.V(4).Infof("Cache miss for icon: %s/%s, fetching from catalogd", catalog, packageName)
+
+ var baseURL string
+ baseURLKey := getCatalogBaseURLKey(catalog)
+ if cachedBaseURL, ok := s.cache.Get(baseURLKey); ok {
+ if baseURL, ok = cachedBaseURL.(string); !ok {
+ return nil, fmt.Errorf("cached base URL for catalog %s is not a string", catalog)
+ }
+ }
+
+ resp, err := s.client.FetchPackageIcon(catalog, baseURL, packageName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch package icon: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return nil, nil
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("catalogd request failed with status: %d", resp.StatusCode)
+ }
+
+ // Parse the response to extract the package icon
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ icon, err := s.parsePackageIcon(body)
+ if err != nil {
+ return nil, err
+ }
+
+ if icon == nil {
+ // Package found but has no icon
+ klog.V(4).Infof("Package %s/%s has no icon", catalog, packageName)
+ return nil, nil
+ }
+
+ // Cache the icon
+ s.cache.Set(iconKey, icon, cacheExpiration)
+ klog.V(4).Infof("Cached icon for %s/%s", catalog, packageName)
+
+ return icon, nil
+}
+
+// parsePackageIcon extracts icon data from the catalogd metas response.
+func (s *CatalogService) parsePackageIcon(body []byte) (*CachedIcon, error) {
+ var pkg declcfg.Package
+
+ // The response is a JSON stream, we need to parse the first (and only) package
+ reader := io.NopCloser(bytes.NewReader(body))
+ if err := declcfg.WalkMetasReader(reader, func(meta *declcfg.Meta, err error) error {
+ if err != nil {
+ return err
+ }
+ if meta.Schema == declcfg.SchemaPackage {
+ if err := json.Unmarshal(meta.Blob, &pkg); err != nil {
+ return fmt.Errorf("failed to unmarshal package: %w", err)
+ }
+ }
+ return nil
+ }); err != nil {
+ return nil, fmt.Errorf("failed to parse package metadata: %w", err)
+ }
+
+ if pkg.Icon == nil || len(pkg.Icon.Data) == 0 {
+ return nil, nil
+ }
+
+ // Generate ETag from icon data
+ hash := md5.Sum(pkg.Icon.Data)
+ etag := hex.EncodeToString(hash[:])
+
+ return &CachedIcon{
+ Data: pkg.Icon.Data,
+ MediaType: pkg.Icon.MediaType,
+ LastModified: time.Now().UTC().Format(http.TimeFormat),
+ ETag: etag,
+ }, nil
+}
+
// Start begins the polling process.
func (s *CatalogService) UpdateCatalog(catalog string, baseURL string) error {
itemsKey := getCatalogItemsKey(catalog)
diff --git a/pkg/olm/catalog_service_test.go b/pkg/olm/catalog_service_test.go
index 88de31dbe13..fc84280302c 100644
--- a/pkg/olm/catalog_service_test.go
+++ b/pkg/olm/catalog_service_test.go
@@ -18,9 +18,12 @@ import (
)
type mockCatalogdClient struct {
- packages []declcfg.Package
- bundles []declcfg.Bundle
- err error
+ packages []declcfg.Package
+ bundles []declcfg.Bundle
+ packageIconMap map[string]*declcfg.Package // packageName -> package with icon
+ err error
+ fetchPackageErr error
+ fetchPackageCode int
}
func (m *mockCatalogdClient) FetchMetas(catalog string, baseURL string, r *http.Request) (*http.Response, error) {
@@ -28,6 +31,44 @@ func (m *mockCatalogdClient) FetchMetas(catalog string, baseURL string, r *http.
return nil, fmt.Errorf("FetchMetas not implemented in mock")
}
+func (m *mockCatalogdClient) FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error) {
+ if m.fetchPackageErr != nil {
+ return nil, m.fetchPackageErr
+ }
+
+ if m.fetchPackageCode == http.StatusNotFound {
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(bytes.NewReader([]byte{})),
+ }, nil
+ }
+
+ pkg, ok := m.packageIconMap[packageName]
+ if !ok {
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(bytes.NewReader([]byte{})),
+ }, nil
+ }
+
+ var buf bytes.Buffer
+ encoder := json.NewEncoder(&buf)
+ pkgMap := map[string]any{
+ "schema": declcfg.SchemaPackage,
+ "name": pkg.Name,
+ "defaultChannel": pkg.DefaultChannel,
+ "icon": pkg.Icon,
+ }
+ if err := encoder.Encode(pkgMap); err != nil {
+ return nil, err
+ }
+
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(buf.Bytes())),
+ }, nil
+}
+
func (m *mockCatalogdClient) FetchAll(catalog, baseURL, ifNotModifiedSince string, maxAge time.Duration) (*http.Response, error) {
if m.err != nil {
return nil, m.err
@@ -228,3 +269,132 @@ func TestGetCatalogItems(t *testing.T) {
assert.Equal(t, items, returnedItems)
})
}
+
+func TestGetPackageIcon(t *testing.T) {
+ t.Run("should return icon from cache", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ cachedIcon := &CachedIcon{
+ Data: []byte("cached-icon-data"),
+ MediaType: "image/svg+xml",
+ LastModified: time.Now().UTC().Format(http.TimeFormat),
+ ETag: "cached-etag",
+ }
+ c.Set(getCatalogIconKey("test-catalog", "test-package"), cachedIcon, cache.NoExpiration)
+
+ service := &CatalogService{
+ cache: c,
+ index: make(map[string]struct{}),
+ }
+
+ icon, err := service.GetPackageIcon("test-catalog", "test-package")
+
+ require.NoError(t, err)
+ assert.Equal(t, cachedIcon, icon)
+ })
+
+ t.Run("should fetch icon from catalogd on cache miss", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ // Set the base URL so the service knows where to fetch from
+ c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration)
+
+ iconData := []byte("test-icon-data")
+ client := &mockCatalogdClient{
+ packageIconMap: map[string]*declcfg.Package{
+ "test-package": {
+ Name: "test-package",
+ Icon: &declcfg.Icon{
+ Data: iconData,
+ MediaType: "image/png",
+ },
+ },
+ },
+ }
+
+ service := &CatalogService{
+ cache: c,
+ client: client,
+ index: make(map[string]struct{}),
+ }
+
+ icon, err := service.GetPackageIcon("test-catalog", "test-package")
+
+ require.NoError(t, err)
+ require.NotNil(t, icon)
+ assert.Equal(t, iconData, icon.Data)
+ assert.Equal(t, "image/png", icon.MediaType)
+ assert.NotEmpty(t, icon.ETag)
+ assert.NotEmpty(t, icon.LastModified)
+
+ // Verify the icon was cached
+ cachedIcon, found := c.Get(getCatalogIconKey("test-catalog", "test-package"))
+ assert.True(t, found)
+ assert.Equal(t, icon, cachedIcon)
+ })
+
+ t.Run("should return nil when package not found", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration)
+
+ client := &mockCatalogdClient{
+ packageIconMap: map[string]*declcfg.Package{},
+ fetchPackageCode: http.StatusNotFound,
+ }
+
+ service := &CatalogService{
+ cache: c,
+ client: client,
+ index: make(map[string]struct{}),
+ }
+
+ icon, err := service.GetPackageIcon("test-catalog", "nonexistent-package")
+
+ require.NoError(t, err)
+ assert.Nil(t, icon)
+ })
+
+ t.Run("should return nil when package has no icon", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration)
+
+ client := &mockCatalogdClient{
+ packageIconMap: map[string]*declcfg.Package{
+ "test-package": {
+ Name: "test-package",
+ Icon: nil, // No icon
+ },
+ },
+ }
+
+ service := &CatalogService{
+ cache: c,
+ client: client,
+ index: make(map[string]struct{}),
+ }
+
+ icon, err := service.GetPackageIcon("test-catalog", "test-package")
+
+ require.NoError(t, err)
+ assert.Nil(t, icon)
+ })
+
+ t.Run("should return error when fetch fails", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration)
+
+ client := &mockCatalogdClient{
+ fetchPackageErr: fmt.Errorf("network error"),
+ }
+
+ service := &CatalogService{
+ cache: c,
+ client: client,
+ index: make(map[string]struct{}),
+ }
+
+ icon, err := service.GetPackageIcon("test-catalog", "test-package")
+
+ require.Error(t, err)
+ assert.Nil(t, icon)
+ assert.Contains(t, err.Error(), "network error")
+ })
+}
diff --git a/pkg/olm/handler.go b/pkg/olm/handler.go
index 947aa7f7963..f017ce1ecd3 100644
--- a/pkg/olm/handler.go
+++ b/pkg/olm/handler.go
@@ -32,6 +32,7 @@ func NewOLMHandler(apiServerURL string, client *http.Client, service *CatalogSer
mux := http.NewServeMux()
mux.HandleFunc("/api/olm/catalog-items/", o.catalogItemsHandler)
mux.HandleFunc("/api/olm/catalogd/metas/{catalogName}", middleware.AllowMethod(http.MethodGet, o.catalogdMetasHandler))
+ mux.HandleFunc("/api/olm/catalog-icons/{catalogName}/{packageName}", middleware.AllowMethod(http.MethodGet, o.catalogIconHandler))
mux.HandleFunc("/api/olm/list-operands/", o.operandsListHandler)
mux.HandleFunc("/api/olm/check-package-manifests/", o.checkPackageManifestHandler)
o.mux = mux
@@ -95,6 +96,62 @@ func (o *OLMHandler) catalogItemsHandler(w http.ResponseWriter, r *http.Request)
}
}
+// catalogIconHandler serves operator icons by package name with caching support.
+// It returns 304 Not Modified if the client's cached version is still valid.
+func (o *OLMHandler) catalogIconHandler(w http.ResponseWriter, r *http.Request) {
+ catalogName := r.PathValue("catalogName")
+ packageName := r.PathValue("packageName")
+
+ if catalogName == "" || packageName == "" {
+ serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: "catalog name and package name are required"})
+ return
+ }
+
+ icon, err := o.catalogService.GetPackageIcon(catalogName, packageName)
+ if err != nil {
+ klog.Errorf("Failed to get icon for %s/%s: %v", catalogName, packageName, err)
+ serverutils.SendResponse(w, http.StatusInternalServerError, serverutils.ApiError{Err: fmt.Sprintf("Failed to get icon: %v", err)})
+ return
+ }
+
+ if icon == nil {
+ serverutils.SendResponse(w, http.StatusNotFound, serverutils.ApiError{Err: "icon not found"})
+ return
+ }
+
+ // Check If-None-Match header for ETag validation
+ ifNoneMatch := r.Header.Get("If-None-Match")
+ if ifNoneMatch != "" && ifNoneMatch == fmt.Sprintf(`"%s"`, icon.ETag) {
+ klog.V(4).Infof("Icon not modified (ETag match) for %s/%s", catalogName, packageName)
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ // Check If-Modified-Since header
+ modified, err := serverutils.ModifiedSince(r, icon.LastModified)
+ if err != nil {
+ klog.Warningf("Invalid If-Modified-Since header: %v", err)
+ // Continue serving the icon even if the header is invalid
+ } else if !modified {
+ klog.V(4).Infof("Icon not modified for %s/%s", catalogName, packageName)
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ // Set cache headers
+ w.Header().Set("Content-Type", icon.MediaType)
+ w.Header().Set("Cache-Control", "public, max-age=86400") // 24 hours
+ w.Header().Set("ETag", fmt.Sprintf(`"%s"`, icon.ETag))
+ if icon.LastModified != "" {
+ w.Header().Set("Last-Modified", icon.LastModified)
+ }
+
+ // Write icon data
+ if _, err := w.Write(icon.Data); err != nil {
+ klog.Errorf("Failed to write icon data: %v", err)
+ }
+}
+
func (o *OLMHandler) checkPackageManifestHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
diff --git a/pkg/olm/handler_test.go b/pkg/olm/handler_test.go
index c6ae5a674a9..b19e50e4a44 100644
--- a/pkg/olm/handler_test.go
+++ b/pkg/olm/handler_test.go
@@ -1,7 +1,9 @@
package olm
import (
+ "encoding/base64"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -114,3 +116,158 @@ func TestOLMHandler_catalogdMetasHandler(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, rr.Code)
})
}
+
+func TestOLMHandler_catalogIconHandler(t *testing.T) {
+ t.Run("should return icon when found in cache", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ icon := &CachedIcon{
+ Data: []byte("test-icon-data"),
+ MediaType: "image/png",
+ LastModified: time.Now().UTC().Format(http.TimeFormat),
+ ETag: "abc123",
+ }
+ c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration)
+
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil)
+ req.SetPathValue("catalogName", "test-catalog")
+ req.SetPathValue("packageName", "test-package")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Equal(t, "image/png", rr.Header().Get("Content-Type"))
+ assert.Equal(t, `"abc123"`, rr.Header().Get("ETag"))
+ assert.Equal(t, "public, max-age=86400", rr.Header().Get("Cache-Control"))
+ assert.Equal(t, "test-icon-data", rr.Body.String())
+ })
+
+ t.Run("should return 404 when icon not found", func(t *testing.T) {
+ // Create a mock catalogd server that returns no icon
+ catalogdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer catalogdServer.Close()
+
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ c.Set(getCatalogBaseURLKey("test-catalog"), catalogdServer.URL, cache.NoExpiration)
+
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil)
+ req.SetPathValue("catalogName", "test-catalog")
+ req.SetPathValue("packageName", "test-package")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+ })
+
+ t.Run("should return 304 when ETag matches", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ icon := &CachedIcon{
+ Data: []byte("test-icon-data"),
+ MediaType: "image/png",
+ LastModified: time.Now().UTC().Format(http.TimeFormat),
+ ETag: "abc123",
+ }
+ c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration)
+
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil)
+ req.SetPathValue("catalogName", "test-catalog")
+ req.SetPathValue("packageName", "test-package")
+ req.Header.Set("If-None-Match", `"abc123"`)
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusNotModified, rr.Code)
+ })
+
+ t.Run("should return 304 when If-Modified-Since is after LastModified", func(t *testing.T) {
+ lastModified := time.Now().Add(-1 * time.Hour)
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ icon := &CachedIcon{
+ Data: []byte("test-icon-data"),
+ MediaType: "image/png",
+ LastModified: lastModified.UTC().Format(http.TimeFormat),
+ ETag: "abc123",
+ }
+ c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration)
+
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil)
+ req.SetPathValue("catalogName", "test-catalog")
+ req.SetPathValue("packageName", "test-package")
+ // Set If-Modified-Since to after the icon's LastModified time
+ req.Header.Set("If-Modified-Since", time.Now().UTC().Format(http.TimeFormat))
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusNotModified, rr.Code)
+ })
+
+ t.Run("should return icon when fetched from catalogd on cache miss", func(t *testing.T) {
+ // Create a mock catalogd server that returns a package with icon
+ iconData := []byte("mock-icon-data")
+ base64IconData := base64.StdEncoding.EncodeToString(iconData)
+ catalogdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify the query parameters
+ assert.Equal(t, "olm.package", r.URL.Query().Get("schema"))
+ assert.Equal(t, "test-package", r.URL.Query().Get("name"))
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ // Write a package with icon in FBC format (icon data must be base64 encoded)
+ pkgJSON := fmt.Sprintf(`{"schema":"olm.package","name":"test-package","icon":{"base64data":"%s","mediatype":"image/png"}}`,
+ base64IconData)
+ w.Write([]byte(pkgJSON + "\n"))
+ }))
+ defer catalogdServer.Close()
+
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ c.Set(getCatalogBaseURLKey("test-catalog"), catalogdServer.URL, cache.NoExpiration)
+
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil)
+ req.SetPathValue("catalogName", "test-catalog")
+ req.SetPathValue("packageName", "test-package")
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Equal(t, "image/png", rr.Header().Get("Content-Type"))
+ assert.NotEmpty(t, rr.Header().Get("ETag"))
+ assert.NotEmpty(t, rr.Header().Get("Cache-Control"))
+ assert.Equal(t, iconData, rr.Body.Bytes())
+ })
+
+ t.Run("should return 404 when URL does not match pattern", func(t *testing.T) {
+ c := cache.New(5*time.Minute, 10*time.Minute)
+ service := NewCatalogService(&http.Client{}, nil, c)
+ handler := NewOLMHandler("", nil, service)
+
+ // URL with missing package name doesn't match the route pattern
+ req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog", nil)
+ rr := httptest.NewRecorder()
+
+ handler.ServeHTTP(rr, req)
+
+ // Router returns 404 when URL doesn't match pattern
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+ })
+}