;
+ autoDeploy?: boolean;
}>;
/** Get project details (including UID and deletionTimestamp) */
diff --git a/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx b/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx
new file mode 100644
index 000000000..748b3931e
--- /dev/null
+++ b/plugins/openchoreo/src/components/AccessControl/AccessControlPage.test.tsx
@@ -0,0 +1,195 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { AccessControlContent } from './AccessControlPage';
+
+// ---- Mocks ----
+
+jest.mock('@openchoreo/backstage-design-system', () => ({
+ VerticalTabNav: ({ tabs, children }: any) => (
+
+ {tabs.map((t: any) => (
+
+ {t.label}
+
+ ))}
+ {children}
+
+ ),
+}));
+
+jest.mock('@backstage/core-components', () => ({
+ Progress: () => Loading...
,
+ WarningPanel: ({ title, children }: any) => (
+
+ {children}
+
+ ),
+}));
+
+const mockUseRolePermissions = jest.fn();
+const mockUseClusterRolePermissions = jest.fn();
+const mockUseRoleMappingPermissions = jest.fn();
+const mockUseClusterRoleMappingPermissions = jest.fn();
+
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ useRolePermissions: () => mockUseRolePermissions(),
+ useClusterRolePermissions: () => mockUseClusterRolePermissions(),
+ useRoleMappingPermissions: () => mockUseRoleMappingPermissions(),
+ useClusterRoleMappingPermissions: () =>
+ mockUseClusterRoleMappingPermissions(),
+}));
+
+const mockUseClusterRoles = jest.fn();
+jest.mock('./hooks', () => ({
+ useClusterRoles: () => mockUseClusterRoles(),
+}));
+
+jest.mock('./RolesTab', () => ({
+ RolesTab: () => RolesTab
,
+}));
+
+jest.mock('./MappingsTab', () => ({
+ MappingsTab: () => MappingsTab
,
+}));
+
+jest.mock('./ActionsTab', () => ({
+ ActionsTab: () => ActionsTab
,
+}));
+
+// ---- Helpers ----
+
+const grantedPerm = { canView: true, loading: false };
+const deniedPerm = { canView: false, loading: false };
+const loadingPerm = { canView: false, loading: true };
+
+function setAllPermissions(
+ overrides: {
+ nsRoles?: typeof grantedPerm;
+ clusterRoles?: typeof grantedPerm;
+ nsMappings?: typeof grantedPerm;
+ clusterMappings?: typeof grantedPerm;
+ } = {},
+) {
+ mockUseRolePermissions.mockReturnValue(overrides.nsRoles ?? grantedPerm);
+ mockUseClusterRolePermissions.mockReturnValue(
+ overrides.clusterRoles ?? grantedPerm,
+ );
+ mockUseRoleMappingPermissions.mockReturnValue(
+ overrides.nsMappings ?? grantedPerm,
+ );
+ mockUseClusterRoleMappingPermissions.mockReturnValue(
+ overrides.clusterMappings ?? grantedPerm,
+ );
+}
+
+function renderContent() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('AccessControlContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseClusterRoles.mockReturnValue({
+ roles: [],
+ loading: false,
+ error: null,
+ });
+ setAllPermissions();
+ });
+
+ it('shows progress when permissions are loading', () => {
+ setAllPermissions({ nsRoles: loadingPerm });
+
+ renderContent();
+
+ expect(screen.getByTestId('progress')).toBeInTheDocument();
+ });
+
+ it('shows authorization disabled warning when authz is disabled', () => {
+ mockUseClusterRoles.mockReturnValue({
+ roles: [],
+ loading: false,
+ error: new Error('authorization is disabled'),
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('warning-panel')).toHaveAttribute(
+ 'data-title',
+ 'Authorization is Disabled',
+ );
+ expect(
+ screen.getByText(/Policy management operations are not available/),
+ ).toBeInTheDocument();
+ });
+
+ it('shows Roles tab when user has role view permissions', () => {
+ renderContent();
+
+ expect(screen.getByTestId('tab-roles')).toHaveTextContent('Roles');
+ });
+
+ it('shows Role Bindings tab when user has mapping permissions', () => {
+ renderContent();
+
+ expect(screen.getByTestId('tab-mappings')).toHaveTextContent(
+ 'Role Bindings',
+ );
+ });
+
+ it('always shows Actions tab', () => {
+ setAllPermissions({
+ nsRoles: deniedPerm,
+ clusterRoles: deniedPerm,
+ nsMappings: deniedPerm,
+ clusterMappings: deniedPerm,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('tab-actions')).toHaveTextContent('Actions');
+ });
+
+ it('hides Roles tab when user has no role view permissions', () => {
+ setAllPermissions({
+ nsRoles: deniedPerm,
+ clusterRoles: deniedPerm,
+ });
+
+ renderContent();
+
+ expect(screen.queryByTestId('tab-roles')).not.toBeInTheDocument();
+ });
+
+ it('hides Role Bindings tab when user has no mapping permissions', () => {
+ setAllPermissions({
+ nsMappings: deniedPerm,
+ clusterMappings: deniedPerm,
+ });
+
+ renderContent();
+
+ expect(screen.queryByTestId('tab-mappings')).not.toBeInTheDocument();
+ });
+
+ it('shows only Actions tab when all permissions denied', () => {
+ setAllPermissions({
+ nsRoles: deniedPerm,
+ clusterRoles: deniedPerm,
+ nsMappings: deniedPerm,
+ clusterMappings: deniedPerm,
+ });
+
+ renderContent();
+
+ expect(screen.queryByTestId('tab-roles')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('tab-mappings')).not.toBeInTheDocument();
+ expect(screen.getByTestId('tab-actions')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx b/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx
new file mode 100644
index 000000000..9a313c97c
--- /dev/null
+++ b/plugins/openchoreo/src/components/AccessControl/MappingsTab/MappingsTab.test.tsx
@@ -0,0 +1,128 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { MappingsTab } from './MappingsTab';
+
+// ---- Mocks ----
+
+jest.mock('../ScopeDropdown', () => ({
+ ScopeDropdown: ({ value, onChange, clusterLabel, namespaceLabel }: any) => (
+
+ {value}
+
+
+
+ ),
+}));
+
+jest.mock('./ClusterRoleBindingsContent', () => ({
+ ClusterRoleBindingsContent: () => (
+ ClusterRoleBindingsContent
+ ),
+}));
+
+jest.mock('./NamespaceRoleBindingsContent', () => ({
+ NamespaceRoleBindingsContent: ({ selectedNamespace }: any) => (
+
+ NamespaceRoleBindingsContent:{selectedNamespace}
+
+ ),
+}));
+
+jest.mock('../RolesTab/NamespaceSelector', () => ({
+ NamespaceSelector: ({ value, onChange }: any) => (
+
+ ),
+}));
+
+// ---- Helpers ----
+
+function renderTab() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('MappingsTab', () => {
+ it('defaults to cluster scope with ClusterRoleBindingsContent visible', () => {
+ renderTab();
+
+ expect(screen.getByTestId('cluster-bindings-content')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('namespace-bindings-content'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not show namespace selector in cluster scope', () => {
+ renderTab();
+
+ expect(screen.queryByTestId('namespace-selector')).not.toBeInTheDocument();
+ });
+
+ it('shows scope dropdown with correct labels', () => {
+ renderTab();
+
+ expect(screen.getByText('Cluster Role Bindings')).toBeInTheDocument();
+ expect(screen.getByText('Namespace Role Bindings')).toBeInTheDocument();
+ });
+
+ it('switches to namespace scope and shows NamespaceRoleBindingsContent', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+
+ expect(
+ screen.getByTestId('namespace-bindings-content'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('cluster-bindings-content'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows namespace selector when in namespace scope', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+
+ expect(screen.getByTestId('namespace-selector')).toBeInTheDocument();
+ });
+
+ it('switches back to cluster scope', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+ expect(
+ screen.getByTestId('namespace-bindings-content'),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByTestId('switch-to-cluster'));
+ expect(screen.getByTestId('cluster-bindings-content')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx
new file mode 100644
index 000000000..d8c2ab0d0
--- /dev/null
+++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.test.tsx
@@ -0,0 +1,287 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TestApiProvider } from '@backstage/test-utils';
+import { createMockOpenChoreoClient } from '@openchoreo/test-utils';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { ClusterRolesContent } from './ClusterRolesContent';
+
+// ---- Mocks ----
+
+const mockUseClusterRolePermissions = jest.fn();
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ useClusterRolePermissions: () => mockUseClusterRolePermissions(),
+ ForbiddenState: ({ message, onRetry }: any) => (
+
+ {message}
+ {onRetry && (
+
+ )}
+
+ ),
+}));
+
+jest.mock('../../../utils/errorUtils', () => ({
+ isForbiddenError: (err: any) => err?.message?.includes('403'),
+}));
+
+const mockFetchRoles = jest.fn();
+const mockAddRole = jest.fn();
+const mockUpdateRole = jest.fn();
+const mockDeleteRole = jest.fn();
+const mockUseClusterRoles = jest.fn();
+
+jest.mock('../hooks', () => ({
+ useClusterRoles: () => mockUseClusterRoles(),
+}));
+
+jest.mock('../../../hooks', () => ({
+ useNotification: () => ({
+ notification: null,
+ showSuccess: jest.fn(),
+ showError: jest.fn(),
+ }),
+}));
+
+jest.mock('../../Environments/components', () => ({
+ NotificationBanner: () => null,
+}));
+
+jest.mock('@backstage/core-components', () => ({
+ Progress: () => Loading...
,
+ ResponseErrorPanel: ({ error }: any) => (
+ {error.message}
+ ),
+}));
+
+jest.mock('./RolesTable', () => ({
+ RolesTable: ({ roles, onEdit, onDelete }: any) => (
+
+ {roles.map((r: any) => (
+
+ {r.name}
+
+
+
+ ))}
+
+ ),
+}));
+
+jest.mock('./RoleDialog', () => ({
+ RoleDialog: ({ open, onClose, onSave, editingRole }: any) =>
+ open ? (
+
+ {editingRole ? 'edit' : 'create'}
+
+
+
+ ) : null,
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+
+const grantedPermissions = {
+ canView: true,
+ canCreate: true,
+ canUpdate: true,
+ canDelete: true,
+ loading: false,
+ createDeniedTooltip: '',
+ updateDeniedTooltip: '',
+ deleteDeniedTooltip: '',
+};
+
+function createActionsRef() {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ return { current: container };
+}
+
+function renderContent(actionsRef?: React.RefObject) {
+ const ref = actionsRef ?? createActionsRef();
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('ClusterRolesContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseClusterRolePermissions.mockReturnValue(grantedPermissions);
+ mockUseClusterRoles.mockReturnValue({
+ roles: [{ name: 'admin', actions: ['read', 'write'] }],
+ loading: false,
+ error: null,
+ fetchRoles: mockFetchRoles,
+ addRole: mockAddRole,
+ updateRole: mockUpdateRole,
+ deleteRole: mockDeleteRole,
+ });
+ });
+
+ it('shows progress when loading', () => {
+ mockUseClusterRoles.mockReturnValue({
+ roles: [],
+ loading: true,
+ error: null,
+ fetchRoles: mockFetchRoles,
+ addRole: mockAddRole,
+ updateRole: mockUpdateRole,
+ deleteRole: mockDeleteRole,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('progress')).toBeInTheDocument();
+ });
+
+ it('shows progress when permissions are loading', () => {
+ mockUseClusterRolePermissions.mockReturnValue({
+ ...grantedPermissions,
+ loading: true,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('progress')).toBeInTheDocument();
+ });
+
+ it('shows forbidden state for 403 error', () => {
+ mockUseClusterRoles.mockReturnValue({
+ roles: [],
+ loading: false,
+ error: new Error('403 Forbidden'),
+ fetchRoles: mockFetchRoles,
+ addRole: mockAddRole,
+ updateRole: mockUpdateRole,
+ deleteRole: mockDeleteRole,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('forbidden-state')).toBeInTheDocument();
+ expect(
+ screen.getByText('You do not have permission to view cluster roles.'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows error panel for non-forbidden errors', () => {
+ mockUseClusterRoles.mockReturnValue({
+ roles: [],
+ loading: false,
+ error: new Error('Network error'),
+ fetchRoles: mockFetchRoles,
+ addRole: mockAddRole,
+ updateRole: mockUpdateRole,
+ deleteRole: mockDeleteRole,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('error-panel')).toBeInTheDocument();
+ expect(screen.getByText('Network error')).toBeInTheDocument();
+ });
+
+ it('shows forbidden state when canView is false', () => {
+ mockUseClusterRolePermissions.mockReturnValue({
+ ...grantedPermissions,
+ canView: false,
+ });
+
+ renderContent();
+
+ expect(screen.getByTestId('forbidden-state')).toBeInTheDocument();
+ });
+
+ it('renders roles table with roles when loaded', () => {
+ renderContent();
+
+ expect(screen.getByTestId('roles-table')).toBeInTheDocument();
+ expect(screen.getByTestId('role-admin')).toBeInTheDocument();
+ });
+
+ it('renders New Cluster Role button in actions portal', () => {
+ renderContent();
+
+ expect(
+ screen.getByRole('button', { name: /new cluster role/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('disables create button when canCreate is false', () => {
+ mockUseClusterRolePermissions.mockReturnValue({
+ ...grantedPermissions,
+ canCreate: false,
+ });
+
+ renderContent();
+
+ expect(
+ screen.getByRole('button', { name: /new cluster role/i }),
+ ).toBeDisabled();
+ });
+
+ it('opens create dialog when New Cluster Role is clicked', async () => {
+ const user = userEvent.setup();
+
+ renderContent();
+
+ await user.click(screen.getByRole('button', { name: /new cluster role/i }));
+
+ expect(screen.getByTestId('role-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('dialog-mode')).toHaveTextContent('create');
+ });
+
+ it('opens edit dialog when edit is clicked on a role', async () => {
+ const user = userEvent.setup();
+
+ renderContent();
+
+ await user.click(screen.getByTestId('edit-admin'));
+
+ expect(screen.getByTestId('role-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('dialog-mode')).toHaveTextContent('edit');
+ });
+
+ it('calls deleteRole when delete is clicked on a role', async () => {
+ const user = userEvent.setup();
+
+ renderContent();
+
+ await user.click(screen.getByTestId('delete-admin'));
+
+ await waitFor(() => {
+ expect(mockDeleteRole).toHaveBeenCalledWith('admin');
+ });
+ });
+
+ it('renders refresh button in actions portal', () => {
+ renderContent();
+
+ expect(screen.getByTitle('Refresh')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx
new file mode 100644
index 000000000..9da8f4289
--- /dev/null
+++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/RolesTab.test.tsx
@@ -0,0 +1,124 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { RolesTab } from './RolesTab';
+
+// ---- Mocks ----
+
+jest.mock('../ScopeDropdown', () => ({
+ ScopeDropdown: ({ value, onChange, clusterLabel, namespaceLabel }: any) => (
+
+ {value}
+
+
+
+ ),
+}));
+
+jest.mock('./ClusterRolesContent', () => ({
+ ClusterRolesContent: () => (
+ ClusterRolesContent
+ ),
+}));
+
+jest.mock('./NamespaceRolesContent', () => ({
+ NamespaceRolesContent: ({ selectedNamespace }: any) => (
+
+ NamespaceRolesContent:{selectedNamespace}
+
+ ),
+}));
+
+jest.mock('./NamespaceSelector', () => ({
+ NamespaceSelector: ({ value, onChange }: any) => (
+
+ ),
+}));
+
+// ---- Helpers ----
+
+function renderTab() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('RolesTab', () => {
+ it('defaults to cluster scope with ClusterRolesContent visible', () => {
+ renderTab();
+
+ expect(screen.getByTestId('cluster-roles-content')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('namespace-roles-content'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not show namespace selector in cluster scope', () => {
+ renderTab();
+
+ expect(screen.queryByTestId('namespace-selector')).not.toBeInTheDocument();
+ });
+
+ it('shows scope dropdown with correct labels', () => {
+ renderTab();
+
+ expect(screen.getByText('Cluster Roles')).toBeInTheDocument();
+ expect(screen.getByText('Namespace Roles')).toBeInTheDocument();
+ });
+
+ it('switches to namespace scope and shows NamespaceRolesContent', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+
+ expect(screen.getByTestId('namespace-roles-content')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('cluster-roles-content'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows namespace selector when in namespace scope', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+
+ expect(screen.getByTestId('namespace-selector')).toBeInTheDocument();
+ });
+
+ it('switches back to cluster scope', async () => {
+ const user = userEvent.setup();
+
+ renderTab();
+
+ await user.click(screen.getByTestId('switch-to-namespace'));
+ expect(screen.getByTestId('namespace-roles-content')).toBeInTheDocument();
+
+ await user.click(screen.getByTestId('switch-to-cluster'));
+ expect(screen.getByTestId('cluster-roles-content')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx
new file mode 100644
index 000000000..f25ba5f9c
--- /dev/null
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.test.tsx
@@ -0,0 +1,309 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { TestApiProvider } from '@backstage/test-utils';
+import { alertApiRef } from '@backstage/core-plugin-api';
+import { createMockOpenChoreoClient } from '@openchoreo/test-utils';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import {
+ useDeleteEntityMenuItems,
+ type DeletePermissionInfo,
+} from './useDeleteEntityMenuItems';
+import type { Entity } from '@backstage/catalog-model';
+
+// ---- Mocks ----
+
+jest.mock('../utils', () => ({
+ isMarkedForDeletion: jest.fn().mockReturnValue(false),
+}));
+
+jest.mock('../../ResourceDefinition/utils', () => ({
+ isSupportedKind: (kind: string) =>
+ ['componenttype', 'traittype', 'workflow'].includes(kind),
+ mapKindToApiKind: (kind: string) => kind,
+}));
+
+jest.mock('../../../utils/errorUtils', () => ({
+ isForbiddenError: (err: any) => err?.message?.includes('403'),
+ getErrorMessage: (err: any) =>
+ err instanceof Error ? err.message : String(err),
+}));
+
+const mockNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+const mockAlertApi = { post: jest.fn() };
+
+function makeEntity(kind: string, name: string): Entity {
+ return {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind,
+ metadata: {
+ name,
+ namespace: 'default',
+ annotations: {
+ 'openchoreo.io/namespace': 'test-ns',
+ },
+ },
+ spec: {},
+ };
+}
+
+/**
+ * Wrapper component that renders the hook's menu item and dialog.
+ */
+function TestHarness({
+ entity,
+ deletePermission,
+}: {
+ entity: Entity;
+ deletePermission?: DeletePermissionInfo;
+}) {
+ const { extraMenuItems, DeleteConfirmationDialog } = useDeleteEntityMenuItems(
+ entity,
+ deletePermission,
+ );
+
+ return (
+
+ {extraMenuItems.map(item => (
+
+ ))}
+ {extraMenuItems.length === 0 && (
+ No items
+ )}
+
+
+ );
+}
+
+function renderHarness(
+ entity: Entity,
+ deletePermission?: DeletePermissionInfo,
+) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('useDeleteEntityMenuItems', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset isMarkedForDeletion since clearAllMocks doesn't reset mockReturnValue
+ const { isMarkedForDeletion } = require('../utils');
+ isMarkedForDeletion.mockReturnValue(false);
+ });
+
+ it('returns "Delete Component" for Component entity', () => {
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ expect(screen.getByTestId('menu-item')).toHaveTextContent(
+ 'Delete Component',
+ );
+ });
+
+ it('returns "Delete Project" for System entity', () => {
+ renderHarness(makeEntity('System', 'my-project'));
+
+ expect(screen.getByTestId('menu-item')).toHaveTextContent('Delete Project');
+ });
+
+ it('returns "Delete Namespace" for Domain entity', () => {
+ renderHarness(makeEntity('Domain', 'my-ns'));
+
+ expect(screen.getByTestId('menu-item')).toHaveTextContent(
+ 'Delete Namespace',
+ );
+ });
+
+ it('returns empty items for unsupported entity kind', () => {
+ renderHarness(makeEntity('API', 'my-api'));
+
+ expect(screen.getByTestId('no-menu-items')).toBeInTheDocument();
+ });
+
+ it('returns empty items when already marked for deletion', () => {
+ const { isMarkedForDeletion } = require('../utils');
+ isMarkedForDeletion.mockReturnValue(true);
+
+ renderHarness(makeEntity('Component', 'deleting-service'));
+
+ expect(screen.getByTestId('no-menu-items')).toBeInTheDocument();
+ });
+
+ it('returns empty items when deletePermission is loading', () => {
+ renderHarness(makeEntity('Component', 'my-service'), {
+ canDelete: false,
+ loading: true,
+ deniedTooltip: '',
+ });
+
+ expect(screen.getByTestId('no-menu-items')).toBeInTheDocument();
+ });
+
+ it('returns disabled item with tooltip when permission denied', () => {
+ renderHarness(makeEntity('Component', 'my-service'), {
+ canDelete: false,
+ loading: false,
+ deniedTooltip: 'No delete permission',
+ });
+
+ const item = screen.getByTestId('menu-item');
+ expect(item).toBeDisabled();
+ expect(item).toHaveAttribute('title', 'No delete permission');
+ });
+
+ it('opens confirmation dialog when menu item is clicked', async () => {
+ const user = userEvent.setup();
+
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ await user.click(screen.getByTestId('menu-item'));
+
+ // Dialog title is inside an h4
+ expect(
+ screen.getByRole('heading', { name: /delete component/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/my-service/)).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /^delete$/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('shows cascade warning for project deletion', async () => {
+ const user = userEvent.setup();
+
+ renderHarness(makeEntity('System', 'my-project'));
+
+ await user.click(screen.getByTestId('menu-item'));
+
+ expect(
+ screen.getByText(
+ /All components within this project will also be deleted/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('shows cascade warning for namespace deletion', async () => {
+ const user = userEvent.setup();
+
+ renderHarness(makeEntity('Domain', 'my-ns'));
+
+ await user.click(screen.getByTestId('menu-item'));
+
+ expect(
+ screen.getByText(/All projects and components within this namespace/),
+ ).toBeInTheDocument();
+ });
+
+ it('calls deleteComponent on confirm and navigates to /catalog', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteComponent.mockResolvedValue(undefined);
+
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ await user.click(screen.getByTestId('menu-item'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteComponent).toHaveBeenCalled();
+ expect(mockAlertApi.post).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Component "my-service" has been marked for deletion',
+ severity: 'success',
+ }),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith('/catalog');
+ });
+ });
+
+ it('calls deleteProject on confirm for System entity', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteProject.mockResolvedValue(undefined);
+
+ renderHarness(makeEntity('System', 'my-project'));
+
+ await user.click(screen.getByTestId('menu-item'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteProject).toHaveBeenCalled();
+ });
+ });
+
+ it('shows permission error when delete returns 403', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteComponent.mockRejectedValue(new Error('403 Forbidden'));
+
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ await user.click(screen.getByTestId('menu-item'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/You do not have permission to delete this resource/),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('shows error message when delete fails with non-403 error', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteComponent.mockRejectedValue(new Error('Network timeout'));
+
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ await user.click(screen.getByTestId('menu-item'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Network timeout/)).toBeInTheDocument();
+ });
+ });
+
+ it('closes dialog on cancel without API call', async () => {
+ const user = userEvent.setup();
+
+ renderHarness(makeEntity('Component', 'my-service'));
+
+ await user.click(screen.getByTestId('menu-item'));
+ expect(
+ screen.getByRole('heading', { name: /delete component/i }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('heading', { name: /delete component/i }),
+ ).not.toBeInTheDocument();
+ });
+ expect(mockClient.deleteComponent).not.toHaveBeenCalled();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/Environments.test.tsx b/plugins/openchoreo/src/components/Environments/Environments.test.tsx
index ab4a0e0d5..9b8e49957 100644
--- a/plugins/openchoreo/src/components/Environments/Environments.test.tsx
+++ b/plugins/openchoreo/src/components/Environments/Environments.test.tsx
@@ -1,14 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { mockComponentEntity } from '@openchoreo/test-utils';
import { Environments } from './Environments';
// ---- Mocks ----
-// Mock styles (no-op)
-jest.mock('./styles', () => ({
- useEnvironmentsStyles: jest.fn(),
-}));
-
// Mock useNotification
jest.mock('../../hooks', () => ({
useNotification: () => ({
@@ -38,40 +35,6 @@ jest.mock('./hooks', () => ({
}),
}));
-// Mock useAutoDeployUpdate
-jest.mock('./hooks/useAutoDeployUpdate', () => ({
- useAutoDeployUpdate: () => ({
- updateAutoDeploy: jest.fn(),
- isUpdating: false,
- error: null,
- }),
-}));
-
-// Mock @backstage/plugin-catalog-react
-jest.mock('@backstage/plugin-catalog-react', () => ({
- useEntity: () => ({
- entity: {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'Component',
- metadata: {
- name: 'test-component',
- namespace: 'default',
- annotations: {},
- tags: ['service'],
- },
- spec: { type: 'service' },
- },
- }),
-}));
-
-// Mock @backstage/core-plugin-api
-jest.mock('@backstage/core-plugin-api', () => ({
- useApi: () => ({
- getComponentDetails: jest.fn().mockResolvedValue({}),
- fetchEnvironmentInfo: jest.fn().mockResolvedValue([]),
- }),
-}));
-
// Mock @backstage/core-components
jest.mock('@backstage/core-components', () => ({
Progress: () => Loading...
,
@@ -107,15 +70,16 @@ jest.mock('./components', () => ({
NotificationBanner: () => null,
}));
-// Mock openChoreoClientApiRef
-jest.mock('../../api/OpenChoreoClientApi', () => ({
- openChoreoClientApiRef: { id: 'mock' },
-}));
-
// ---- Helpers ----
+const testEntity = mockComponentEntity();
+
function renderWithRouter(ui: React.ReactElement) {
- return render({ui});
+ return render(
+
+ {ui}
+ ,
+ );
}
// ---- Tests ----
diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx
index 32304d12f..92dc3f9fc 100644
--- a/plugins/openchoreo/src/components/Environments/Environments.tsx
+++ b/plugins/openchoreo/src/components/Environments/Environments.tsx
@@ -1,8 +1,7 @@
-import { useCallback, useState, useEffect, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Progress } from '@backstage/core-components';
import { Box } from '@material-ui/core';
-import { useApi } from '@backstage/core-plugin-api';
import { useNotification } from '../../hooks';
import {
@@ -11,13 +10,11 @@ import {
useEnvironmentPolling,
useEnvironmentRouting,
} from './hooks';
-import { useAutoDeployUpdate } from './hooks/useAutoDeployUpdate';
import type { PendingAction } from './types';
import { useEnvironmentsStyles } from './styles';
import { EnvironmentsRouter } from './EnvironmentsRouter';
import { EnvironmentsProvider } from './EnvironmentsContext';
import { NotificationBanner } from './components';
-import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi';
import {
ForbiddenState,
useReleaseBindingPermission,
@@ -29,7 +26,6 @@ export const Environments = () => {
useEnvironmentsStyles();
const { entity } = useEntity();
- const client = useApi(openChoreoClientApiRef);
// Routing
const { navigateToList } = useEnvironmentRouting();
@@ -45,30 +41,9 @@ export const Environments = () => {
const { canViewBindings, loading: bindingsPermissionLoading } =
useReleaseBindingPermission();
- // Auto deploy state
- const [autoDeploy, setAutoDeploy] = useState(undefined);
- const { updateAutoDeploy, isUpdating: autoDeployUpdating } =
- useAutoDeployUpdate(entity);
-
// Notifications
const notification = useNotification();
- // Fetch component details to get autoDeploy value
- useEffect(() => {
- const fetchComponentData = async () => {
- try {
- const componentData = await client.getComponentDetails(entity);
- if (componentData && 'autoDeploy' in componentData) {
- setAutoDeploy((componentData as any).autoDeploy);
- }
- } catch (err) {
- // Silently fail - autoDeploy will remain undefined
- }
- };
-
- fetchComponentData();
- }, [entity, client]);
-
// Polling for pending deployments
useEnvironmentPolling(isPending, refetch);
@@ -84,22 +59,6 @@ export const Environments = () => {
[entity],
);
- // Handler for auto deploy toggle change
- const handleAutoDeployChange = useCallback(
- async (newAutoDeploy: boolean) => {
- const success = await updateAutoDeploy(newAutoDeploy);
- if (success) {
- setAutoDeploy(newAutoDeploy);
- notification.showSuccess(
- `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`,
- );
- } else {
- notification.showError('Failed to update auto deploy setting');
- }
- },
- [updateAutoDeploy, notification],
- );
-
// Handler for when overrides are saved with a pending action
// The release binding was already created/updated by updateReleaseBinding
// in EnvironmentOverridesPage — just show success and refresh
@@ -132,9 +91,6 @@ export const Environments = () => {
refetch,
lowestEnvironment: environments[0]?.name?.toLowerCase() || 'development',
isWorkloadEditorSupported,
- autoDeploy,
- autoDeployUpdating,
- onAutoDeployChange: handleAutoDeployChange,
onPendingActionComplete: handlePendingActionComplete,
canViewEnvironments,
environmentReadPermissionLoading,
@@ -147,9 +103,6 @@ export const Environments = () => {
loading,
refetch,
isWorkloadEditorSupported,
- autoDeploy,
- autoDeployUpdating,
- handleAutoDeployChange,
handlePendingActionComplete,
canViewEnvironments,
environmentReadPermissionLoading,
diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx
index a751b7b92..47d37ae58 100644
--- a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx
+++ b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx
@@ -15,12 +15,6 @@ interface EnvironmentsContextValue {
lowestEnvironment: string;
/** Whether workload editor is supported for this component */
isWorkloadEditorSupported: boolean;
- /** Auto deploy setting */
- autoDeploy: boolean | undefined;
- /** Whether auto deploy is being updated */
- autoDeployUpdating: boolean;
- /** Handler for auto deploy toggle */
- onAutoDeployChange: (enabled: boolean) => Promise;
/** Handler for completing a pending action (deploy/promote) */
onPendingActionComplete: (action: PendingAction) => Promise;
/** Whether the user has permission to view environments */
diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx
index 3e532e831..aa61529cf 100644
--- a/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx
+++ b/plugins/openchoreo/src/components/Environments/EnvironmentsList.test.tsx
@@ -1,5 +1,7 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { mockComponentEntity } from '@openchoreo/test-utils';
import { EnvironmentsList } from './EnvironmentsList';
import type { Environment } from './hooks';
import type { EnvironmentCardProps, SetupCardProps } from './types';
@@ -7,21 +9,10 @@ import type { EnvironmentCardProps, SetupCardProps } from './types';
// ---- Captured props for child components ----
let capturedEnvironmentCardProps: Map;
-let capturedSetupCardProps: SetupCardProps | undefined;
-
-// ---- Mock: styles ----
-jest.mock('./styles', () => ({
- useEnvironmentsListStyles: () => ({
- cardGrid: 'cardGrid',
- cardItem: 'cardItem',
- }),
-}));
-
// ---- Mock: EnvironmentCard & SetupCard ----
jest.mock('./components', () => ({
NotificationBanner: () => null,
- SetupCard: (props: SetupCardProps) => {
- capturedSetupCardProps = props;
+ SetupCard: (_props: SetupCardProps) => {
return ;
},
EnvironmentCard: (props: EnvironmentCardProps) => {
@@ -30,23 +21,6 @@ jest.mock('./components', () => ({
},
}));
-// ---- Mock: @backstage/plugin-catalog-react ----
-jest.mock('@backstage/plugin-catalog-react', () => ({
- useEntity: () => ({
- entity: {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'Component',
- metadata: {
- name: 'test-component',
- namespace: 'default',
- annotations: {},
- tags: ['service'],
- },
- spec: { type: 'service' },
- },
- }),
-}));
-
// ---- Mock: @openchoreo/backstage-plugin-react (EmptyState, ForbiddenState) ----
jest.mock('@openchoreo/backstage-plugin-react', () => ({
EmptyState: (props: { title: string; description: string }) => (
@@ -79,9 +53,6 @@ interface MockContextValue {
refetch: jest.Mock;
lowestEnvironment: string;
isWorkloadEditorSupported: boolean;
- autoDeploy: boolean | undefined;
- autoDeployUpdating: boolean;
- onAutoDeployChange: jest.Mock;
onPendingActionComplete: jest.Mock;
canViewEnvironments: boolean;
environmentReadPermissionLoading: boolean;
@@ -98,9 +69,6 @@ const defaultMockContext = (): MockContextValue => ({
refetch: jest.fn(),
lowestEnvironment: 'development',
isWorkloadEditorSupported: true,
- autoDeploy: true,
- autoDeployUpdating: false,
- onAutoDeployChange: jest.fn(),
onPendingActionComplete: jest.fn(),
canViewEnvironments: true,
environmentReadPermissionLoading: false,
@@ -171,8 +139,14 @@ jest.mock('../../utils/errorUtils', () => ({
// ---- Helpers ----
+const testEntity = mockComponentEntity();
+
function renderWithRouter(ui: React.ReactElement) {
- return render({ui});
+ return render(
+
+ {ui}
+ ,
+ );
}
function makeEnv(
@@ -197,7 +171,6 @@ describe('EnvironmentsList', () => {
jest.clearAllMocks();
mockContextValue = defaultMockContext();
capturedEnvironmentCardProps = new Map();
- capturedSetupCardProps = undefined;
});
// 1. Empty state when no environments + canViewEnvironments: true
@@ -377,22 +350,7 @@ describe('EnvironmentsList', () => {
});
});
- // 10. SetupCard receives autoDeploy and onAutoDeployChange
- it('passes autoDeploy and onAutoDeployChange to SetupCard', () => {
- const mockOnAutoDeployChange = jest.fn();
- mockContextValue.autoDeploy = true;
- mockContextValue.onAutoDeployChange = mockOnAutoDeployChange;
-
- renderWithRouter();
-
- expect(capturedSetupCardProps).toBeDefined();
- expect(capturedSetupCardProps!.autoDeploy).toBe(true);
- expect(capturedSetupCardProps!.onAutoDeployChange).toBe(
- mockOnAutoDeployChange,
- );
- });
-
- // 11. Settings gear: invoking onOpenOverrides calls navigateToOverrides
+ // 10. Settings gear: invoking onOpenOverrides calls navigateToOverrides
it('calls navigateToOverrides with environment name when onOpenOverrides is invoked', () => {
const envs = [
makeEnv({
diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx
index 6627a929a..3d260d7bc 100644
--- a/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx
+++ b/plugins/openchoreo/src/components/Environments/EnvironmentsList.tsx
@@ -32,9 +32,6 @@ export const EnvironmentsList = () => {
loading,
refetch,
isWorkloadEditorSupported,
- autoDeploy,
- autoDeployUpdating,
- onAutoDeployChange,
canViewEnvironments,
environmentReadPermissionLoading,
canViewBindings,
@@ -126,9 +123,6 @@ export const EnvironmentsList = () => {
environmentsExist={environments.length > 0}
isWorkloadEditorSupported={isWorkloadEditorSupported}
onConfigureWorkload={handleOpenWorkloadConfig}
- autoDeploy={autoDeploy}
- onAutoDeployChange={onAutoDeployChange}
- autoDeployUpdating={autoDeployUpdating}
/>
diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx
new file mode 100644
index 000000000..7dbeb254f
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentActions.test.tsx
@@ -0,0 +1,247 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { EnvironmentActions } from './EnvironmentActions';
+import type { EnvironmentActionsProps, ItemActionTracker } from '../types';
+
+// ---- Mocks ----
+
+const mockUseDeployPermission = jest.fn();
+const mockUseUndeployPermission = jest.fn();
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ useDeployPermission: () => mockUseDeployPermission(),
+ useUndeployPermission: () => mockUseUndeployPermission(),
+}));
+
+// ---- Helpers ----
+
+function createTracker(
+ overrides: Partial = {},
+): ItemActionTracker {
+ return {
+ isActive: jest.fn().mockReturnValue(false),
+ withTracking: jest.fn((_item: string, fn: () => Promise) => fn()),
+ activeItems: new Set(),
+ startAction: jest.fn(),
+ endAction: jest.fn(),
+ ...overrides,
+ } as unknown as ItemActionTracker;
+}
+
+function renderActions(overrides: Partial = {}) {
+ const defaultProps: EnvironmentActionsProps = {
+ environmentName: 'development',
+ deploymentStatus: 'Ready',
+ onPromote: jest.fn(),
+ onSuspend: jest.fn(),
+ onRedeploy: jest.fn(),
+ isAlreadyPromoted: jest.fn().mockReturnValue(false),
+ promotionTracker: createTracker(),
+ suspendTracker: createTracker(),
+ ...overrides,
+ };
+
+ return {
+ ...render(),
+ props: defaultProps,
+ };
+}
+
+const grantedDeploy = {
+ canDeploy: true,
+ loading: false,
+ deniedTooltip: '',
+};
+
+const deniedDeploy = {
+ canDeploy: false,
+ loading: false,
+ deniedTooltip: 'You do not have permission to deploy',
+};
+
+const grantedUndeploy = {
+ canUndeploy: true,
+ loading: false,
+ deniedTooltip: '',
+};
+
+const deniedUndeploy = {
+ canUndeploy: false,
+ loading: false,
+ deniedTooltip: 'You do not have permission to undeploy',
+};
+
+// ---- Tests ----
+
+describe('EnvironmentActions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseDeployPermission.mockReturnValue(grantedDeploy);
+ mockUseUndeployPermission.mockReturnValue(grantedUndeploy);
+ });
+
+ it('renders nothing when no promotion targets and no binding', () => {
+ const { container } = renderActions({
+ promotionTargets: undefined,
+ bindingName: undefined,
+ });
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders promote button for single target with permission', () => {
+ renderActions({
+ promotionTargets: [{ name: 'staging' }],
+ });
+
+ const btn = screen.getByRole('button', { name: /promote/i });
+ expect(btn).toBeEnabled();
+ expect(btn).toHaveTextContent('Promote');
+ });
+
+ it('calls onPromote with correct target on click', async () => {
+ const user = userEvent.setup();
+ const onPromote = jest.fn();
+
+ renderActions({
+ promotionTargets: [{ name: 'staging', resourceName: 'staging-res' }],
+ onPromote,
+ });
+
+ await user.click(screen.getByRole('button', { name: /promote/i }));
+ expect(onPromote).toHaveBeenCalledWith('staging-res');
+ });
+
+ it('shows "Promoted" and disables button when already promoted', () => {
+ renderActions({
+ promotionTargets: [{ name: 'staging' }],
+ isAlreadyPromoted: jest.fn().mockReturnValue(true),
+ });
+
+ const btn = screen.getByRole('button', { name: /promoted/i });
+ expect(btn).toBeDisabled();
+ expect(btn).toHaveTextContent('Promoted');
+ });
+
+ it('shows "Promoting..." when promotion is in progress', () => {
+ renderActions({
+ promotionTargets: [{ name: 'staging' }],
+ promotionTracker: createTracker({
+ isActive: jest.fn().mockReturnValue(true),
+ }),
+ });
+
+ const btn = screen.getByRole('button', { name: /promoting/i });
+ expect(btn).toBeDisabled();
+ });
+
+ it('disables promote button when user lacks deploy permission', () => {
+ mockUseDeployPermission.mockReturnValue(deniedDeploy);
+
+ renderActions({
+ promotionTargets: [{ name: 'staging' }],
+ });
+
+ expect(screen.getByRole('button', { name: /promote/i })).toBeDisabled();
+ });
+
+ it('shows Undeploy button when has binding and not undeployed', () => {
+ renderActions({
+ bindingName: 'my-binding',
+ statusReason: undefined,
+ });
+
+ const btn = screen.getByRole('button', { name: /undeploy/i });
+ expect(btn).toBeEnabled();
+ });
+
+ it('calls onSuspend when Undeploy is clicked', async () => {
+ const user = userEvent.setup();
+ const onSuspend = jest.fn();
+
+ renderActions({
+ bindingName: 'my-binding',
+ onSuspend,
+ });
+
+ await user.click(screen.getByRole('button', { name: /undeploy/i }));
+ expect(onSuspend).toHaveBeenCalled();
+ });
+
+ it('shows Redeploy button when resources are undeployed', () => {
+ renderActions({
+ bindingName: 'my-binding',
+ statusReason: 'ResourcesUndeployed',
+ });
+
+ const btn = screen.getByRole('button', { name: /redeploy/i });
+ expect(btn).toBeEnabled();
+ });
+
+ it('calls onRedeploy when Redeploy is clicked', async () => {
+ const user = userEvent.setup();
+ const onRedeploy = jest.fn();
+
+ renderActions({
+ bindingName: 'my-binding',
+ statusReason: 'ResourcesUndeployed',
+ onRedeploy,
+ });
+
+ await user.click(screen.getByRole('button', { name: /redeploy/i }));
+ expect(onRedeploy).toHaveBeenCalled();
+ });
+
+ it('disables undeploy/redeploy when user lacks permission', () => {
+ mockUseUndeployPermission.mockReturnValue(deniedUndeploy);
+
+ renderActions({
+ bindingName: 'my-binding',
+ });
+
+ expect(screen.getByRole('button', { name: /undeploy/i })).toBeDisabled();
+ });
+
+ it('renders stacked buttons for multiple promotion targets', () => {
+ renderActions({
+ promotionTargets: [
+ { name: 'staging', resourceName: 'staging-res' },
+ { name: 'production', resourceName: 'production-res' },
+ ],
+ });
+
+ expect(
+ screen.getByRole('button', { name: /promote to staging/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /promote to production/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onPromote with correct target for multi-target', async () => {
+ const user = userEvent.setup();
+ const onPromote = jest.fn();
+
+ renderActions({
+ promotionTargets: [
+ { name: 'staging', resourceName: 'staging-res' },
+ { name: 'production', resourceName: 'prod-res' },
+ ],
+ onPromote,
+ });
+
+ await user.click(
+ screen.getByRole('button', { name: /promote to production/i }),
+ );
+ expect(onPromote).toHaveBeenCalledWith('prod-res');
+ });
+
+ it('shows approval required text for target requiring approval', () => {
+ renderActions({
+ promotionTargets: [{ name: 'production', requiresApproval: true }],
+ });
+
+ expect(screen.getByRole('button')).toHaveTextContent(
+ 'Promote (Approval Required)',
+ );
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx
new file mode 100644
index 000000000..9f23ee9b8
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentCard.test.tsx
@@ -0,0 +1,143 @@
+import { render, screen } from '@testing-library/react';
+import { EnvironmentCard } from './EnvironmentCard';
+import type { EnvironmentCardProps, ItemActionTracker } from '../types';
+
+// ---- Mocks ----
+
+jest.mock('./LoadingSkeleton', () => ({
+ LoadingSkeleton: ({ variant }: { variant: string }) => (
+
+ ),
+}));
+
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ ForbiddenState: ({ message }: { message: string }) => (
+ {message}
+ ),
+ useDeployPermission: () => ({
+ canDeploy: true,
+ loading: false,
+ deniedTooltip: '',
+ }),
+ useUndeployPermission: () => ({
+ canUndeploy: true,
+ loading: false,
+ deniedTooltip: '',
+ }),
+ formatRelativeTime: (ts: string) => `relative(${ts})`,
+}));
+
+jest.mock('@openchoreo/backstage-design-system', () => ({
+ Card: ({ children, ...rest }: any) => (
+
+ {children}
+
+ ),
+ StatusBadge: ({ status }: { status: string }) => (
+ {status}
+ ),
+}));
+
+jest.mock('./InvokeUrlsDialog', () => ({
+ InvokeUrlsDialog: () => null,
+}));
+
+jest.mock('./IncidentsBanner', () => ({
+ IncidentsBanner: () => null,
+}));
+
+// ---- Helpers ----
+
+function createTracker(
+ overrides: Partial = {},
+): ItemActionTracker {
+ return {
+ isActive: jest.fn().mockReturnValue(false),
+ withTracking: jest.fn((_item: string, fn: () => Promise) => fn()),
+ activeItems: new Set(),
+ startAction: jest.fn(),
+ endAction: jest.fn(),
+ ...overrides,
+ } as unknown as ItemActionTracker;
+}
+
+function renderCard(overrides: Partial = {}) {
+ const defaultProps: EnvironmentCardProps = {
+ environmentName: 'development',
+ deployment: { status: 'Ready' },
+ endpoints: [],
+ isRefreshing: false,
+ isAlreadyPromoted: jest.fn().mockReturnValue(false),
+ actionTrackers: {
+ promotionTracker: createTracker(),
+ suspendTracker: createTracker(),
+ },
+ onRefresh: jest.fn(),
+ onOpenOverrides: jest.fn(),
+ onOpenReleaseDetails: jest.fn(),
+ onPromote: jest.fn(),
+ onSuspend: jest.fn(),
+ onRedeploy: jest.fn(),
+ ...overrides,
+ };
+
+ return render();
+}
+
+// ---- Tests ----
+
+describe('EnvironmentCard', () => {
+ it('shows loading skeleton when isRefreshing is true', () => {
+ renderCard({ isRefreshing: true });
+
+ expect(screen.getByTestId('loading-skeleton-card')).toBeInTheDocument();
+ expect(screen.queryByText('Deployment Status:')).not.toBeInTheDocument();
+ });
+
+ it('shows forbidden state when canViewBindings is false', () => {
+ renderCard({
+ canViewBindings: false,
+ bindingsPermissionLoading: false,
+ });
+
+ expect(screen.getByTestId('forbidden-state')).toBeInTheDocument();
+ expect(
+ screen.getByText('You do not have permission to view release bindings.'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders header and content in normal state', () => {
+ renderCard({
+ environmentName: 'production',
+ deployment: { status: 'Ready', releaseName: 'release-1' },
+ });
+
+ // Header: environment name
+ expect(screen.getByText('production')).toBeInTheDocument();
+ // Content: deployment status
+ expect(screen.getByText('Deployment Status:')).toBeInTheDocument();
+ });
+
+ it('passes hasOverrides=true to header when hasComponentTypeOverrides', () => {
+ renderCard({
+ hasComponentTypeOverrides: true,
+ deployment: { releaseName: 'release-1' },
+ });
+
+ // Settings icon should be present (header renders it when hasReleaseName is true)
+ expect(
+ screen.getByTitle('Configure environment overrides'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show forbidden state while permissions are loading', () => {
+ renderCard({
+ canViewBindings: false,
+ bindingsPermissionLoading: true,
+ });
+
+ // Should render content, not forbidden state
+ expect(screen.queryByTestId('forbidden-state')).not.toBeInTheDocument();
+ expect(screen.getByText('Deployment Status:')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx
new file mode 100644
index 000000000..c2bec09e1
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentCardContent.test.tsx
@@ -0,0 +1,193 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { EnvironmentCardContent } from './EnvironmentCardContent';
+import type { EnvironmentCardContentProps } from '../types';
+
+// ---- Mocks ----
+
+jest.mock('@openchoreo/backstage-design-system', () => ({
+ StatusBadge: ({ status }: { status: string }) => (
+ {status}
+ ),
+}));
+
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ formatRelativeTime: (ts: string) => `relative(${ts})`,
+}));
+
+jest.mock('./InvokeUrlsDialog', () => ({
+ InvokeUrlsDialog: ({ open, endpoints }: any) =>
+ open ? (
+ {endpoints.length} endpoint(s)
+ ) : null,
+}));
+
+jest.mock('./IncidentsBanner', () => ({
+ IncidentsBanner: ({ count, environmentName }: any) => (
+
+ {count} incidents in {environmentName}
+
+ ),
+}));
+
+// ---- Helpers ----
+
+function renderContent(overrides: Partial = {}) {
+ const defaultProps: EnvironmentCardContentProps = {
+ status: 'Ready',
+ endpoints: [],
+ onOpenReleaseDetails: jest.fn(),
+ ...overrides,
+ };
+
+ return {
+ ...render(),
+ props: defaultProps,
+ };
+}
+
+// ---- Tests ----
+
+describe('EnvironmentCardContent', () => {
+ it('shows deployed time when lastDeployed is provided', () => {
+ renderContent({ lastDeployed: '2024-01-01T00:00:00Z' });
+
+ expect(screen.getByText('Deployed')).toBeInTheDocument();
+ expect(
+ screen.getByText('relative(2024-01-01T00:00:00Z)'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show deployed section when lastDeployed is absent', () => {
+ renderContent({ lastDeployed: undefined });
+
+ expect(screen.queryByText('Deployed')).not.toBeInTheDocument();
+ });
+
+ it('shows "active" status badge for Ready status', () => {
+ renderContent({ status: 'Ready' });
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('active');
+ });
+
+ it('shows "pending" status badge for NotReady status', () => {
+ renderContent({ status: 'NotReady' });
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('pending');
+ });
+
+ it('shows "failed" status badge for Failed status', () => {
+ renderContent({ status: 'Failed' });
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('failed');
+ });
+
+ it('shows "undeployed" status badge for ResourcesUndeployed reason', () => {
+ renderContent({
+ status: 'Ready',
+ statusReason: 'ResourcesUndeployed',
+ });
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('undeployed');
+ });
+
+ it('shows View K8s Artifacts button when releaseName exists', () => {
+ renderContent({ releaseName: 'release-1' });
+
+ expect(
+ screen.getByRole('button', { name: /view k8s artifacts/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onOpenReleaseDetails when View K8s Artifacts is clicked', async () => {
+ const user = userEvent.setup();
+ const onOpenReleaseDetails = jest.fn();
+
+ renderContent({ releaseName: 'release-1', onOpenReleaseDetails });
+
+ await user.click(
+ screen.getByRole('button', { name: /view k8s artifacts/i }),
+ );
+ expect(onOpenReleaseDetails).toHaveBeenCalled();
+ });
+
+ it('does not show artifacts button when releaseName is absent', () => {
+ renderContent({ releaseName: undefined });
+
+ expect(
+ screen.queryByRole('button', { name: /view k8s artifacts/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows Endpoint URLs with count badge when Ready with endpoints', () => {
+ renderContent({
+ status: 'Ready',
+ endpoints: [
+ { url: 'https://api.example.com' },
+ { url: 'https://web.example.com' },
+ ] as any,
+ });
+
+ expect(screen.getByText('Endpoint URLs')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('opens InvokeUrlsDialog when eye icon is clicked', async () => {
+ const user = userEvent.setup();
+
+ renderContent({
+ status: 'Ready',
+ endpoints: [{ url: 'https://api.example.com' }] as any,
+ });
+
+ await user.click(screen.getByLabelText('Show endpoint URLs'));
+
+ expect(screen.getByTestId('invoke-urls-dialog')).toBeInTheDocument();
+ expect(screen.getByText('1 endpoint(s)')).toBeInTheDocument();
+ });
+
+ it('does not show endpoint URLs section when not Ready', () => {
+ renderContent({
+ status: 'NotReady',
+ endpoints: [{ url: 'https://api.example.com' }] as any,
+ });
+
+ expect(screen.queryByText('Endpoint URLs')).not.toBeInTheDocument();
+ });
+
+ it('shows IncidentsBanner when there are active incidents', () => {
+ renderContent({
+ status: 'Ready',
+ activeIncidentCount: 3,
+ environmentName: 'production',
+ });
+
+ expect(screen.getByTestId('incidents-banner')).toBeInTheDocument();
+ expect(screen.getByText('3 incidents in production')).toBeInTheDocument();
+ });
+
+ it('does not show IncidentsBanner when activeIncidentCount is 0', () => {
+ renderContent({
+ status: 'Ready',
+ activeIncidentCount: 0,
+ environmentName: 'production',
+ });
+
+ expect(screen.queryByTestId('incidents-banner')).not.toBeInTheDocument();
+ });
+
+ it('shows image when provided', () => {
+ renderContent({ image: 'registry.io/my-service:v1.0.0' });
+
+ expect(screen.getByText('Image')).toBeInTheDocument();
+ expect(
+ screen.getByText('registry.io/my-service:v1.0.0'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show image section when image is absent', () => {
+ renderContent({ image: undefined });
+
+ expect(screen.queryByText('Image')).not.toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx
new file mode 100644
index 000000000..2d6b000a7
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx
@@ -0,0 +1,245 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { TestApiProvider } from '@backstage/test-utils';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import {
+ createMockOpenChoreoClient,
+ mockComponentEntity,
+} from '@openchoreo/test-utils';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { SetupCard } from './SetupCard';
+
+// ---- Mocks ----
+
+jest.mock('./LoadingSkeleton', () => ({
+ LoadingSkeleton: ({ variant }: { variant: string }) => (
+
+ ),
+}));
+
+jest.mock('../Workload/WorkloadButton', () => ({
+ WorkloadButton: ({ onConfigureWorkload }: any) => (
+
+ ),
+}));
+
+const mockUpdateAutoDeploy = jest.fn();
+jest.mock('../hooks/useAutoDeployUpdate', () => ({
+ useAutoDeployUpdate: () => ({
+ updateAutoDeploy: mockUpdateAutoDeploy,
+ isUpdating: false,
+ error: null,
+ }),
+}));
+
+const mockShowSuccess = jest.fn();
+const mockShowError = jest.fn();
+jest.mock('../../../hooks', () => ({
+ useNotification: () => ({
+ notification: null,
+ showSuccess: mockShowSuccess,
+ showError: mockShowError,
+ hide: jest.fn(),
+ }),
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+const testEntity = mockComponentEntity();
+
+function renderSetupCard(
+ props: Partial> = {},
+) {
+ const defaultProps = {
+ loading: false,
+ environmentsExist: true,
+ isWorkloadEditorSupported: false,
+ onConfigureWorkload: jest.fn(),
+ };
+
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('SetupCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockClient.getComponentDetails.mockResolvedValue({});
+ });
+
+ it('shows loading skeleton when loading with no environments', () => {
+ renderSetupCard({ loading: true, environmentsExist: false });
+
+ expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument();
+ expect(screen.queryByText('Auto Deploy')).not.toBeInTheDocument();
+ });
+
+ it('shows content when loaded', () => {
+ renderSetupCard();
+
+ expect(screen.getByText('Set up')).toBeInTheDocument();
+ expect(
+ screen.getByText('Manage deployment configuration and settings'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Auto Deploy')).toBeInTheDocument();
+ });
+
+ it('fetches and displays autoDeploy=true from component details', async () => {
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true });
+
+ renderSetupCard();
+
+ await waitFor(() => {
+ const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i });
+ expect(switchEl).toBeChecked();
+ });
+ });
+
+ it('fetches and displays autoDeploy=false from component details', async () => {
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false });
+
+ renderSetupCard();
+
+ await waitFor(() => {
+ const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i });
+ expect(switchEl).not.toBeChecked();
+ });
+ });
+
+ it('switch defaults to unchecked when autoDeploy is undefined', () => {
+ mockClient.getComponentDetails.mockResolvedValue({});
+
+ renderSetupCard();
+
+ const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i });
+ expect(switchEl).not.toBeChecked();
+ });
+
+ it('opens confirmation dialog when toggle is clicked', async () => {
+ const user = userEvent.setup();
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false });
+
+ renderSetupCard();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('checkbox', { name: /auto deploy/i }),
+ ).not.toBeChecked();
+ });
+
+ await user.click(screen.getByRole('checkbox', { name: /auto deploy/i }));
+
+ expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument();
+ expect(screen.getByText('Confirm')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('calls updateAutoDeploy on confirm and shows success notification', async () => {
+ const user = userEvent.setup();
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false });
+ mockUpdateAutoDeploy.mockResolvedValue(true);
+
+ renderSetupCard();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('checkbox', { name: /auto deploy/i }),
+ ).not.toBeChecked();
+ });
+
+ await user.click(screen.getByRole('checkbox', { name: /auto deploy/i }));
+ await user.click(screen.getByText('Confirm'));
+
+ expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true);
+ await waitFor(() => {
+ expect(mockShowSuccess).toHaveBeenCalledWith(
+ 'Auto deploy enabled successfully',
+ );
+ });
+ });
+
+ it('shows error notification when updateAutoDeploy fails', async () => {
+ const user = userEvent.setup();
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true });
+ mockUpdateAutoDeploy.mockResolvedValue(false);
+
+ renderSetupCard();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('checkbox', { name: /auto deploy/i }),
+ ).toBeChecked();
+ });
+
+ await user.click(screen.getByRole('checkbox', { name: /auto deploy/i }));
+ await user.click(screen.getByText('Confirm'));
+
+ expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(false);
+ await waitFor(() => {
+ expect(mockShowError).toHaveBeenCalledWith(
+ 'Failed to update auto deploy setting',
+ );
+ });
+ });
+
+ it('closes dialog on cancel without calling updateAutoDeploy', async () => {
+ const user = userEvent.setup();
+ renderSetupCard();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('checkbox', { name: /auto deploy/i }),
+ ).toBeEnabled();
+ });
+
+ await user.click(screen.getByRole('checkbox', { name: /auto deploy/i }));
+ expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument();
+
+ await user.click(screen.getByText('Cancel'));
+
+ await waitFor(() => {
+ expect(screen.queryByText('Enable Auto Deploy?')).not.toBeInTheDocument();
+ });
+ expect(mockUpdateAutoDeploy).not.toHaveBeenCalled();
+ });
+
+ it('shows WorkloadButton when isWorkloadEditorSupported is true', () => {
+ renderSetupCard({ isWorkloadEditorSupported: true });
+
+ expect(screen.getByTestId('workload-button')).toBeInTheDocument();
+ });
+
+ it('hides WorkloadButton when isWorkloadEditorSupported is false', () => {
+ renderSetupCard({ isWorkloadEditorSupported: false });
+
+ expect(screen.queryByTestId('workload-button')).not.toBeInTheDocument();
+ });
+
+ it('silently handles getComponentDetails failure', async () => {
+ mockClient.getComponentDetails.mockRejectedValue(
+ new Error('Network error'),
+ );
+
+ renderSetupCard();
+
+ // Should render normally with switch unchecked (default)
+ await waitFor(() => {
+ expect(
+ screen.getByRole('checkbox', { name: /auto deploy/i }),
+ ).not.toBeChecked();
+ });
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx
index 3f0cf8a15..ba299e52a 100644
--- a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx
+++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
@@ -9,11 +9,16 @@ import {
} from '@material-ui/core';
import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined';
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined';
+import { useApi } from '@backstage/core-plugin-api';
+import { useEntity } from '@backstage/plugin-catalog-react';
import { useSetupCardStyles } from '../styles';
import { SetupCardProps } from '../types';
import { LoadingSkeleton } from './LoadingSkeleton';
import { WorkloadButton } from '../Workload/WorkloadButton';
import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate';
+import { useNotification } from '../../../hooks';
/**
* Setup card showing workload deployment options and auto deploy toggle.
@@ -24,14 +29,60 @@ export const SetupCard = ({
environmentsExist,
isWorkloadEditorSupported,
onConfigureWorkload,
- autoDeploy,
- onAutoDeployChange,
- autoDeployUpdating,
}: SetupCardProps) => {
const classes = useSetupCardStyles();
+ const { entity } = useEntity();
+ const client = useApi(openChoreoClientApiRef);
+ const notification = useNotification();
+ const { updateAutoDeploy, isUpdating: autoDeployUpdating } =
+ useAutoDeployUpdate(entity);
+
+ const [autoDeploy, setAutoDeploy] = useState(undefined);
+ const [autoDeployLoaded, setAutoDeployLoaded] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false);
+ // Fetch component details to get autoDeploy value
+ useEffect(() => {
+ let cancelled = false;
+ setAutoDeployLoaded(false);
+
+ const fetchComponentData = async () => {
+ try {
+ const componentData = await client.getComponentDetails(entity);
+ if (!cancelled && componentData?.autoDeploy !== undefined) {
+ setAutoDeploy(componentData.autoDeploy);
+ }
+ } catch {
+ // Auto-deploy state will remain undefined and switch stays disabled
+ return;
+ }
+ if (!cancelled) {
+ setAutoDeployLoaded(true);
+ }
+ };
+
+ fetchComponentData();
+ return () => {
+ cancelled = true;
+ };
+ }, [entity, client]);
+
+ const handleAutoDeployChange = useCallback(
+ async (newAutoDeploy: boolean) => {
+ const success = await updateAutoDeploy(newAutoDeploy);
+ if (success) {
+ setAutoDeploy(newAutoDeploy);
+ notification.showSuccess(
+ `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`,
+ );
+ } else {
+ notification.showError('Failed to update auto deploy setting');
+ }
+ },
+ [updateAutoDeploy, notification],
+ );
+
const handleToggleChange = (event: React.ChangeEvent) => {
const newValue = event.target.checked;
setPendingAutoDeployValue(newValue);
@@ -39,7 +90,7 @@ export const SetupCard = ({
};
const handleConfirm = () => {
- onAutoDeployChange(pendingAutoDeployValue);
+ handleAutoDeployChange(pendingAutoDeployValue);
setShowConfirmDialog(false);
};
@@ -76,7 +127,7 @@ export const SetupCard = ({
onChange={handleToggleChange}
name="autoDeploy"
color="primary"
- disabled={autoDeployUpdating}
+ disabled={!autoDeployLoaded || autoDeployUpdating}
/>
}
label={Auto Deploy}
diff --git a/plugins/openchoreo/src/components/Environments/types.ts b/plugins/openchoreo/src/components/Environments/types.ts
index d22f35d35..c5e8dd7f0 100644
--- a/plugins/openchoreo/src/components/Environments/types.ts
+++ b/plugins/openchoreo/src/components/Environments/types.ts
@@ -65,9 +65,6 @@ export interface SetupCardProps {
environmentsExist: boolean;
isWorkloadEditorSupported: boolean;
onConfigureWorkload: () => void;
- autoDeploy?: boolean;
- onAutoDeployChange: (autoDeploy: boolean) => void;
- autoDeployUpdating: boolean;
}
/**
diff --git a/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx b/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx
new file mode 100644
index 000000000..13194ffb9
--- /dev/null
+++ b/plugins/openchoreo/src/components/GitSecrets/CreateSecretDialog.test.tsx
@@ -0,0 +1,281 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CreateSecretDialog, WorkflowPlaneOption } from './CreateSecretDialog';
+
+// ---- Helpers ----
+
+const planes: WorkflowPlaneOption[] = [
+ { name: 'default-plane', kind: 'WorkflowPlane' },
+ { name: 'shared-plane', kind: 'ClusterWorkflowPlane' },
+];
+
+function renderDialog(
+ overrides: Partial> = {},
+) {
+ const defaultProps = {
+ open: true,
+ onClose: jest.fn(),
+ onSubmit: jest.fn().mockResolvedValue(undefined),
+ namespaceName: 'test-ns',
+ existingSecretNames: [] as string[],
+ workflowPlanes: planes,
+ workflowPlanesLoading: false,
+ };
+
+ return {
+ ...render(),
+ props: { ...defaultProps, ...overrides },
+ };
+}
+
+// ---- Tests ----
+
+describe('CreateSecretDialog', () => {
+ it('renders dialog title and namespace info when open', () => {
+ renderDialog();
+
+ expect(screen.getByText('Create Git Secret')).toBeInTheDocument();
+ expect(screen.getByText('test-ns')).toBeInTheDocument();
+ });
+
+ it('does not render when closed', () => {
+ renderDialog({ open: false });
+
+ expect(screen.queryByText('Create Git Secret')).not.toBeInTheDocument();
+ });
+
+ it('defaults to Basic Authentication type', () => {
+ renderDialog();
+
+ const basicRadio = screen.getByLabelText('Basic Authentication');
+ expect(basicRadio).toBeChecked();
+ });
+
+ it('shows basic auth fields by default', () => {
+ renderDialog();
+
+ expect(
+ screen.getByText('Username for git authentication.'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Your git provider password or access token/),
+ ).toBeInTheDocument();
+ });
+
+ it('switches to SSH auth fields when SSH radio is selected', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByLabelText('SSH Authentication'));
+
+ expect(
+ screen.getByText('SSH key identifier for git authentication.'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Drag and drop file with your private SSH key/),
+ ).toBeInTheDocument();
+ });
+
+ it('disables Create button when secret name is empty', () => {
+ renderDialog();
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+ });
+
+ it('disables Create button when basic auth token is empty', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ // Type a secret name but leave token empty
+ const inputs = screen.getAllByRole('textbox');
+ // First textbox is Secret Name
+ await user.type(inputs[0], 'my-secret');
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+ });
+
+ it('enables Create button when name and token are filled', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ const inputs = screen.getAllByRole('textbox');
+ // Secret Name
+ await user.type(inputs[0], 'my-secret');
+ // Username (optional)
+ // Password or Token - it's a password field, not a textbox
+ // Need to find the password input differently
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'my-token');
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
+ });
+
+ it('shows error for duplicate secret name on submit', async () => {
+ const user = userEvent.setup();
+ renderDialog({ existingSecretNames: ['existing-secret'] });
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'existing-secret');
+
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'token');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(
+ screen.getByText(/A secret with this name already exists/),
+ ).toBeInTheDocument();
+ });
+
+ it('shows error for invalid secret name format', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'invalid_name!');
+
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'token');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(
+ screen.getByText(/must consist of lowercase alphanumeric characters/),
+ ).toBeInTheDocument();
+ });
+
+ it('keeps Create button disabled when name is filled but token is empty', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'my-secret');
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+ });
+
+ it('calls onSubmit with correct args and closes on success', async () => {
+ const user = userEvent.setup();
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
+ const onClose = jest.fn();
+
+ renderDialog({ onSubmit, onClose });
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'my-secret');
+
+ // Type username
+ await user.type(inputs[1], 'myuser');
+
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'my-token');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith(
+ 'my-secret',
+ 'basic-auth',
+ 'my-token',
+ 'myuser',
+ undefined,
+ 'WorkflowPlane',
+ 'default-plane',
+ );
+ });
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error when onSubmit rejects', async () => {
+ const user = userEvent.setup();
+ const onSubmit = jest.fn().mockRejectedValue(new Error('Create failed'));
+
+ renderDialog({ onSubmit });
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'my-secret');
+
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'token');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Create failed')).toBeInTheDocument();
+ });
+ });
+
+ it('shows "Creating..." and disables buttons when loading', async () => {
+ const user = userEvent.setup();
+ const onSubmit = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves
+
+ renderDialog({ onSubmit });
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'my-secret');
+
+ const passwordInput = document.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await user.type(passwordInput, 'token');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
+ });
+
+ it('calls onClose when Cancel is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = jest.fn();
+
+ renderDialog({ onClose });
+
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('shows SSH key validation error for invalid key format', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByLabelText('SSH Authentication'));
+
+ const inputs = screen.getAllByRole('textbox');
+ // Secret Name
+ await user.type(inputs[0], 'ssh-secret');
+ // SSH Private Key textarea
+ const sshKeyTextarea = inputs[inputs.length - 1];
+ await user.type(sshKeyTextarea, 'not-a-valid-key');
+
+ await user.click(screen.getByRole('button', { name: 'Create' }));
+
+ expect(screen.getByText(/Invalid SSH key format/)).toBeInTheDocument();
+ });
+
+ it('keeps Create button disabled when SSH auth selected but key is empty', async () => {
+ const user = userEvent.setup();
+ renderDialog();
+
+ await user.click(screen.getByLabelText('SSH Authentication'));
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'ssh-secret');
+
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
+ });
+});
diff --git a/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx b/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx
new file mode 100644
index 000000000..cfeb6d5c1
--- /dev/null
+++ b/plugins/openchoreo/src/components/GitSecrets/GitSecretsPage.test.tsx
@@ -0,0 +1,239 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TestApiProvider } from '@backstage/test-utils';
+import { createMockOpenChoreoClient } from '@openchoreo/test-utils';
+import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+import { GitSecretsContent } from './GitSecretsPage';
+
+// ---- Mocks ----
+
+jest.mock('@backstage/core-components', () => ({
+ Page: ({ children }: any) => {children}
,
+ Header: () => null,
+ Content: ({ children }: any) => {children}
,
+ WarningPanel: ({ title, children }: any) => (
+
+ {children}
+
+ ),
+}));
+
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ ForbiddenState: ({ message, onRetry }: any) => (
+
+ {message}
+ {onRetry && }
+
+ ),
+}));
+
+const mockUseGitSecrets = jest.fn();
+jest.mock('./hooks/useGitSecrets', () => ({
+ useGitSecrets: (ns: string) => mockUseGitSecrets(ns),
+}));
+
+jest.mock('./SecretsTable', () => ({
+ SecretsTable: ({ secrets, loading, onDelete, namespaceName }: any) => (
+
+
{namespaceName}
+
{String(loading)}
+ {secrets.map((s: any) => (
+
+ {s.name}
+
+
+ ))}
+
+ ),
+}));
+
+jest.mock('./CreateSecretDialog', () => ({
+ CreateSecretDialog: ({ open, onClose, onSubmit }: any) =>
+ open ? (
+
+
+
+
+ ) : null,
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+const mockCatalogApi = {
+ getEntities: jest.fn(),
+};
+
+const defaultSecretsHook = {
+ secrets: [],
+ loading: false,
+ error: null,
+ isForbidden: false,
+ createSecret: jest.fn(),
+ deleteSecret: jest.fn(),
+ fetchSecrets: jest.fn(),
+};
+
+function renderContent() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('GitSecretsContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockClient.listNamespaces.mockResolvedValue([
+ { name: 'alpha-ns', displayName: 'Alpha' },
+ { name: 'beta-ns', displayName: 'Beta' },
+ ]);
+ mockCatalogApi.getEntities.mockResolvedValue({ items: [] });
+ mockUseGitSecrets.mockReturnValue(defaultSecretsHook);
+ });
+
+ it('shows "Select a namespace" prompt initially before namespaces load', () => {
+ mockClient.listNamespaces.mockReturnValue(new Promise(() => {})); // never resolves
+
+ renderContent();
+
+ expect(
+ screen.getByText('Select a namespace to manage git secrets'),
+ ).toBeInTheDocument();
+ });
+
+ it('auto-selects first namespace after loading', async () => {
+ renderContent();
+
+ await waitFor(() => {
+ expect(mockUseGitSecrets).toHaveBeenCalledWith('alpha-ns');
+ });
+ });
+
+ it('renders namespace selector', async () => {
+ renderContent();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Namespace')).toBeInTheDocument();
+ });
+ });
+
+ it('shows secrets table when namespace is selected', async () => {
+ mockUseGitSecrets.mockReturnValue({
+ ...defaultSecretsHook,
+ secrets: [{ name: 'my-secret' }],
+ });
+
+ renderContent();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('secrets-table')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('secret-my-secret')).toBeInTheDocument();
+ });
+
+ it('shows forbidden state when secrets access is forbidden', async () => {
+ mockUseGitSecrets.mockReturnValue({
+ ...defaultSecretsHook,
+ error: new Error('403 Forbidden'),
+ isForbidden: true,
+ });
+
+ renderContent();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('forbidden-state')).toBeInTheDocument();
+ });
+ expect(
+ screen.getByText('You do not have permission to view git secrets.'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows error warning for non-forbidden secrets errors', async () => {
+ mockUseGitSecrets.mockReturnValue({
+ ...defaultSecretsHook,
+ error: new Error('Network failure'),
+ isForbidden: false,
+ });
+
+ renderContent();
+
+ await waitFor(() => {
+ expect(screen.getByText('Network failure')).toBeInTheDocument();
+ });
+ });
+
+ it('shows namespace loading error', async () => {
+ mockClient.listNamespaces.mockRejectedValue(
+ new Error('Failed to fetch namespaces'),
+ );
+
+ renderContent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Failed to fetch namespaces'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('disables Create Secret button when no namespace selected', () => {
+ mockClient.listNamespaces.mockReturnValue(new Promise(() => {}));
+
+ renderContent();
+
+ expect(
+ screen.getByRole('button', { name: /create secret/i }),
+ ).toBeDisabled();
+ });
+
+ it('enables Create Secret button when namespace is selected', async () => {
+ renderContent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /create secret/i }),
+ ).toBeEnabled();
+ });
+ });
+
+ it('opens create dialog when Create Secret is clicked', async () => {
+ const user = userEvent.setup();
+
+ renderContent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /create secret/i }),
+ ).toBeEnabled();
+ });
+
+ await user.click(screen.getByRole('button', { name: /create secret/i }));
+
+ expect(screen.getByTestId('create-dialog')).toBeInTheDocument();
+ });
+
+ it('disables Refresh button when no namespace selected', () => {
+ mockClient.listNamespaces.mockReturnValue(new Promise(() => {}));
+
+ renderContent();
+
+ expect(screen.getByTitle('Refresh')).toBeDisabled();
+ });
+});
diff --git a/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx b/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx
new file mode 100644
index 000000000..cb07a7396
--- /dev/null
+++ b/plugins/openchoreo/src/components/GitSecrets/SecretsTable.test.tsx
@@ -0,0 +1,198 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SecretsTable } from './SecretsTable';
+import { GitSecret } from '../../api/OpenChoreoClientApi';
+
+// ---- Helpers ----
+
+const secrets: GitSecret[] = [
+ {
+ name: 'repo-token',
+ namespace: 'test-ns',
+ workflowPlaneName: 'default-plane',
+ workflowPlaneKind: 'WorkflowPlane',
+ },
+ {
+ name: 'deploy-key',
+ namespace: 'test-ns',
+ workflowPlaneName: 'shared-plane',
+ workflowPlaneKind: 'ClusterWorkflowPlane',
+ },
+];
+
+function renderTable(
+ overrides: Partial> = {},
+) {
+ const defaultProps = {
+ secrets,
+ loading: false,
+ onDelete: jest.fn().mockResolvedValue(undefined),
+ namespaceName: 'test-ns',
+ };
+
+ return {
+ ...render(),
+ props: { ...defaultProps, ...overrides },
+ };
+}
+
+// ---- Tests ----
+
+describe('SecretsTable', () => {
+ it('shows progress bar when loading', () => {
+ renderTable({ loading: true });
+
+ expect(screen.getByTestId('progress')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no secrets', () => {
+ renderTable({ secrets: [] });
+
+ expect(screen.getByText('No git secrets in test-ns')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Create a git secret to access private repositories during builds.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('renders secret names in the table', () => {
+ renderTable();
+
+ expect(screen.getByText('repo-token')).toBeInTheDocument();
+ expect(screen.getByText('deploy-key')).toBeInTheDocument();
+ });
+
+ it('renders workflow plane names', () => {
+ renderTable();
+
+ expect(screen.getByText('default-plane')).toBeInTheDocument();
+ expect(screen.getByText('shared-plane')).toBeInTheDocument();
+ });
+
+ it('shows Cluster chip for ClusterWorkflowPlane secrets', () => {
+ renderTable();
+
+ expect(screen.getByText('Cluster')).toBeInTheDocument();
+ });
+
+ it('shows search field when secrets exist', () => {
+ renderTable();
+
+ expect(screen.getByLabelText('Search secrets')).toBeInTheDocument();
+ });
+
+ it('does not show search field when no secrets', () => {
+ renderTable({ secrets: [] });
+
+ expect(screen.queryByLabelText('Search secrets')).not.toBeInTheDocument();
+ });
+
+ it('filters secrets by search query', async () => {
+ const user = userEvent.setup();
+ renderTable();
+
+ await user.type(screen.getByLabelText('Search secrets'), 'repo');
+
+ expect(screen.getByText('repo-token')).toBeInTheDocument();
+ expect(screen.queryByText('deploy-key')).not.toBeInTheDocument();
+ });
+
+ it('shows no match message when search has no results', async () => {
+ const user = userEvent.setup();
+ renderTable();
+
+ await user.type(screen.getByLabelText('Search secrets'), 'nonexistent');
+
+ expect(
+ screen.getByText('No secrets match your search'),
+ ).toBeInTheDocument();
+ });
+
+ it('opens delete confirmation dialog when delete icon is clicked', async () => {
+ const user = userEvent.setup();
+ renderTable();
+
+ const deleteButtons = screen.getAllByTitle('Delete');
+ await user.click(deleteButtons[0]);
+
+ expect(screen.getByText('Delete Git Secret')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Are you sure you want to delete the git secret/),
+ ).toBeInTheDocument();
+ // "repo-token" appears both in the table row and the dialog's tag
+ expect(screen.getAllByText('repo-token').length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('calls onDelete and closes dialog on confirm', async () => {
+ const user = userEvent.setup();
+ const onDelete = jest.fn().mockResolvedValue(undefined);
+ renderTable({ onDelete });
+
+ const deleteButtons = screen.getAllByTitle('Delete');
+ await user.click(deleteButtons[0]);
+
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
+
+ await waitFor(() => {
+ expect(onDelete).toHaveBeenCalledWith('repo-token');
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Delete Git Secret')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows error when delete fails', async () => {
+ const user = userEvent.setup();
+ const onDelete = jest.fn().mockRejectedValue(new Error('Delete failed'));
+ renderTable({ onDelete });
+
+ const deleteButtons = screen.getAllByTitle('Delete');
+ await user.click(deleteButtons[0]);
+
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Delete failed')).toBeInTheDocument();
+ });
+ });
+
+ it('closes delete dialog on cancel', async () => {
+ const user = userEvent.setup();
+ renderTable();
+
+ const deleteButtons = screen.getAllByTitle('Delete');
+ await user.click(deleteButtons[0]);
+
+ expect(screen.getByText('Delete Git Secret')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => {
+ expect(screen.queryByText('Delete Git Secret')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows "Deleting..." and disables buttons during deletion', async () => {
+ const user = userEvent.setup();
+ const onDelete = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves
+ renderTable({ onDelete });
+
+ const deleteButtons = screen.getAllByTitle('Delete');
+ await user.click(deleteButtons[0]);
+
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
+
+ expect(screen.getByRole('button', { name: /deleting/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
+ });
+
+ it('renders table headers', () => {
+ renderTable();
+
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ expect(screen.getByText('Workflow Plane')).toBeInTheDocument();
+ expect(screen.getByText('Actions')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx b/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx
new file mode 100644
index 000000000..4bb532fa0
--- /dev/null
+++ b/plugins/openchoreo/src/components/GitSecrets/hooks/useGitSecrets.test.tsx
@@ -0,0 +1,179 @@
+import { renderHook, act } from '@testing-library/react';
+import { useGitSecrets } from './useGitSecrets';
+
+// ---- Mocks ----
+
+const mockClient = {
+ listGitSecrets: jest.fn(),
+ createGitSecret: jest.fn(),
+ deleteGitSecret: jest.fn(),
+};
+
+jest.mock('@backstage/core-plugin-api', () => ({
+ ...jest.requireActual('@backstage/core-plugin-api'),
+ useApi: () => mockClient,
+}));
+
+// ---- Tests ----
+
+describe('useGitSecrets', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockClient.listGitSecrets.mockResolvedValue({ items: [] });
+ });
+
+ it('fetches secrets on mount', async () => {
+ renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ expect(mockClient.listGitSecrets).toHaveBeenCalledWith('test-ns');
+ });
+
+ it('returns secrets from the API', async () => {
+ const items = [
+ { name: 'secret-1', namespace: 'test-ns' },
+ { name: 'secret-2', namespace: 'test-ns' },
+ ];
+ mockClient.listGitSecrets.mockResolvedValue({ items });
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ expect(result.current.secrets).toEqual(items);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('sets loading to true while fetching', () => {
+ mockClient.listGitSecrets.mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('sets error when fetch fails', async () => {
+ mockClient.listGitSecrets.mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ expect(result.current.error).toEqual(new Error('Network error'));
+ expect(result.current.secrets).toEqual([]);
+ });
+
+ it('returns empty secrets when namespace is empty', () => {
+ const { result } = renderHook(() => useGitSecrets(''));
+
+ expect(result.current.secrets).toEqual([]);
+ expect(mockClient.listGitSecrets).not.toHaveBeenCalled();
+ });
+
+ it('creates a secret and refreshes the list', async () => {
+ const newSecret = { name: 'new-secret', namespace: 'test-ns' };
+ mockClient.createGitSecret.mockResolvedValue(newSecret);
+ mockClient.listGitSecrets.mockResolvedValue({
+ items: [newSecret],
+ });
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ await act(async () => {
+ const created = await result.current.createSecret(
+ 'new-secret',
+ 'basic-auth',
+ 'token123',
+ 'user',
+ );
+ expect(created).toEqual(newSecret);
+ });
+
+ expect(mockClient.createGitSecret).toHaveBeenCalledWith(
+ 'test-ns',
+ 'new-secret',
+ 'basic-auth',
+ 'token123',
+ 'user',
+ undefined,
+ undefined,
+ undefined,
+ );
+
+ // Verify list was refreshed after create (initial load + refresh)
+ expect(mockClient.listGitSecrets).toHaveBeenCalledTimes(2);
+ });
+
+ it('deletes a secret and refreshes the list', async () => {
+ mockClient.deleteGitSecret.mockResolvedValue(undefined);
+ mockClient.listGitSecrets
+ .mockResolvedValueOnce({
+ items: [{ name: 'to-delete', namespace: 'test-ns' }],
+ })
+ .mockResolvedValueOnce({ items: [] });
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ await act(async () => {
+ await result.current.deleteSecret('to-delete');
+ });
+
+ expect(mockClient.deleteGitSecret).toHaveBeenCalledWith(
+ 'test-ns',
+ 'to-delete',
+ );
+
+ // Verify list was refreshed after delete (initial load + refresh)
+ expect(mockClient.listGitSecrets).toHaveBeenCalledTimes(2);
+ });
+
+ it('refetches secrets when namespace changes', async () => {
+ mockClient.listGitSecrets.mockResolvedValue({ items: [] });
+
+ const { rerender } = renderHook(({ ns }) => useGitSecrets(ns), {
+ initialProps: { ns: 'ns-a' },
+ });
+
+ await act(async () => {});
+
+ expect(mockClient.listGitSecrets).toHaveBeenCalledWith('ns-a');
+
+ rerender({ ns: 'ns-b' });
+
+ await act(async () => {});
+
+ expect(mockClient.listGitSecrets).toHaveBeenCalledWith('ns-b');
+ });
+
+ it('sets isForbidden to false for non-403 errors', async () => {
+ mockClient.listGitSecrets.mockRejectedValue(new Error('Server error'));
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ expect(result.current.isForbidden).toBe(false);
+ });
+
+ it('sets isForbidden to true for 403 errors', async () => {
+ const { ResponseError } = jest.requireActual('@backstage/errors');
+ const forbiddenError = new ResponseError({
+ statusCode: 403,
+ statusText: 'Forbidden',
+ data: { error: { name: 'NotAllowedError', message: 'Forbidden' } },
+ });
+ mockClient.listGitSecrets.mockRejectedValue(forbiddenError);
+
+ const { result } = renderHook(() => useGitSecrets('test-ns'));
+
+ await act(async () => {});
+
+ expect(result.current.isForbidden).toBe(true);
+ });
+});
diff --git a/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx b/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx
index d3fa74737..f34cedb48 100644
--- a/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx
+++ b/plugins/openchoreo/src/components/Projects/OverviewCards/DeploymentPipelineCard.test.tsx
@@ -1,5 +1,7 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { mockSystemEntity } from '@openchoreo/test-utils';
import { DeploymentPipelineCard } from './DeploymentPipelineCard';
// ---- Mocks ----
@@ -10,24 +12,6 @@ jest.mock('../hooks', () => ({
useDeploymentPipeline: () => mockUseDeploymentPipeline(),
}));
-// Mock useEntity
-jest.mock('@backstage/plugin-catalog-react', () => ({
- useEntity: () => ({
- entity: {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'System',
- metadata: {
- name: 'test-project',
- namespace: 'default',
- annotations: {
- 'openchoreo.io/namespace': 'test-ns',
- },
- },
- spec: {},
- },
- }),
-}));
-
// Mock permission hook
const mockUseProjectUpdatePermission = jest.fn();
jest.mock('@openchoreo/backstage-plugin-react', () => ({
@@ -61,22 +45,6 @@ jest.mock('./ChangePipelineDialog', () => ({
ChangePipelineDialog: () => null,
}));
-// Mock styles
-jest.mock('./styles', () => ({
- useProjectOverviewCardStyles: () => ({
- card: 'card',
- cardHeader: 'cardHeader',
- cardTitle: 'cardTitle',
- content: 'content',
- pipelineInfo: 'pipelineInfo',
- infoRow: 'infoRow',
- infoLabel: 'infoLabel',
- infoValue: 'infoValue',
- disabledState: 'disabledState',
- disabledIcon: 'disabledIcon',
- }),
-}));
-
// Mock error utils
jest.mock('../../../utils/errorUtils', () => ({
isForbiddenError: (err: any) =>
@@ -87,8 +55,14 @@ jest.mock('../../../utils/errorUtils', () => ({
// ---- Helpers ----
+const testEntity = mockSystemEntity({ name: 'test-project' });
+
function renderWithRouter(ui: React.ReactElement) {
- return render({ui});
+ return render(
+
+ {ui}
+ ,
+ );
}
// ---- Tests ----
diff --git a/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx
index 9fe071ac3..f7a729ca9 100644
--- a/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx
+++ b/plugins/openchoreo/src/components/Projects/ProjectComponentsCard/ProjectComponentsCard.test.tsx
@@ -1,27 +1,11 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { mockSystemEntity } from '@openchoreo/test-utils';
import { ProjectComponentsCard } from './ProjectComponentsCard';
// ---- Mocks ----
-// Mock styles
-jest.mock('./styles', () => ({
- useProjectComponentsCardStyles: () => ({
- cardWrapper: 'cardWrapper',
- deploymentStatus: 'deploymentStatus',
- chipContainer: 'chipContainer',
- environmentChip: 'environmentChip',
- statusIconReady: 'statusIconReady',
- statusIconWarning: 'statusIconWarning',
- statusIconError: 'statusIconError',
- statusIconDefault: 'statusIconDefault',
- buildStatus: 'buildStatus',
- tooltipBuildName: 'tooltipBuildName',
- moreChip: 'moreChip',
- createComponentButton: 'createComponentButton',
- }),
-}));
-
// Mock project hooks
const mockUseComponentsWithDeployment = jest.fn();
const mockUseEnvironments = jest.fn();
@@ -33,26 +17,9 @@ jest.mock('../hooks', () => ({
useDeploymentPipeline: () => mockUseDeploymentPipeline(),
}));
-// Mock @backstage/plugin-catalog-react
-jest.mock('@backstage/plugin-catalog-react', () => ({
- useEntity: () => ({
- entity: {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'System',
- metadata: {
- name: 'test-project',
- namespace: 'default',
- annotations: {
- 'openchoreo.io/namespace': 'test-ns',
- },
- },
- spec: {},
- },
- }),
-}));
-
-// Mock @backstage/core-plugin-api
+// Mock @backstage/core-plugin-api (useApp is not provided by TestApiProvider)
jest.mock('@backstage/core-plugin-api', () => ({
+ ...jest.requireActual('@backstage/core-plugin-api'),
useApp: () => ({
getSystemIcon: () => () => ,
}),
@@ -136,8 +103,14 @@ jest.mock('./BuildStatusCell', () => ({
// ---- Helpers ----
+const testEntity = mockSystemEntity({ name: 'test-project' });
+
function renderWithRouter(ui: React.ReactElement) {
- return render({ui});
+ return render(
+
+ {ui}
+ ,
+ );
}
const defaultPermissions = () => {
diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx b/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx
new file mode 100644
index 000000000..e69d38aed
--- /dev/null
+++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/WorkflowsOverviewCard.test.tsx
@@ -0,0 +1,239 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { WorkflowsOverviewCard } from './WorkflowsOverviewCard';
+
+// ---- Mocks ----
+
+jest.mock('@openchoreo/backstage-design-system', () => ({
+ Card: ({ children, ...rest }: any) => (
+
+ {children}
+
+ ),
+}));
+
+jest.mock('@backstage/core-components', () => ({
+ Link: ({ children, to }: any) => {children},
+}));
+
+jest.mock('../BuildStatusChip', () => ({
+ BuildStatusChip: ({ status }: { status: string }) => (
+ {status}
+ ),
+}));
+
+const mockUseBuildPermission = jest.fn();
+
+jest.mock('@openchoreo/backstage-plugin-react', () => ({
+ useBuildPermission: () => mockUseBuildPermission(),
+ ForbiddenState: ({ message }: any) => (
+ {message}
+ ),
+ formatRelativeTime: (ts: string) => `relative(${ts})`,
+}));
+
+const mockUseWorkflowsSummary = jest.fn();
+jest.mock('./useWorkflowsSummary', () => ({
+ useWorkflowsSummary: () => mockUseWorkflowsSummary(),
+}));
+
+// ---- Helpers ----
+
+const defaultPermission = {
+ canBuild: true,
+ canView: true,
+ viewBuildDeniedTooltip: '',
+ triggerLoading: false,
+ viewLoading: false,
+ triggerBuildDeniedTooltip: '',
+};
+
+function renderCard() {
+ return render(
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('WorkflowsOverviewCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseBuildPermission.mockReturnValue(defaultPermission);
+ });
+
+ it('shows forbidden state when view permission is denied', () => {
+ mockUseBuildPermission.mockReturnValue({
+ ...defaultPermission,
+ canView: false,
+ viewLoading: false,
+ viewBuildDeniedTooltip: 'No permission to view builds',
+ });
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: false,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByTestId('forbidden-state')).toBeInTheDocument();
+ expect(
+ screen.getByText('No permission to view builds'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows loading skeleton when loading', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: true,
+ error: null,
+ hasWorkflows: false,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByTestId('ds-card')).toBeInTheDocument();
+ expect(screen.queryByText('Workflows')).not.toBeInTheDocument();
+ });
+
+ it('shows error state when there is an error', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: new Error('fetch failed'),
+ hasWorkflows: false,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(
+ screen.getByText('Failed to load workflow data'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows workflows not enabled state', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: false,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(
+ screen.getByText('Workflows not enabled for this component'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows no builds yet with Build Now button', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: true,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByText('No builds yet')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /build now/i })).toBeEnabled();
+ expect(screen.getByText('View All')).toBeInTheDocument();
+ });
+
+ it('shows latest build info with status chip', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: true,
+ latestBuild: {
+ name: 'build-42',
+ status: 'Succeeded',
+ createdAt: '2024-06-01T10:00:00Z',
+ commit: 'abcdef1234567890',
+ },
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByText('Latest Build')).toBeInTheDocument();
+ expect(screen.getByText('build-42')).toBeInTheDocument();
+ expect(screen.getByTestId('build-status-chip')).toHaveTextContent(
+ 'Succeeded',
+ );
+ expect(
+ screen.getByText('relative(2024-06-01T10:00:00Z)'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('abcdef12')).toBeInTheDocument(); // first 8 chars
+ });
+
+ it('disables Build Now when build permission denied', () => {
+ mockUseBuildPermission.mockReturnValue({
+ ...defaultPermission,
+ canBuild: false,
+ });
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: true,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByRole('button', { name: /build now/i })).toBeDisabled();
+ });
+
+ it('calls triggerBuild when Build Now is clicked', async () => {
+ const user = userEvent.setup();
+ const triggerBuild = jest.fn();
+
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: true,
+ latestBuild: null,
+ triggeringBuild: false,
+ triggerBuild,
+ });
+
+ renderCard();
+
+ await user.click(screen.getByRole('button', { name: /build now/i }));
+ expect(triggerBuild).toHaveBeenCalled();
+ });
+
+ it('shows Building... when build is in progress', () => {
+ mockUseWorkflowsSummary.mockReturnValue({
+ loading: false,
+ error: null,
+ hasWorkflows: true,
+ latestBuild: null,
+ triggeringBuild: true,
+ triggerBuild: jest.fn(),
+ });
+
+ renderCard();
+
+ expect(screen.getByRole('button', { name: /building/i })).toBeDisabled();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts
index 41476525f..b14c4a7dd 100644
--- a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts
+++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts
@@ -90,6 +90,7 @@ export function useWorkflowsSummary() {
namespaceName: run.namespaceName,
status: run.status,
createdAt: run.createdAt,
+ commit: run.commit,
}));
const sortedBuilds = [...runs].sort(
(a, b) =>
diff --git a/yarn.lock b/yarn.lock
index a7f9f5082..dbfcc87c7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10711,6 +10711,7 @@ __metadata:
"@openchoreo/backstage-design-system": "workspace:^"
"@openchoreo/backstage-plugin-common": "workspace:^"
"@openchoreo/backstage-plugin-react": "workspace:^"
+ "@openchoreo/test-utils": "workspace:^"
"@rjsf/core": "npm:5.24.13"
"@rjsf/material-ui": "npm:5.24.13"
"@rjsf/utils": "npm:5.24.13"
@@ -11021,6 +11022,7 @@ __metadata:
"@openchoreo/backstage-design-system": "workspace:^"
"@openchoreo/backstage-plugin-common": "workspace:^"
"@openchoreo/backstage-plugin-react": "workspace:^"
+ "@openchoreo/test-utils": "workspace:^"
"@rjsf/core": "npm:5.24.13"
"@rjsf/material-ui": "npm:5.24.13"
"@rjsf/utils": "npm:5.24.13"
@@ -11093,6 +11095,7 @@ __metadata:
resolution: "@openchoreo/test-utils@workspace:packages/test-utils"
dependencies:
"@backstage/backend-test-utils": "npm:1.9.0"
+ "@backstage/catalog-model": "npm:1.7.5"
"@backstage/cli": "npm:0.34.3"
languageName: unknown
linkType: soft