diff --git a/public/app/features/alerting/unified/api/testIntegrationApi.ts b/public/app/features/alerting/unified/api/testIntegrationApi.ts new file mode 100644 index 00000000000..57664d65664 --- /dev/null +++ b/public/app/features/alerting/unified/api/testIntegrationApi.ts @@ -0,0 +1,57 @@ +import { getAPINamespace } from '../../../../api/utils'; + +import { alertingApi } from './alertingApi'; + +interface TestIntegrationAlert { + labels: Record; + annotations: Record; +} + +interface TestIntegrationSettings { + uid?: string; + type: string; + version?: string; + settings: Record; + secureFields?: Record; + disableResolveMessage?: boolean; +} + +export interface TestIntegrationRequest { + receiverUid: string; + integration: TestIntegrationSettings; + alert: TestIntegrationAlert; +} + +export interface TestIntegrationResponse { + apiVersion: string; + kind: string; + status: 'success' | 'failure'; + duration: string; + error?: string; +} + +const NEW_RECEIVER_PLACEHOLDER = '-'; + +export const testIntegrationApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + testIntegrationK8s: build.mutation({ + query: (params) => { + const namespace = getAPINamespace(); + const receiverName = params.receiverUid || NEW_RECEIVER_PLACEHOLDER; + + const body = { + integration: params.integration, + alert: params.alert, + }; + + return { + url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${namespace}/receivers/${receiverName}/test`, + method: 'POST', + data: body, + }; + }, + }), + }), +}); + +export const { useTestIntegrationK8sMutation } = testIntegrationApi; diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx index face7c4c69a..cb38d6b8d1c 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.test.tsx @@ -1,10 +1,12 @@ import 'core-js/stable/structured-clone'; import { MemoryHistoryBuildOptions } from 'history'; +import { HttpResponse, delay, http } from 'msw'; import { ComponentProps, ReactNode } from 'react'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { render, screen, waitFor } from 'test/test-utils'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; +import { config } from '@grafana/runtime'; import { disablePlugin } from 'app/features/alerting/unified/mocks/server/configure'; import { setOnCallFeatures, @@ -112,6 +114,7 @@ describe('GrafanaReceiverForm', () => { beforeEach(() => { grantUserPermissions([ + AccessControlAction.AlertingReceiversRead, AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite, ]); @@ -720,6 +723,282 @@ describe('GrafanaReceiverForm', () => { expect(requestBody.receivers[0].name).toBe(originalContactPointName); }); }); + + describe('Test contact point - K8s Test API', () => { + describe('when alertingImportAlertmanagerAPI is enabled', () => { + beforeEach(() => { + config.featureToggles.alertingImportAlertmanagerAPI = true; + }); + + afterEach(() => { + config.featureToggles.alertingImportAlertmanagerAPI = false; + }); + + it('should use K8s API for testing an integration', async () => { + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build(); + + const capturedRequests = captureRequests( + (req) => req.url.includes('/apis/notifications.alerting.grafana.app/') && req.method === 'POST' + ); + + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Find and click the test button + const testButton = await screen.findByRole('button', { name: /test/i }); + await user.click(testButton); + + // Modal should open + expect(await screen.findByText(/test contact point/i)).toBeInTheDocument(); + + // Submit the test + const sendButton = screen.getByRole('button', { name: /send test notification/i }); + await user.click(sendButton); + + // Should show success + await waitFor(() => { + expect(screen.getByText(/test notification sent successfully/i)).toBeInTheDocument(); + }); + + // Verify the request was made to the K8s API endpoint + const [request] = await capturedRequests; + expect(request.url).toContain('/apis/notifications.alerting.grafana.app/'); + expect(request.url).toContain('/receivers/'); + expect(request.url).toContain('/test'); + }); + + it('should use "-" placeholder for receiver UID when testing a new contact point', async () => { + // Set up a handler that only succeeds when the receiver UID is "-" + // This proves the correct endpoint was called through UI side effects + server.use( + http.post<{ namespace: string; name: string }>( + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test', + ({ params }) => { + if (params.name !== '-') { + return HttpResponse.json( + { message: `Expected receiver UID to be "-", got "${params.name}"` }, + { status: 400 } + ); + } + return HttpResponse.json({ + apiVersion: 'notifications.alerting.grafana.app/v0alpha1', + kind: 'CreateReceiverIntegrationTest', + status: 'success', + duration: '150ms', + }); + } + ) + ); + + // Render form without contactPoint prop - this is a brand new contact point + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Fill in the required fields + const nameField = screen.getByRole('textbox', { name: /^name/i }); + await user.type(nameField, 'my-new-contact-point'); + + const emailField = screen.getByRole('textbox', { name: /^Addresses/ }); + await user.clear(emailField); + await user.type(emailField, 'test@example.com'); + + // Click the test button + await user.click(ui.testButton.get()); + + // Wait for the modal to open + await waitFor(() => expect(ui.testModal.query()).toBeInTheDocument()); + + // Send the test notification + await user.click(ui.sendTestNotificationButton.get()); + + expect(screen.getByText(/test notification sent successfully/i)).toBeInTheDocument(); + }); + + it('should not show test button when canTest annotation is false', async () => { + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build({ + metadata: { + annotations: { + 'grafana.com/access/canTest': 'false', + }, + }, + }); + + renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Test button should not be visible or disabled + const testButton = screen.queryByRole('button', { name: /test/i }); + expect(testButton).not.toBeInTheDocument(); + }); + + it('should not show test button when canTest annotation is missing', async () => { + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build({ + metadata: { + annotations: { + 'grafana.com/access/canTest': undefined, + }, + }, + }); + + renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + const testButton = screen.queryByRole('button', { name: /test/i }); + expect(testButton).not.toBeInTheDocument(); + }); + + it('should show test button when canTest annotation is true', async () => { + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build({ + metadata: { + annotations: { + 'grafana.com/access/canTest': 'true', + }, + }, + }); + + renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + expect(ui.testButton.get()).toBeInTheDocument(); + }); + + it('should disable send button while test notification is in progress', async () => { + // Use a delayed handler to observe the loading state + server.use( + http.post<{ namespace: string; name: string }>( + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test', + async () => { + await delay(100); + return HttpResponse.json({ + apiVersion: 'notifications.alerting.grafana.app/v0alpha1', + kind: 'CreateReceiverIntegrationTest', + status: 'success', + duration: '150ms', + }); + } + ) + ); + + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build(); + + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Open the test modal + await user.click(ui.testButton.get()); + await waitFor(() => expect(ui.testModal.query()).toBeInTheDocument()); + + // Button should be enabled before clicking + const sendButton = ui.sendTestNotificationButton.get(); + expect(sendButton).toBeEnabled(); + + // Click to send test notification + await user.click(sendButton); + + // Button should be disabled while loading + expect(sendButton).toBeDisabled(); + + // Wait for success and verify button is enabled again + await waitFor(() => { + expect(screen.getByText(/test notification sent successfully/i)).toBeInTheDocument(); + }); + expect(sendButton).toBeEnabled(); + }); + + it('should display error message when K8s API returns an error', async () => { + const errorMessage = 'Connection refused: unable to reach webhook endpoint'; + server.use( + http.post<{ namespace: string; name: string }>( + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test', + () => { + return HttpResponse.json({ + apiVersion: 'notifications.alerting.grafana.app/v0alpha1', + kind: 'CreateReceiverIntegrationTest', + status: 'failure', + duration: '50ms', + error: errorMessage, + }); + } + ) + ); + + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build(); + + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Open the test modal + await user.click(ui.testButton.get()); + await waitFor(() => expect(ui.testModal.query()).toBeInTheDocument()); + + // Send the test notification + await user.click(ui.sendTestNotificationButton.get()); + + await waitFor(() => { + expect(screen.getByText(/test notification failed/i)).toBeInTheDocument(); + }); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + + expect(screen.queryByText(/test notification sent successfully/i)).not.toBeInTheDocument(); + }); + + it('should display error message when K8s API returns HTTP error', async () => { + server.use( + http.post<{ namespace: string; name: string }>( + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test', + () => { + return HttpResponse.json( + { message: 'Internal server error: database connection failed' }, + { status: 500 } + ); + } + ) + ); + + const contactPoint = alertingFactory.alertmanager.grafana.contactPoint + .withIntegrations((integrationFactory) => [integrationFactory.webhook().build()]) + .build(); + + const { user } = renderWithProvider(); + + await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument()); + + // Open the test modal + await user.click(ui.testButton.get()); + await waitFor(() => expect(ui.testModal.query()).toBeInTheDocument()); + + // Send the test notification + await user.click(ui.sendTestNotificationButton.get()); + + // Should show the error alert + await waitFor(() => { + expect(screen.getByText(/test notification failed/i)).toBeInTheDocument(); + }); + + // Success message should not be shown + expect(screen.queryByText(/test notification sent successfully/i)).not.toBeInTheDocument(); + }); + }); + }); }); function getAmCortexConfig(configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig { diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index 9dcf9fedb80..ff5c3018aca 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -14,20 +14,13 @@ import { canModifyProtectedEntity, isProvisionedResource, } from 'app/features/alerting/unified/utils/k8s/utils'; -import { - GrafanaManagedContactPoint, - GrafanaManagedReceiverConfig, - Receiver, -} from 'app/plugins/datasource/alertmanager/types'; +import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../../../api/alertmanagerApi'; +import { useTestContactPoint } from '../../../hooks/useTestContactPoint'; import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form'; import { hasLegacyIntegrations } from '../../../utils/notifier-versions'; -import { - formChannelValuesToGrafanaChannelConfig, - formValuesToGrafanaReceiver, - grafanaReceiverToFormValues, -} from '../../../utils/receiver-form'; +import { formValuesToGrafanaReceiver, grafanaReceiverToFormValues } from '../../../utils/receiver-form'; import { ImportedResourceAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall'; import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration'; @@ -74,7 +67,10 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } = useOnCallIntegration(); const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery(); - const [testReceivers, setTestReceivers] = useState(); + const [testChannelData, setTestChannelData] = useState<{ + channelValues: GrafanaChannelValues; + existingIntegration?: GrafanaManagedReceiverConfig; + }>(); // transform receiver DTO to form values const [existingValue, id2original] = useMemo((): [ @@ -116,24 +112,23 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } const onTestChannel = (values: GrafanaChannelValues) => { const existing: GrafanaManagedReceiverConfig | undefined = id2original[values.__id]; - const chan = formChannelValuesToGrafanaChannelConfig(values, defaultChannelValues, 'test', existing); - - const receivers: Receiver[] = [ - { - name: contactPoint?.name ?? '', // for new receivers we can use empty string as name - grafana_managed_receiver_configs: [chan], - }, - ]; - - setTestReceivers(receivers); + setTestChannelData({ + channelValues: values, + existingIntegration: existing, + }); }; + const { canTest } = useTestContactPoint({ + contactPoint, + defaultChannelValues, + }); + // If there is no contact point it means we're creating a new one, so scoped permissions doesn't exist yet const hasScopedEditPermissions = contactPoint ? canEditEntity(contactPoint) : true; const hasScopedEditProtectedPermissions = contactPoint ? canModifyProtectedEntity(contactPoint) : true; const isProvisioned = isProvisionedResource(contactPoint?.provenance); const isEditable = !readOnly && hasScopedEditPermissions && !isProvisioned; - const isTestable = !readOnly; + const isTestable = !readOnly && canTest; const canEditProtectedFields = editMode ? hasScopedEditProtectedPermissions : true; if (isLoadingNotifiers || isLoadingOnCallIntegration) { @@ -196,12 +191,14 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } canEditProtectedFields={canEditProtectedFields} /> - {testReceivers && ( + {testChannelData && ( setTestReceivers(undefined)} - isOpen={!!testReceivers} - alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME} - receivers={testReceivers} + onDismiss={() => setTestChannelData(undefined)} + isOpen={!!testChannelData} + contactPoint={contactPoint} + channelValues={testChannelData.channelValues} + existingIntegration={testChannelData.existingIntegration} + defaultChannelValues={defaultChannelValues} /> )} diff --git a/public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx b/public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx index 78cdb95fa89..e80f9067762 100644 --- a/public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx @@ -5,10 +5,10 @@ import { FormProvider, useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Button, Label, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui'; -import { Receiver, TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types'; -import { Annotations, Labels } from 'app/types/unified-alerting-dto'; +import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; -import { useTestIntegrationMutation } from '../../../api/receiversApi'; +import { useTestContactPoint } from '../../../hooks/useTestContactPoint'; +import { GrafanaChannelValues } from '../../../types/receiver-form'; import { defaultAnnotations } from '../../../utils/constants'; import { stringifyErrorLike } from '../../../utils/misc'; import AnnotationsStep from '../../rule-editor/AnnotationsStep'; @@ -17,8 +17,10 @@ import LabelsField from '../../rule-editor/labels/LabelsField'; interface Props { isOpen: boolean; onDismiss: () => void; - alertManagerSourceName: string; - receivers: Receiver[]; + contactPoint?: GrafanaManagedContactPoint; + channelValues: GrafanaChannelValues; + existingIntegration?: GrafanaManagedReceiverConfig; + defaultChannelValues: GrafanaChannelValues; } type AnnoField = { @@ -43,35 +45,59 @@ const defaultValues: FormFields = { labels: [{ key: '', value: '' }], }; -export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceName, receivers }: Props) => { +export const TestContactPointModal = ({ + isOpen, + onDismiss, + contactPoint, + channelValues, + existingIntegration, + defaultChannelValues, +}: Props) => { const [notificationType, setNotificationType] = useState(NotificationType.predefined); + const [testError, setTestError] = useState(null); + const [testSuccess, setTestSuccess] = useState(false); const styles = useStyles2(getStyles); const formMethods = useForm({ defaultValues, mode: 'onBlur' }); - const [testIntegration, { isLoading, error, isSuccess }] = useTestIntegrationMutation(); + const { + testChannel, + isLoading, + error: apiError, + isSuccess: apiSuccess, + } = useTestContactPoint({ + contactPoint, + defaultChannelValues, + }); + + // Combine RTK Query errors with errors thrown by testChannel + const error = testError || apiError; + const isSuccess = !error && (testSuccess || apiSuccess); const onSubmit = async (data: FormFields) => { - let alert: TestReceiversAlert | undefined; + setTestError(null); + setTestSuccess(false); - if (notificationType === NotificationType.custom) { - alert = { - annotations: data.annotations - .filter(({ key, value }) => !!key && !!value) - .reduce((acc, { key, value }) => { - return { ...acc, [key]: value }; - }, {}), - labels: data.labels - .filter(({ key, value }) => !!key && !!value) - .reduce((acc, { key, value }) => { - return { ...acc, [key]: value }; - }, {}), - }; + const alert = + notificationType === NotificationType.custom + ? { + annotations: data.annotations + .filter(({ key, value }) => !!key && !!value) + .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}), + labels: data.labels + .filter(({ key, value }) => !!key && !!value) + .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}), + } + : undefined; + + try { + await testChannel({ + channelValues, + existingIntegration, + alert, + }); + setTestSuccess(true); + } catch (err) { + setTestError(err); } - - await testIntegration({ - alertManagerSourceName, - receivers, - alert, - }).unwrap(); }; return ( diff --git a/public/app/features/alerting/unified/hooks/useTestContactPoint.test.ts b/public/app/features/alerting/unified/hooks/useTestContactPoint.test.ts new file mode 100644 index 00000000000..3c9c287c5c3 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useTestContactPoint.test.ts @@ -0,0 +1,715 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { getWrapper } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; +import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; + +import { TestIntegrationResponse } from '../api/testIntegrationApi'; +import { setupMswServer } from '../mockApi'; +import { GrafanaChannelValues } from '../types/receiver-form'; +import { K8sAnnotations } from '../utils/k8s/constants'; + +import { useTestContactPoint } from './useTestContactPoint'; + +const server = setupMswServer(); + +interface K8sTestRequestBody { + integration: { + uid?: string; + type: string; + [key: string]: unknown; + }; + alert: { + labels: Record; + annotations: Record; + }; +} + +const wrapper = () => getWrapper({ renderWithRouter: true }); + +const K8S_TEST_ENDPOINT = + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test'; + +const defaultK8sSuccessResponse: TestIntegrationResponse = { + apiVersion: 'notifications.alerting.grafana.app/v0alpha1', + kind: 'CreateReceiverIntegrationTest', + status: 'success', + duration: '100ms', +}; + +interface K8sTestHandlerOptions { + onRequestBody?: (body: unknown) => void; + onRequestUrl?: (url: string) => void; + response?: Partial; + status?: number; + waitFor?: Promise; + networkError?: boolean; +} + +// Creates a MSW handler for the K8s receiver test endpoint. +function createK8sTestHandler(options: K8sTestHandlerOptions = {}) { + return http.post(K8S_TEST_ENDPOINT, async ({ request }) => { + options.onRequestUrl?.(request.url); + + if (!options.networkError) { + const body = await request.json(); + options.onRequestBody?.(body); + } + + if (options.waitFor) { + await options.waitFor; + } + + if (options.networkError) { + return HttpResponse.error(); + } + + return HttpResponse.json({ ...defaultK8sSuccessResponse, ...options.response }, { status: options.status }); + }); +} + +// Mock data factories +const createChannelValues = (overrides?: Partial): GrafanaChannelValues => ({ + __id: '1', + type: 'webhook', + settings: { url: 'https://example.com' }, + secureFields: {}, + disableResolveMessage: false, + ...overrides, +}); + +const createExistingIntegration = ( + overrides?: Partial +): GrafanaManagedReceiverConfig => ({ + uid: 'integration-123', + type: 'webhook', + settings: { url: 'https://example.com' }, + secureFields: {}, + disableResolveMessage: false, + ...overrides, +}); + +const createContactPoint = (overrides?: Partial): GrafanaManagedContactPoint => ({ + id: 'receiver-uid-123', + name: 'Test Receiver', + grafana_managed_receiver_configs: [], + metadata: { + name: 'Test Receiver', + namespace: 'default', + uid: 'receiver-uid-123', + annotations: { + [K8sAnnotations.AccessTest]: 'true', + }, + }, + ...overrides, +}); + +const defaultChannelValues = createChannelValues(); + +describe('useTestContactPoint', () => { + afterEach(() => { + server.resetHandlers(); + }); + + describe('canTest logic', () => { + it('should return canTest=true when contactPoint is undefined (new receiver)', () => { + const { result } = renderHook(() => useTestContactPoint({ contactPoint: undefined, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.canTest).toBe(true); + }); + + it('should return canTest=true when canTest annotation is "true"', () => { + const contactPoint = createContactPoint({ + metadata: { + name: 'Test Receiver', + namespace: 'default', + uid: 'receiver-uid-123', + annotations: { [K8sAnnotations.AccessTest]: 'true' }, + }, + }); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.canTest).toBe(true); + }); + + it('should return canTest=false when canTest annotation is "false"', () => { + const contactPoint = createContactPoint({ + metadata: { + name: 'Test Receiver', + namespace: 'default', + uid: 'receiver-uid-123', + annotations: { [K8sAnnotations.AccessTest]: 'false' }, + }, + }); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.canTest).toBe(false); + }); + + it('should return canTest=false when canTest annotation is missing', () => { + const contactPoint = createContactPoint({ + metadata: { + name: 'Test Receiver', + namespace: 'default', + uid: 'receiver-uid-123', + annotations: {}, + }, + }); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.canTest).toBe(false); + }); + }); + + describe('API path selection', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + server.resetHandlers(); + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + server.resetHandlers(); + }); + + it('should use K8s API when alertingImportAlertmanagerAPI is enabled', async () => { + // Suppress expected RTK Query async state update warnings + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + config.featureToggles = { ...originalFeatureToggles, alertingImportAlertmanagerAPI: true }; + + let requestUrl: string | undefined; + server.use(createK8sTestHandler({ onRequestUrl: (url) => (requestUrl = url) })); + + const contactPoint = createContactPoint(); + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + const testPromise = result.current.testChannel({ + channelValues: createChannelValues(), + existingIntegration: createExistingIntegration(), + }); + + await act(async () => { + await testPromise; + }); + + // Wait for RTK Query state updates to complete and request to be made + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 3000 } + ); + + expect(requestUrl).not.toBeUndefined(); + expect(requestUrl).toContain('/apis/notifications.alerting.grafana.app/'); + } finally { + consoleSpy.mockRestore(); + } + }); + + it('should use old API when alertingImportAlertmanagerAPI is disabled', async () => { + // Suppress expected RTK Query async state update warnings + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + config.featureToggles = { ...originalFeatureToggles, alertingImportAlertmanagerAPI: false }; + + let requestUrl: string | undefined; + server.use( + http.post('*/config/api/v1/receivers/test', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ + notified_at: new Date().toISOString(), + receivers: [ + { + name: 'Test Receiver', + grafana_managed_receiver_configs: [{ name: 'webhook', status: 'ok' }], + }, + ], + }); + }) + ); + + const contactPoint = createContactPoint(); + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + const testPromise = result.current.testChannel({ + channelValues: createChannelValues(), + existingIntegration: createExistingIntegration(), + }); + + await act(async () => { + await testPromise; + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 3000 } + ); + + expect(requestUrl).not.toBeUndefined(); + expect(requestUrl).toContain('/api/alertmanager/'); + } finally { + consoleSpy.mockRestore(); + } + }); + }); + + describe('K8s API request construction', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + config.featureToggles = { ...originalFeatureToggles, alertingImportAlertmanagerAPI: true }; + server.resetHandlers(); + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + server.resetHandlers(); + }); + + it('should build payload correctly when integration is unchanged', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const existingIntegration = createExistingIntegration(); + const channelValues = createChannelValues(); // Same as existing + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + const body = capturedBody as K8sTestRequestBody; + expect(body.integration?.uid).toBe('integration-123'); + expect(body.integration?.type).toBe('webhook'); + }); + + it('should build payload correctly when integration has changed', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const existingIntegration = createExistingIntegration(); + const channelValues = createChannelValues({ + settings: { url: 'https://changed.com' }, + }); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + const body = capturedBody as K8sTestRequestBody; + expect(body.integration?.uid).toBe('integration-123'); + expect(body.integration?.type).toBe('webhook'); + }); + + it('should use test-with-config when type has changed', async () => { + let capturedBody: unknown; + server.use(createK8sTestHandler({ onRequestBody: (body) => (capturedBody = body) })); + + const existingIntegration = createExistingIntegration({ type: 'webhook' }); + const channelValues = createChannelValues({ type: 'email' }); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + const body = capturedBody as K8sTestRequestBody; + expect(body.integration?.type).toBe('email'); + }); + + it('should use test-with-config when disableResolveMessage has changed', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const existingIntegration = createExistingIntegration({ disableResolveMessage: false }); + const channelValues = createChannelValues({ disableResolveMessage: true }); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + }); + + it('should use test-with-config when a secure field was cleared', async () => { + let capturedBody: unknown; + server.use(createK8sTestHandler({ onRequestBody: (body) => (capturedBody = body) })); + + const existingIntegration = createExistingIntegration({ + secureFields: { password: true }, + }); + const channelValues = createChannelValues({ + secureFields: { password: false }, + }); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + }); + + it('should use test-with-config when nested settings have changed', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const existingIntegration = createExistingIntegration({ + settings: { config: { nested: { value: 1 } } }, + }); + const channelValues = createChannelValues({ + settings: { config: { nested: { value: 2 } } }, + }); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + }); + + it('should use test-with-config for new integrations (no existingIntegration)', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const channelValues = createChannelValues(); + const contactPoint = createContactPoint(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues, existingIntegration: undefined }); + }); + + expect(capturedBody).not.toBeUndefined(); + expect(capturedBody).toHaveProperty('integration'); + const body = capturedBody as K8sTestRequestBody; + expect(body.integration?.uid).toBeUndefined(); + }); + + it('should use "-" placeholder for new receiver (no contactPoint.id)', async () => { + let capturedUrl: string | undefined; + server.use( + createK8sTestHandler({ + onRequestUrl: (url) => { + capturedUrl = url; + }, + }) + ); + + const contactPoint = createContactPoint({ id: '' }); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ channelValues }); + }); + + expect(capturedUrl).not.toBeUndefined(); + expect(capturedUrl).toContain('/receivers/-/test'); + }); + + it('should include custom alert labels and annotations', async () => { + let capturedBody: unknown; + server.use( + createK8sTestHandler({ + onRequestBody: (body) => { + capturedBody = body; + }, + }) + ); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + await result.current.testChannel({ + channelValues, + alert: { + labels: { severity: 'critical', alertname: 'CustomAlert' }, + annotations: { summary: 'Test summary' }, + }, + }); + }); + + expect(capturedBody).not.toBeUndefined(); + const body = capturedBody as K8sTestRequestBody; + expect(body.alert).toEqual({ + labels: { severity: 'critical', alertname: 'CustomAlert' }, + annotations: { summary: 'Test summary' }, + }); + }); + }); + + describe('error handling', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + config.featureToggles = { ...originalFeatureToggles, alertingImportAlertmanagerAPI: true }; + server.resetHandlers(); + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + server.resetHandlers(); + }); + + it('should throw error when K8s API returns failure status', async () => { + server.use( + createK8sTestHandler({ + response: { status: 'failure', error: 'Connection refused' }, + }) + ); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + let testPromise: Promise; + act(() => { + testPromise = result.current.testChannel({ channelValues }); + }); + + await expect(testPromise!).rejects.toThrow('Connection refused'); + }); + + it('should throw generic error when failure has no error message', async () => { + server.use( + createK8sTestHandler({ + response: { status: 'failure' }, + }) + ); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + let testPromise: Promise; + act(() => { + testPromise = result.current.testChannel({ channelValues }); + }); + + await expect(testPromise!).rejects.toThrow('Test notification failed'); + }); + + it('should propagate network errors', async () => { + server.use(createK8sTestHandler({ networkError: true })); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + let testPromise: Promise; + act(() => { + testPromise = result.current.testChannel({ channelValues }); + }); + + await expect(testPromise!).rejects.toThrow(); + }); + }); + + describe('state management', () => { + const originalFeatureToggles = config.featureToggles; + + beforeEach(() => { + config.featureToggles = { ...originalFeatureToggles, alertingImportAlertmanagerAPI: true }; + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + server.resetHandlers(); + }); + + it('should set isLoading=true while request is in progress', async () => { + let resolveRequest: () => void; + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + + server.use(createK8sTestHandler({ waitFor: requestPromise })); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.isLoading).toBe(false); + + let testPromise: Promise; + await act(async () => { + testPromise = result.current.testChannel({ channelValues }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + resolveRequest!(); + await act(async () => { + await testPromise!; + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should set isSuccess=true after successful test', async () => { + server.use(createK8sTestHandler()); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + expect(result.current.isSuccess).toBe(false); + + await act(async () => { + await result.current.testChannel({ channelValues }); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }); + + it('should set error after failed API call', async () => { + server.use( + createK8sTestHandler({ + response: { message: 'Internal server error' } as unknown as Partial, + status: 500, + }) + ); + + const contactPoint = createContactPoint(); + const channelValues = createChannelValues(); + + const { result } = renderHook(() => useTestContactPoint({ contactPoint, defaultChannelValues }), { + wrapper: wrapper(), + }); + + await act(async () => { + try { + await result.current.testChannel({ channelValues }); + } catch { + // Expected to throw + } + }); + + await waitFor(() => { + expect(result.current.error).toBeDefined(); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/useTestContactPoint.ts b/public/app/features/alerting/unified/hooks/useTestContactPoint.ts new file mode 100644 index 00000000000..de982340de9 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useTestContactPoint.ts @@ -0,0 +1,131 @@ +import { useCallback, useMemo } from 'react'; + +import { config } from '@grafana/runtime'; +import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types'; + +import { useTestIntegrationMutation } from '../api/receiversApi'; +import { TestIntegrationRequest, useTestIntegrationK8sMutation } from '../api/testIntegrationApi'; +import { GrafanaChannelValues } from '../types/receiver-form'; +import { canTestEntity } from '../utils/k8s/utils'; +import { formChannelValuesToGrafanaChannelConfig } from '../utils/receiver-form'; + +interface UseTestContactPointOptions { + contactPoint?: GrafanaManagedContactPoint; + defaultChannelValues: GrafanaChannelValues; +} + +interface TestChannelParams { + channelValues: GrafanaChannelValues; + existingIntegration?: GrafanaManagedReceiverConfig; + alert?: { + labels: Record; + annotations: Record; + }; +} + +export function useTestContactPoint({ contactPoint, defaultChannelValues }: UseTestContactPointOptions) { + const [testOldApi, oldApiState] = useTestIntegrationMutation(); + const [testNewApi, newApiState] = useTestIntegrationK8sMutation(); + + const useK8sApi = Boolean(config.featureToggles.alertingImportAlertmanagerAPI); + + const canTest = useMemo(() => { + if (!contactPoint) { + // For new receivers, assume user can test if they can create + return true; + } + return canTestEntity(contactPoint); + }, [contactPoint]); + + const testWithK8sApi = useCallback( + async ({ + channelValues, + existingIntegration, + testAlert, + }: TestChannelParams & { testAlert: { labels: Record; annotations: Record } }) => { + const receiverUid = contactPoint?.id || ''; + + const integrationConfig = formChannelValuesToGrafanaChannelConfig( + channelValues, + defaultChannelValues, + 'test', + existingIntegration + ); + + const request: TestIntegrationRequest = { + receiverUid, + integration: { + uid: existingIntegration?.uid, + type: integrationConfig.type, + version: integrationConfig.version, + settings: integrationConfig.settings, + secureFields: integrationConfig.secureFields, + disableResolveMessage: integrationConfig.disableResolveMessage, + }, + alert: testAlert, + }; + + const result = await testNewApi(request).unwrap(); + + if (result.status === 'failure') { + throw new Error(result.error || 'Test notification failed'); + } + + return result; + }, + [contactPoint, defaultChannelValues, testNewApi] + ); + + const testWithOldApi = useCallback( + async ({ channelValues, existingIntegration, alert }: TestChannelParams) => { + const chan = formChannelValuesToGrafanaChannelConfig( + channelValues, + defaultChannelValues, + 'test', + existingIntegration + ); + + return testOldApi({ + alertManagerSourceName: 'grafana', + receivers: [ + { + name: contactPoint?.name ?? '', + grafana_managed_receiver_configs: [chan], + }, + ], + alert: alert + ? { + annotations: alert.annotations, + labels: alert.labels, + } + : undefined, + }).unwrap(); + }, + [contactPoint, defaultChannelValues, testOldApi] + ); + + const testChannel = useCallback( + async ({ channelValues, existingIntegration, alert }: TestChannelParams) => { + const defaultAlert = { + labels: { alertname: 'TestAlert' }, + annotations: {}, + }; + const testAlert = alert || defaultAlert; + + if (useK8sApi) { + return testWithK8sApi({ channelValues, existingIntegration, testAlert }); + } else { + return testWithOldApi({ channelValues, existingIntegration, alert }); + } + }, + [useK8sApi, testWithK8sApi, testWithOldApi] + ); + + return { + testChannel, + canTest, + isLoading: useK8sApi ? newApiState.isLoading : oldApiState.isLoading, + error: useK8sApi ? newApiState.error : oldApiState.error, + isSuccess: useK8sApi ? newApiState.isSuccess : oldApiState.isSuccess, + }; +} diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index c3a9db4ac5c..8eff715400d 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -13,6 +13,7 @@ import receiverK8sHandlers from 'app/features/alerting/unified/mocks/server/hand import routingTreeK8sHandlers from 'app/features/alerting/unified/mocks/server/handlers/k8s/routingtrees.k8s'; import templatesK8sHandlers from 'app/features/alerting/unified/mocks/server/handlers/k8s/templates.k8s'; import timeIntervalK8sHandlers from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s'; +import receiverTestK8sHandlers from 'app/features/alerting/unified/mocks/server/handlers/k8sReceiverTest'; import mimirRulerHandlers from 'app/features/alerting/unified/mocks/server/handlers/mimirRuler'; import pluginsHandlers from 'app/features/alerting/unified/mocks/server/handlers/plugins'; import allPluginHandlers from 'app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers'; @@ -36,6 +37,7 @@ export const alertingHandlers = [ // Kubernetes-style handlers ...timeIntervalK8sHandlers, ...receiverK8sHandlers, + ...receiverTestK8sHandlers, ...templatesK8sHandlers, ...routingTreeK8sHandlers, ]; diff --git a/public/app/features/alerting/unified/mocks/server/db.ts b/public/app/features/alerting/unified/mocks/server/db.ts index d33f6938b4c..eecc8b8b887 100644 --- a/public/app/features/alerting/unified/mocks/server/db.ts +++ b/public/app/features/alerting/unified/mocks/server/db.ts @@ -230,6 +230,7 @@ const grafanaContactPointFactory = GrafanaContactPointFactory.define(({ sequence 'grafana.com/access/canDelete': 'true', 'grafana.com/access/canReadSecrets': 'true', 'grafana.com/access/canWrite': 'true', + 'grafana.com/access/canTest': 'true', 'grafana.com/inUse/routes': '0', 'grafana.com/inUse/rules': '1', 'grafana.com/provenance': 'none', diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8sReceiverTest.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8sReceiverTest.ts new file mode 100644 index 00000000000..a88995af1b7 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8sReceiverTest.ts @@ -0,0 +1,41 @@ +import { HttpResponse, http } from 'msw'; + +interface TestRequestBody { + integration: { + uid?: string; + type: string; + settings: Record; + }; + alert: { + labels: Record; + annotations: Record; + }; +} + +const testReceiverK8sHandler = () => + http.post<{ namespace: string; name: string }, TestRequestBody>( + '/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/:namespace/receivers/:name/test', + async ({ request }) => { + const body = await request.json(); + + // Validate request structure + if (!body.alert) { + return HttpResponse.json({ message: 'alert is required' }, { status: 400 }); + } + + if (!body.integration) { + return HttpResponse.json({ message: 'integration is required' }, { status: 400 }); + } + + // Simulate successful test + return HttpResponse.json({ + apiVersion: 'notifications.alerting.grafana.app/v0alpha1', + kind: 'CreateReceiverIntegrationTest', + status: 'success', + duration: '150ms', + }); + } + ); + +const handlers = [testReceiverK8sHandler()]; +export default handlers; diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts index 4bd23b58b73..1fbbd97e42e 100644 --- a/public/app/features/alerting/unified/utils/k8s/constants.ts +++ b/public/app/features/alerting/unified/utils/k8s/constants.ts @@ -20,6 +20,8 @@ export enum K8sAnnotations { AccessDelete = 'grafana.com/access/canDelete', /** Annotation key that indicates that the calling user is able to modify protected fields of this entity */ AccessModifyProtected = 'grafana.com/access/canModifyProtected', + /** Annotation key that indicates that the calling user is able to test this entity */ + AccessTest = 'grafana.com/access/canTest', /** Annotation key that indicates whether this entity can be used in routes and rules */ CanUse = 'grafana.com/canUse', diff --git a/public/app/features/alerting/unified/utils/k8s/utils.test.ts b/public/app/features/alerting/unified/utils/k8s/utils.test.ts index 5b6214846e7..b4d78f5a37e 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.test.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.test.ts @@ -1,6 +1,7 @@ import { KnownProvenance } from '../../types/knownProvenance'; -import { encodeFieldSelector, isProvisionedResource } from './utils'; +import { K8sAnnotations } from './constants'; +import { canTestEntity, encodeFieldSelector, isProvisionedResource } from './utils'; describe('encodeFieldSelector', () => { it('should escape backslashes', () => { @@ -53,3 +54,41 @@ describe('isProvisionedResource', () => { expect(isProvisionedResource('custom-provenance')).toBe(true); }); }); + +describe('canTestEntity', () => { + it('should return true when canTest annotation is "true"', () => { + const entity = { + metadata: { + annotations: { + [K8sAnnotations.AccessTest]: 'true', + }, + }, + }; + expect(canTestEntity(entity)).toBe(true); + }); + + it('should return false when canTest annotation is "false"', () => { + const entity = { + metadata: { + annotations: { + [K8sAnnotations.AccessTest]: 'false', + }, + }, + }; + expect(canTestEntity(entity)).toBe(false); + }); + + it('should return false when canTest annotation is missing', () => { + const entity = { + metadata: { + annotations: {}, + }, + }; + expect(canTestEntity(entity)).toBe(false); + }); + + it('should return false when metadata is undefined', () => { + const entity = {}; + expect(canTestEntity(entity)).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 409298bf9bb..c3e9376c2cc 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -47,6 +47,9 @@ export const canDeleteEntity = (k8sEntity: EntityToCheck) => export const canModifyProtectedEntity = (k8sEntity: EntityToCheck) => getAnnotation(k8sEntity, K8sAnnotations.AccessModifyProtected) === 'true'; +export const canTestEntity = (k8sEntity: EntityToCheck) => + getAnnotation(k8sEntity, K8sAnnotations.AccessTest) === 'true'; + /** * Escape \ and = characters for field selectors. * The Kubernetes API Machinery will decode those automatically.