diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.test.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.test.tsx new file mode 100644 index 000000000..22baba628 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.test.tsx @@ -0,0 +1,702 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { vi, beforeEach, afterEach } from 'vitest'; + +import { Application } from '../../types'; + +import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable'; + +// Mock icon components +vi.mock('lucide-react', () => ({ + Asterisk: () => , + File: () => , + FileCode: () => , + Key: () => , + Lock: () => , +})); + +// Mock UI components +vi.mock('@@/Icon', () => ({ + Icon: ({ + icon: IconComponent, + ...props + }: { + icon: React.ComponentType; + [key: string]: unknown; + }) => , +})); + +vi.mock('@@/Tip/TextTip', () => ({ + TextTip: ({ + children, + color, + }: { + children: React.ReactNode; + color?: string; + }) => ( +
+ {children} +
+ ), +})); + +// Mock the Link component to capture routing props +const mockLink = vi.fn(); +vi.mock('@@/Link', () => ({ + Link: ({ + children, + to, + params, + 'data-cy': dataCy, + className, + }: { + children: React.ReactNode; + to: string; + params: Record; + 'data-cy'?: string; + className?: string; + }) => { + mockLink({ children, to, params, 'data-cy': dataCy, className }); + return ( + + {children} + + ); + }, +})); + +describe('ApplicationEnvVarsTable', () => { + beforeEach(() => { + mockLink.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render helpful tip when there are no environment variables', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect( + screen.getByText('Environment variables, ConfigMaps or Secrets') + ).toBeInTheDocument(); + expect(screen.getByTestId('text-tip')).toBeInTheDocument(); + expect( + screen.getByText( + 'This application is not using any environment variable, ConfigMap or Secret.' + ) + ).toBeInTheDocument(); + }); + + it('should render nothing when app is undefined', () => { + render(); + + expect( + screen.getByText('Environment variables, ConfigMaps or Secrets') + ).toBeInTheDocument(); + expect(screen.getByTestId('text-tip')).toBeInTheDocument(); + }); + + it('should render regular environment variables with direct values', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'ENV_VAR', + value: 'test-value', + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('ENV_VAR')).toBeInTheDocument(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); // No configuration resource + }); + + it('should render configmap environment variables with correct routing', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'CONFIG_VAR', + valueFrom: { + configMapKeyRef: { + name: 'test-configmap', + key: 'config-key', + }, + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns + // Note: config-key is not displayed in UI - the component shows the env var name + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + expect(screen.getByTestId('key-icon')).toBeInTheDocument(); + expect(screen.getByTestId('file-code-icon')).toBeInTheDocument(); + + // Verify the Link component was called with correct routing parameters + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'test-configmap', + namespace: 'default', + }, + 'data-cy': 'configmap-link-test-configmap', + }) + ); + }); + + it('should render secret environment variables with correct routing', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'SECRET_VAR', + valueFrom: { + secretKeyRef: { + name: 'test-secret', + key: 'secret-key', + }, + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns + // Note: secret-key is not displayed in UI - the component shows the env var name + expect(screen.getByText('test-secret')).toBeInTheDocument(); + expect(screen.getByTestId('key-icon')).toBeInTheDocument(); + expect(screen.getByTestId('lock-icon')).toBeInTheDocument(); + + // Verify the Link component was called with correct routing parameters for secret + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'test-secret', + namespace: 'default', + }, + 'data-cy': 'configmap-link-test-secret', + }) + ); + }); + + it('should render downward API field references', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'POD_NAME', + valueFrom: { + fieldRef: { + fieldPath: 'metadata.name', + }, + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('POD_NAME')).toBeInTheDocument(); + expect( + screen.getByText( + (content, element) => + content.includes('metadata.name') && element?.tagName === 'SPAN' + ) + ).toBeInTheDocument(); + expect(screen.getByText('downward API')).toBeInTheDocument(); + expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); // No configuration resource + }); + + it('should render envFrom configmap (entire configmap import)', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + envFrom: [ + { + configMapRef: { + name: 'entire-configmap', + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getAllByText('-')).toHaveLength(2); // EnvFrom doesn't have a specific key name or value + expect(screen.getByText('entire-configmap')).toBeInTheDocument(); + expect(screen.getByTestId('file-code-icon')).toBeInTheDocument(); + + // Verify configmap routing + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'entire-configmap', + namespace: 'default', + }, + 'data-cy': 'configmap-link-entire-configmap', + }) + ); + }); + + it('should render envFrom secret (entire secret import)', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + envFrom: [ + { + secretRef: { + name: 'entire-secret', + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getAllByText('-')).toHaveLength(2); // EnvFrom doesn't have a specific key name or value + expect(screen.getByText('entire-secret')).toBeInTheDocument(); + expect(screen.getByTestId('lock-icon')).toBeInTheDocument(); + + // Verify secret routing + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'entire-secret', + namespace: 'default', + }, + 'data-cy': 'configmap-link-entire-secret', + }) + ); + }); + + it('should render init containers with asterisk indicator', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'main-container', + image: 'main-image', + env: [ + { + name: 'MAIN_VAR', + value: 'main-value', + }, + ], + }, + ], + initContainers: [ + { + name: 'init-container', + image: 'init-image', + env: [ + { + name: 'INIT_VAR', + value: 'init-value', + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + // Check main container + expect(screen.getByText('main-container')).toBeInTheDocument(); + expect(screen.getByText('MAIN_VAR')).toBeInTheDocument(); + expect(screen.getByText('main-value')).toBeInTheDocument(); + + // Check init container + expect(screen.getByText('init-container')).toBeInTheDocument(); + expect(screen.getByText('INIT_VAR')).toBeInTheDocument(); + expect(screen.getByText('init-value')).toBeInTheDocument(); + expect(screen.getByText('init container')).toBeInTheDocument(); + expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument(); + }); + + it('should handle mixed environment variable types correctly', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'REGULAR_VAR', + value: 'regular-value', + }, + { + name: 'CONFIG_VAR', + valueFrom: { + configMapKeyRef: { + name: 'test-configmap', + key: 'config-key', + }, + }, + }, + { + name: 'SECRET_VAR', + valueFrom: { + secretKeyRef: { + name: 'test-secret', + key: 'secret-key', + }, + }, + }, + ], + envFrom: [ + { + configMapRef: { + name: 'entire-configmap', + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getAllByText('test-container')).toHaveLength(4); // Should appear 4 times - once for each env var + expect(screen.getByText('REGULAR_VAR')).toBeInTheDocument(); + expect(screen.getByText('regular-value')).toBeInTheDocument(); + expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns + // Note: config-key is not displayed in UI - the component shows the env var name + expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns + // Note: secret-key is not displayed in UI - the component shows the env var name + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + expect(screen.getByText('test-secret')).toBeInTheDocument(); + expect(screen.getByText('entire-configmap')).toBeInTheDocument(); + + // Should have made multiple Link calls + expect(mockLink).toHaveBeenCalledTimes(3); + + // Verify different routing calls + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + }) + ); + + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + }) + ); + }); + + it('should handle Deployment kind applications', () => { + const app: Application = { + metadata: { name: 'test-deployment', namespace: 'default' }, + spec: { + selector: { + matchLabels: { + app: 'test-app', + }, + }, + template: { + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'ENV_VAR', + value: 'test-value', + }, + ], + }, + ], + }, + }, + }, + kind: 'Deployment', + apiVersion: 'apps/v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('ENV_VAR')).toBeInTheDocument(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should handle missing resource names gracefully', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: 'CONFIG_VAR', + valueFrom: { + configMapKeyRef: { + // name is undefined + key: 'config-key', + }, + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns + // Note: config-key is not displayed in UI - the component shows the env var name + + // Should show dash for missing resource name + const dashElements = screen.getAllByText('-'); + expect(dashElements.length).toBeGreaterThan(0); + }); + + it('should handle containers without environment variables', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + // No env or envFrom + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect( + screen.getByText('Environment variables, ConfigMaps or Secrets') + ).toBeInTheDocument(); + expect(screen.getByTestId('text-tip')).toBeInTheDocument(); + expect( + screen.getByText( + 'This application is not using any environment variable, ConfigMap or Secret.' + ) + ).toBeInTheDocument(); + }); + + it('should handle environment variables without keys', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + env: [ + { + name: '', // Empty name to test this edge case + value: 'test-value', + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + + // Should show dash for missing env var name + const dashElements = screen.getAllByText('-'); + expect(dashElements.length).toBeGreaterThan(0); + }); + + it('should render multiple containers with different environment variable types', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'container-1', + image: 'image-1', + env: [ + { + name: 'CONFIG_VAR', + valueFrom: { + configMapKeyRef: { + name: 'shared-config', + key: 'config-key', + }, + }, + }, + ], + }, + { + name: 'container-2', + image: 'image-2', + env: [ + { + name: 'SECRET_VAR', + valueFrom: { + secretKeyRef: { + name: 'shared-config', // Same name but different type + key: 'secret-key', + }, + }, + }, + ], + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('container-1')).toBeInTheDocument(); + expect(screen.getByText('container-2')).toBeInTheDocument(); + expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns + expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns + expect(screen.getAllByText('shared-config')).toHaveLength(2); + + // Should have made two Link calls - one for configmap, one for secret + expect(mockLink).toHaveBeenCalledTimes(2); + + // Verify configmap routing + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'shared-config', + namespace: 'default', + }, + }) + ); + + // Verify secret routing + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'shared-config', + namespace: 'default', + }, + }) + ); + }); +}); diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx index c1b7435f2..bbfefef0d 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx @@ -84,8 +84,8 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) { ))} - {!envVar.resourseName && -} - {envVar.resourseName && ( + {!envVar.resourceName && -} + {envVar.resourceName && ( - {envVar.resourseName} + {envVar.resourceName} )} @@ -126,7 +126,7 @@ interface ContainerEnvVar { containerName: string; isInitContainer: boolean; type: EnvVarType; - resourseName: string; + resourceName: string; } function getApplicationEnvironmentVariables( @@ -159,7 +159,7 @@ function getApplicationEnvironmentVariables( containerName: container.name, isInitContainer: false, type: envtype, - resourseName: + resourceName: envVar?.valueFrom?.configMapKeyRef?.name || envVar?.valueFrom?.secretKeyRef?.name || '', @@ -170,7 +170,7 @@ function getApplicationEnvironmentVariables( const containerEnvFroms: ContainerEnvVar[] = container?.envFrom?.map((envFrom) => ({ name: '', - resourseName: + resourceName: envFrom?.configMapRef?.name || envFrom?.secretRef?.name || '', containerName: container.name, isInitContainer: false, @@ -196,7 +196,7 @@ function getApplicationEnvironmentVariables( containerName: container.name, isInitContainer: true, type: envtype, - resourseName: + resourceName: envVar?.valueFrom?.configMapKeyRef?.name || envVar?.valueFrom?.secretKeyRef?.name || '', @@ -207,7 +207,7 @@ function getApplicationEnvironmentVariables( const containerEnvFroms: ContainerEnvVar[] = container?.envFrom?.map((envFrom) => ({ name: '', - resourseName: + resourceName: envFrom?.configMapRef?.name || envFrom?.secretRef?.name || '', containerName: container.name, isInitContainer: true, diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.test.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.test.tsx new file mode 100644 index 000000000..2fce798ea --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.test.tsx @@ -0,0 +1,583 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { vi, beforeEach, afterEach } from 'vitest'; + +import { Application } from '../../types'; + +import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable'; + +// Mock icon components +vi.mock('lucide-react', () => ({ + Asterisk: () => , + Plus: () => , +})); + +// Mock UI components +vi.mock('@@/Icon', () => ({ + Icon: ({ + icon: IconComponent, + ...props + }: { + icon: React.ComponentType; + [key: string]: unknown; + }) => , +})); + +// Mock the Link component to capture routing props +const mockLink = vi.fn(); +vi.mock('@@/Link', () => ({ + Link: ({ + children, + to, + params, + 'data-cy': dataCy, + className, + }: { + children: React.ReactNode; + to: string; + params: Record; + 'data-cy'?: string; + className?: string; + }) => { + mockLink({ children, to, params, 'data-cy': dataCy, className }); + return ( + + {children} + + ); + }, +})); + +describe('ApplicationVolumeConfigsTable', () => { + beforeEach(() => { + mockLink.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render nothing when there are no volume configurations', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when app is undefined', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render volume configurations from configmaps with items', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + items: [ + { + key: 'config-key', + path: 'config.yaml', + }, + ], + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/config/config.yaml')).toBeInTheDocument(); + expect(screen.getByText('config-key')).toBeInTheDocument(); + expect(screen.getAllByTestId('plus-icon')).toHaveLength(2); // One for value, one for link + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + + // Verify the Link component was called with correct routing parameters + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'test-configmap', + namespace: 'default', + }, + 'data-cy': 'config-link-test-configmap', + }) + ); + }); + + it('should render volume configurations from secrets with items', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'secret-volume', + mountPath: '/etc/secrets', + }, + ], + }, + ], + volumes: [ + { + name: 'secret-volume', + secret: { + secretName: 'test-secret', + items: [ + { + key: 'secret-key', + path: 'secret.txt', + }, + ], + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/secrets/secret.txt')).toBeInTheDocument(); + expect(screen.getByText('secret-key')).toBeInTheDocument(); + expect(screen.getAllByTestId('plus-icon')).toHaveLength(2); // One for value, one for link + expect(screen.getByText('test-secret')).toBeInTheDocument(); + + // Verify the Link component was called with correct routing parameters for secret + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'test-secret', + namespace: 'default', + }, + 'data-cy': 'secret-link-test-secret', + }) + ); + }); + + it('should render init containers with asterisk indicator', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'main-container', + image: 'main-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + initContainers: [ + { + name: 'init-container', + image: 'init-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/init-config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'shared-config', + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + // Check main container + expect(screen.getByText('main-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/config')).toBeInTheDocument(); + + // Check init container + expect(screen.getByText('init-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/init-config')).toBeInTheDocument(); + expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument(); + expect(screen.getByText('init container')).toBeInTheDocument(); + }); + + it('should render secret volume configurations correctly based on type', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'secret-volume', + mountPath: '/etc/secrets', + }, + ], + }, + ], + volumes: [ + { + name: 'secret-volume', + secret: { + secretName: 'test-secret', + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('test-secret')).toBeInTheDocument(); + + // Should route to secret page because type is 'secret' + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'test-secret', + namespace: 'default', + }, + 'data-cy': 'secret-link-test-secret', + }) + ); + }); + + it('should render configmap volume configurations correctly based on type', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + + // Should route to configmap page because type is 'configMap' + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'test-configmap', + namespace: 'default', + }, + 'data-cy': 'config-link-test-configmap', + }) + ); + }); + + it('should handle volumes without items (entire volume mount)', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + // No items - entire configmap is mounted + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/config')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); // No specific key + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + }); + + it('should handle multiple volumes with different types correctly', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'secret-volume', + mountPath: '/etc/secrets', + }, + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'secret-volume', + secret: { + secretName: 'test-secret', + }, + }, + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getAllByText('test-container')).toHaveLength(2); // Should appear twice - once for each volume + expect(screen.getByText('test-secret')).toBeInTheDocument(); + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + + // Should have made two Link calls - one for secret, one for configmap + expect(mockLink).toHaveBeenCalledTimes(2); + + // Verify secret link + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.secrets.secret', + params: { + name: 'test-secret', + namespace: 'default', + }, + 'data-cy': 'secret-link-test-secret', + }) + ); + + // Verify configmap link + expect(mockLink).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kubernetes.configmaps.configmap', + params: { + name: 'test-configmap', + namespace: 'default', + }, + 'data-cy': 'config-link-test-configmap', + }) + ); + }); + + it('should handle containers without volume mounts', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + // No volumeMounts + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + const { container } = render( + + ); + + // Should render nothing because there are no matching volume mounts + expect(container.firstChild).toBeNull(); + }); + + it('should handle Deployment kind applications', () => { + const app: Application = { + metadata: { name: 'test-deployment', namespace: 'default' }, + spec: { + selector: { + matchLabels: { + app: 'test-app', + }, + }, + template: { + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + name: 'test-configmap', + }, + }, + ], + }, + }, + }, + kind: 'Deployment', + apiVersion: 'apps/v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/config')).toBeInTheDocument(); + expect(screen.getByText('test-configmap')).toBeInTheDocument(); + }); + + it('should handle missing volume config names', () => { + const app: Application = { + metadata: { name: 'test-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'test-container', + image: 'test-image', + volumeMounts: [ + { + name: 'config-volume', + mountPath: '/etc/config', + }, + ], + }, + ], + volumes: [ + { + name: 'config-volume', + configMap: { + // name is undefined + }, + }, + ], + }, + kind: 'Pod', + apiVersion: 'v1', + }; + + render(); + + expect(screen.getByText('test-container')).toBeInTheDocument(); + expect(screen.getByText('/etc/config')).toBeInTheDocument(); + + // Should show dash for missing volume config name + const dashElements = screen.getAllByText('-'); + expect(dashElements.length).toBeGreaterThan(0); + }); +}); diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx index b3cff0f19..d3b418386 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx @@ -1,15 +1,23 @@ -import { KeyToPath, Pod, Secret } from 'kubernetes-types/core/v1'; +import { KeyToPath, Pod, VolumeMount } from 'kubernetes-types/core/v1'; import { Asterisk, Plus } from 'lucide-react'; -import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets'; - import { Icon } from '@@/Icon'; import { Link } from '@@/Link'; import { Application } from '../../types'; import { applicationIsKind } from '../../utils'; +type VolumeConfigType = 'configMap' | 'secret'; + +type AppVolumeConfig = { + volumeConfigName: string | undefined; + containerVolumeMount: VolumeMount | undefined; + containerName: string; + isInitContainer: boolean; + item: KeyToPath; + type: VolumeConfigType; +}; + type Props = { namespace: string; app?: Application; @@ -18,8 +26,6 @@ type Props = { export function ApplicationVolumeConfigsTable({ namespace, app }: Props) { const containerVolumeConfigs = getApplicationVolumeConfigs(app); - const { data: secrets } = useK8sSecrets(useEnvironmentId(), namespace); - if (containerVolumeConfigs.length === 0) { return null; } @@ -41,6 +47,7 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) { containerName, item, volumeConfigName, + type, }, index ) => ( @@ -76,7 +83,7 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) { {!item.key && '-'} - {isVolumeConfigNameFromSecret(secrets, volumeConfigName) ? ( + {type === 'secret' ? ( secret.metadata?.name === volumeConfigName); -} - // getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume -function getApplicationVolumeConfigs(app?: Application) { +function getApplicationVolumeConfigs(app?: Application): AppVolumeConfig[] { if (!app) { return []; } @@ -142,6 +142,10 @@ function getApplicationVolumeConfigs(app?: Application) { const containerVolumeMount = container.volumeMounts?.find( (volumeMount) => volumeMount.name === volume.name ); + const type: VolumeConfigType = volume.configMap + ? 'configMap' + : 'secret'; + if (volConfigMapItems.length === 0) { return [ { @@ -150,6 +154,7 @@ function getApplicationVolumeConfigs(app?: Application) { containerName: container.name, isInitContainer: appInitContainers.includes(container), item: {} as KeyToPath, + type, }, ]; } @@ -160,6 +165,7 @@ function getApplicationVolumeConfigs(app?: Application) { containerName: container.name, isInitContainer: appInitContainers.includes(container), item, + type, })); }) // only return the app volumes where the container volumeMounts include the volume name (from map step above)