diff --git a/workspaces/quay/plugins/quay/dev/__data__/entity.ts b/workspaces/quay/plugins/quay/dev/__data__/entity.ts new file mode 100644 index 00000000000..3cedba28f74 --- /dev/null +++ b/workspaces/quay/plugins/quay/dev/__data__/entity.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; + +export const mockQuayEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'quay-instance', + description: 'Quay example instance', + annotations: { + 'quay.io/repository-slug': 'backstage-test/test-images', + }, + }, + spec: { + lifecycle: 'production', + type: 'service', + owner: 'user:guest', + }, +}; + +export const mockQuayInstanceDevelEntity: Entity = { + ...mockQuayEntity, + metadata: { + ...mockQuayEntity.metadata, + name: 'quay-instance-devel', + annotations: { + ...mockQuayEntity.metadata.annotations, + 'quay.io/instance-name': 'devel', + }, + }, +}; diff --git a/workspaces/quay/plugins/quay/dev/__data__/security_vulnerabilities.ts b/workspaces/quay/plugins/quay/dev/__data__/security_vulnerabilities.ts index e1202943387..1ef3617713f 100644 --- a/workspaces/quay/plugins/quay/dev/__data__/security_vulnerabilities.ts +++ b/workspaces/quay/plugins/quay/dev/__data__/security_vulnerabilities.ts @@ -388,3 +388,16 @@ export const v4securityDetails: SecurityDetailsResponse = { } as Layer, }, }; + +export const digestSecurityDetails: Record = { + 'sha256:29c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d': + v4securityDetails, + 'sha256:79c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d': + v3securityDetails, + 'sha256:89c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775e': + v2securityDetails, + 'sha256:99c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775f': + v1securityDetails, + 'sha256:69c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775c': + securityDetails, +}; diff --git a/workspaces/quay/plugins/quay/dev/index.tsx b/workspaces/quay/plugins/quay/dev/index.tsx index f47997b7dbf..ee5dd389d0c 100644 --- a/workspaces/quay/plugins/quay/dev/index.tsx +++ b/workspaces/quay/plugins/quay/dev/index.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend import '@backstage/ui/css/styles.css'; -import { Entity } from '@backstage/catalog-model'; import { createDevApp } from '@backstage/dev-utils'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { permissionApiRef } from '@backstage/plugin-permission-react'; @@ -23,40 +24,39 @@ import { mockApis, TestApiProvider } from '@backstage/test-utils'; import { quayApiRef, QuayApiV1, QuayInstanceConfig } from '../src/api'; import { QuayPage, quayPlugin } from '../src/plugin'; +import { mockQuayEntity, mockQuayInstanceDevelEntity } from './__data__/entity'; import { labels } from './__data__/labels'; import { manifestDigest } from './__data__/manifest_digest'; import { + digestSecurityDetails, securityDetails, v1securityDetails, - v2securityDetails, - v3securityDetails, - v4securityDetails, } from './__data__/security_vulnerabilities'; import { tags } from './__data__/tags'; -const mockEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'backstage', - description: 'backstage.io', - annotations: { - 'quay.io/repository-slug': 'backstage-test/test-images', - }, - }, - spec: { - lifecycle: 'production', - type: 'service', - owner: 'user:guest', - }, -}; - export class MockQuayApiClient implements QuayApiV1 { - getQuayInstance(_?: string): QuayInstanceConfig | undefined { + getQuayInstance(instanceName?: string): QuayInstanceConfig | undefined { + if (instanceName === 'devel') { + return { name: 'devel', apiUrl: 'https://quay-devel.io' }; + } + return { name: 'default', apiUrl: 'https://quay.io' }; } - async getTags() { + async getTags(instanceName?: string) { + if (instanceName === 'devel') { + return { + tags: [ + { + ...tags.tags[0], + name: 'v5-devel-only', + }, + ], + page: 1, + has_additional: false, + }; + } + return tags; } @@ -68,35 +68,17 @@ export class MockQuayApiClient implements QuayApiV1 { return manifestDigest; } - async getSecurityDetails(_: string, __: string, ___: string, digest: string) { - if ( - digest === - 'sha256:79c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d' - ) { - return v3securityDetails; + async getSecurityDetails( + instanceName: string | undefined, + _: string, + __: string, + digest: string, + ) { + if (instanceName === 'devel') { + return { ...v1securityDetails }; } - if ( - digest === - 'sha256:89c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775e' - ) { - return v2securityDetails; - } - if ( - digest === - 'sha256:99c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775f' - ) { - return v1securityDetails; - } - - if ( - digest === - 'sha256:29c96c750aa532d92d9cb56cad59159b7cc26b10e39ff4a895c28345d2cd775d' - ) { - return v4securityDetails; - } - - return securityDetails; + return digestSecurityDetails[digest] ?? securityDetails; } } @@ -110,7 +92,7 @@ createDevApp() [permissionApiRef, mockApis.permission()], ]} > - + @@ -118,4 +100,23 @@ createDevApp() title: 'Root Page', path: '/quay', }) + .addPage({ + element: ( + + + + + + ), + title: 'Multi-instance', + path: '/quay/multi-instance', + }) .render(); diff --git a/workspaces/quay/plugins/quay/src/api/index.backend.test.ts b/workspaces/quay/plugins/quay/src/api/index.backend.test.ts index cc0add70d94..8ba3059c208 100644 --- a/workspaces/quay/plugins/quay/src/api/index.backend.test.ts +++ b/workspaces/quay/plugins/quay/src/api/index.backend.test.ts @@ -256,6 +256,27 @@ describe('QuayApiClient-Backend', () => { ); }); + it('should throw when a non-existent instance is requested', async () => { + quayApi = QuayApiClient.fromConfig({ + configApi: mockApis.config({ + data: { + quay: { + instances: [ + { name: 'devel', apiUrl: 'https://quay.devel.example.io' }, + { name: 'staging', apiUrl: 'https://quay.staging.example.io' }, + ], + }, + }, + }), + discoveryApi: mockDiscoveryApi, + identityApi: identityApi, + }); + + await expect(quayApi.getTags('unknown', 'foo', 'bar')).rejects.toEqual( + new Error('Quay instance "unknown" not found in configuration.'), + ); + }); + it('should throw an error when the response is not ok', async () => { await expect(quayApi.getTags(undefined, 'not', 'found')).rejects.toEqual( new Error('failed to fetch data, status 404: Not Found'), diff --git a/workspaces/quay/plugins/quay/src/api/index.test.ts b/workspaces/quay/plugins/quay/src/api/index.test.ts index ccdf0339719..c24d933b9a9 100644 --- a/workspaces/quay/plugins/quay/src/api/index.test.ts +++ b/workspaces/quay/plugins/quay/src/api/index.test.ts @@ -246,6 +246,58 @@ describe('QuayApiClient', () => { ); }); + it('should throw when single-instance and multi-instance configs are mixed', () => { + expect(() => + QuayApiClient.fromConfig({ + configApi: mockApis.config({ + data: { + quay: { + apiUrl: 'https://quay.io', + instances: [{ name: 'devel' }], + }, + }, + }), + discoveryApi: mockDiscoveryApi, + identityApi: identityApi, + }), + ).toThrow( + 'Invalid Quay configuration: Cannot use both "quay.instances" (multi-instance) and "quay.apiUrl", "quay.proxyPath", "quay.uiUrl" (single-instance) at the same time.', + ); + }); + + it('should throw when a non-existent instance is requested', async () => { + quayApi = QuayApiClient.fromConfig({ + configApi: mockApis.config({ + data: { + quay: { + instances: [{ name: 'devel' }, { name: 'staging' }], + }, + }, + }), + discoveryApi: mockDiscoveryApi, + identityApi: identityApi, + }); + + await expect(quayApi.getTags('unknown', 'foo', 'bar')).rejects.toEqual( + new Error('Quay instance "unknown" not found in configuration.'), + ); + await expect( + quayApi.getLabels('unknown', 'foo', 'bar', 'sha256:123'), + ).rejects.toEqual( + new Error('Quay instance "unknown" not found in configuration.'), + ); + await expect( + quayApi.getManifestByDigest('unknown', 'foo', 'bar', 'sha256:123'), + ).rejects.toEqual( + new Error('Quay instance "unknown" not found in configuration.'), + ); + await expect( + quayApi.getSecurityDetails('unknown', 'foo', 'bar', 'sha256:123'), + ).rejects.toEqual( + new Error('Quay instance "unknown" not found in configuration.'), + ); + }); + it('should throw an error when the response is not ok', async () => { await expect( quayApi.getTags(QUAY_SINGLE_INSTANCE_NAME, 'not', 'found'), diff --git a/workspaces/quay/plugins/quay/src/components/QuayRepository/QuayRepository.test.tsx b/workspaces/quay/plugins/quay/src/components/QuayRepository/QuayRepository.test.tsx index 97daf7ebedb..56740f13d78 100644 --- a/workspaces/quay/plugins/quay/src/components/QuayRepository/QuayRepository.test.tsx +++ b/workspaces/quay/plugins/quay/src/components/QuayRepository/QuayRepository.test.tsx @@ -44,10 +44,7 @@ const mockUsePermission = usePermission as jest.MockedFunction< >; const mockQuayApi: Partial = { - getQuayInstance: jest.fn().mockReturnValue({ - name: 'default', - apiUrl: 'https://quay.example.com', - }), + getQuayInstance: jest.fn(), }; describe('QuayRepository', () => { @@ -60,6 +57,10 @@ describe('QuayRepository', () => { beforeEach(() => { mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + (mockQuayApi.getQuayInstance as jest.Mock).mockReturnValue({ + name: 'default', + apiUrl: 'https://quay.example.com', + }); }); afterAll(() => { @@ -82,7 +83,8 @@ describe('QuayRepository', () => { expect(getByTestId('quay-repo-progress')).not.toBeNull(); }); - it('should show empty table if loaded and data is not present', async () => { + it('should show empty table and no title links if loaded and data is not present', async () => { + (mockQuayApi.getQuayInstance as jest.Mock).mockReturnValue(undefined); (useTags as jest.Mock).mockReturnValue({ loading: false, data: [] }); const { getByTestId, queryByText } = await renderComponent(); expect(getByTestId('quay-repo-table')).not.toBeNull(); @@ -94,9 +96,14 @@ describe('QuayRepository', () => { "This repository doesn't contain any images yet, or there might be an access issue.", ), ).toBeInTheDocument(); + expect( + queryByText('backstage-community/redhat-backstage-build', { + selector: 'a', + }), + ).not.toBeInTheDocument(); }); - it('should show table if loaded and data is present', async () => { + it('should show table and title links if loaded and data is present', async () => { (useTags as jest.Mock).mockReturnValue({ loading: false, data: [ @@ -116,6 +123,17 @@ describe('QuayRepository', () => { expect( queryByText('There are no images available.'), ).not.toBeInTheDocument(); + const repositoryLink = queryByText( + 'backstage-community/redhat-backstage-build', + { + selector: 'a', + }, + ); + expect(repositoryLink).toBeInTheDocument(); + expect(repositoryLink).toHaveAttribute( + 'href', + 'https://quay.example.com/repository/backstage-community/redhat-backstage-build', + ); }); it('should show table if loaded and data is present but shows progress if security scan is not loaded', async () => { diff --git a/workspaces/quay/plugins/quay/src/components/useQuayAppData.test.ts b/workspaces/quay/plugins/quay/src/components/useQuayAppData.test.ts index e981aeb7227..6c8cb9647e0 100644 --- a/workspaces/quay/plugins/quay/src/components/useQuayAppData.test.ts +++ b/workspaces/quay/plugins/quay/src/components/useQuayAppData.test.ts @@ -18,22 +18,48 @@ import { Entity } from '@backstage/catalog-model'; import { useQuayAppData } from '../hooks'; describe('useQuayAppData', () => { - it('should correctly get the repository flag from the entity', () => { + it('should correctly get the repository and instance flag from the entity', () => { const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'foo', - annotations: { 'quay.io/repository-slug': 'foo/bar' }, + annotations: { + 'quay.io/repository-slug': 'foo/bar', + 'quay.io/instance-name': 'devel', + }, }, }; const result = useQuayAppData({ entity }); - expect(result).toEqual({ repositorySlug: 'foo/bar' }); + expect(result).toEqual({ + repositorySlug: 'foo/bar', + instanceSlug: 'devel', + }); }); - it('should throw an error when the annotation is not present', () => { + it('should return undefined instance slug if not set', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'foo', + annotations: { + 'quay.io/repository-slug': 'foo/bar', + }, + }, + }; + + const result = useQuayAppData({ entity }); + + expect(result).toEqual({ + repositorySlug: 'foo/bar', + instanceSlug: undefined, + }); + }); + + it('should throw an error when the repository annotation is not present', () => { const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/workspaces/quay/plugins/quay/src/hooks/useRepository.test.ts b/workspaces/quay/plugins/quay/src/hooks/useRepository.test.ts index b723ed7f12d..f629cd39c45 100644 --- a/workspaces/quay/plugins/quay/src/hooks/useRepository.test.ts +++ b/workspaces/quay/plugins/quay/src/hooks/useRepository.test.ts @@ -17,23 +17,29 @@ import { renderHook } from '@testing-library/react'; import { useRepository } from './quay'; +const mockUseEntity = jest.fn(); + jest.mock('@backstage/plugin-catalog-react', () => ({ - useEntity: () => ({ - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'foo', - annotations: { - 'quay.io/repository-slug': 'foo/bar', - 'quay.io/instance-name': 'devel', - }, - }, - }, - }), + useEntity: () => mockUseEntity(), })); describe('useRepository', () => { + beforeEach(() => { + mockUseEntity.mockReturnValue({ + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'foo', + annotations: { + 'quay.io/repository-slug': 'foo/bar', + 'quay.io/instance-name': 'devel', + }, + }, + }, + }); + }); + it('should return instanceName, organization and repository', () => { const { result } = renderHook(() => useRepository()); expect(result.current).toEqual({ @@ -42,4 +48,26 @@ describe('useRepository', () => { repository: 'bar', }); }); + + it('should return undefined instanceName when not set', () => { + mockUseEntity.mockReturnValue({ + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'foo', + annotations: { + 'quay.io/repository-slug': 'foo/bar', + }, + }, + }, + }); + + const { result } = renderHook(() => useRepository()); + expect(result.current).toEqual({ + instanceName: undefined, + organization: 'foo', + repository: 'bar', + }); + }); }); diff --git a/workspaces/quay/plugins/quay/src/hooks/useTags.test.ts b/workspaces/quay/plugins/quay/src/hooks/useTags.test.ts index 2bcea89a74c..92217467981 100644 --- a/workspaces/quay/plugins/quay/src/hooks/useTags.test.ts +++ b/workspaces/quay/plugins/quay/src/hooks/useTags.test.ts @@ -109,4 +109,22 @@ describe('useTags', () => { expect(result.current.data[0].expiration).toBe(formatDate(expiration)); }); }); + + it('should return empty data and stop loading when instance not found', async () => { + (useApi as jest.Mock).mockReturnValueOnce({ + getSecurityDetails: jest.fn(), + getTags: jest + .fn() + .mockRejectedValue( + new Error('Quay instance "unknown" not found in configuration.'), + ), + }); + + const { result } = renderHook(() => useTags('unknown', 'foo', 'bar')); + + await waitFor(() => { + expect(result.current.loading).toBeFalsy(); + expect(result.current.data).toEqual([]); + }); + }); }); diff --git a/workspaces/quay/plugins/quay/tests/quay.spec.ts b/workspaces/quay/plugins/quay/tests/quay.spec.ts index 9b9dc1fce5c..32dc495a7d5 100644 --- a/workspaces/quay/plugins/quay/tests/quay.spec.ts +++ b/workspaces/quay/plugins/quay/tests/quay.spec.ts @@ -137,4 +137,26 @@ test.describe('Quay plugin', () => { ).toBeEnabled(); }); }); + + test('Multi-instance uses configured non-default instance for URL and data', async () => { + await page.goto('/quay/multi-instance'); + const repositoryLink = page.getByRole('link', { + name: 'backstage-test/test-images', + }); + await expect(repositoryLink).toBeVisible(); + await expect(repositoryLink).toHaveAttribute( + 'href', + 'https://quay-devel.io/repository/backstage-test/test-images', + ); + + await expect( + page.getByRole('cell', { name: 'v5-devel-only' }), + ).toBeVisible(); + const rows = page + .locator('tbody') + .getByRole('row') + .filter({ hasText: 'sha' }); + await expect(rows).toHaveCount(1); + await expect(page.getByText('Unsupported')).toBeVisible(); + }); });