Alerting: Migrate to K8s style receiver testing API (#116847)

* Add new AccessTest annotation to K8s annotation constant

* Add canTestEntity helper function

- Following same pattern as the other access related helpers included in the same module
- Using new K8sAnnotations.AccessTest annotation

* Add new api endpoint and mock artifacts for tests

- Added new test integration api endpoint
- Updated mock server database to include new annotation
- Added MSW handler for new test endpoint, will be used in tests when implementing new request hook

* Add hook for new contact point testing api endpoint

* Add tests for canTestEntity helper

* Integrate new endpoint with UI components

* Handle api error in TestContactPointModal

* Remove logic to test by reference in useTestContactPoint

Test by reference will not be supported by the api endpoint anymore, so we can simplify the logic to always send the configuration for the contact point test

* Fix url string for v0 api

* Fix test case when annotation is missing

* Fix feature flag used for k8s api use check

* Fix test usage of feature flag in GrafanaReceiverForm.test.tsx
This commit is contained in:
Rodrigo Vasconcelos de Barros 2026-02-02 10:44:48 -05:00 committed by GitHub
parent b1b1556a1f
commit fadafde3df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1348 additions and 55 deletions

View file

@ -0,0 +1,57 @@
import { getAPINamespace } from '../../../../api/utils';
import { alertingApi } from './alertingApi';
interface TestIntegrationAlert {
labels: Record<string, string>;
annotations: Record<string, string>;
}
interface TestIntegrationSettings {
uid?: string;
type: string;
version?: string;
settings: Record<string, unknown>;
secureFields?: Record<string, boolean>;
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<TestIntegrationResponse, TestIntegrationRequest>({
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;

View file

@ -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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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(<GrafanaReceiverForm contactPoint={contactPoint} editMode />);
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 {

View file

@ -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<Receiver[]>();
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 && (
<TestContactPointModal
onDismiss={() => 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}
/>
)}
</>

View file

@ -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>(NotificationType.predefined);
const [testError, setTestError] = useState<unknown>(null);
const [testSuccess, setTestSuccess] = useState(false);
const styles = useStyles2(getStyles);
const formMethods = useForm<FormFields>({ 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<Annotations>((acc, { key, value }) => {
return { ...acc, [key]: value };
}, {}),
labels: data.labels
.filter(({ key, value }) => !!key && !!value)
.reduce<Labels>((acc, { key, value }) => {
return { ...acc, [key]: value };
}, {}),
};
const alert =
notificationType === NotificationType.custom
? {
annotations: data.annotations
.filter(({ key, value }) => !!key && !!value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {}),
labels: data.labels
.filter(({ key, value }) => !!key && !!value)
.reduce<Record<string, string>>((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 (

View file

@ -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<string, string>;
annotations: Record<string, string>;
};
}
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<TestIntegrationResponse>;
status?: number;
waitFor?: Promise<void>;
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>): GrafanaChannelValues => ({
__id: '1',
type: 'webhook',
settings: { url: 'https://example.com' },
secureFields: {},
disableResolveMessage: false,
...overrides,
});
const createExistingIntegration = (
overrides?: Partial<GrafanaManagedReceiverConfig>
): GrafanaManagedReceiverConfig => ({
uid: 'integration-123',
type: 'webhook',
settings: { url: 'https://example.com' },
secureFields: {},
disableResolveMessage: false,
...overrides,
});
const createContactPoint = (overrides?: Partial<GrafanaManagedContactPoint>): 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<unknown>;
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<unknown>;
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<unknown>;
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<void>((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<unknown>;
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<TestIntegrationResponse>,
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();
});
});
});
});

View file

@ -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<string, string>;
annotations: Record<string, string>;
};
}
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<string, string>; annotations: Record<string, string> } }) => {
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,
};
}

View file

@ -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,
];

View file

@ -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',

View file

@ -0,0 +1,41 @@
import { HttpResponse, http } from 'msw';
interface TestRequestBody {
integration: {
uid?: string;
type: string;
settings: Record<string, unknown>;
};
alert: {
labels: Record<string, string>;
annotations: Record<string, string>;
};
}
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;

View file

@ -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',

View file

@ -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);
});
});

View file

@ -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.