mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 19:40:41 +00:00
Compare commits
6 Commits
feat/scatt
...
fix/channe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d506b435b5 | ||
|
|
59cecc3f3a | ||
|
|
9a29dc8911 | ||
|
|
48fd849868 | ||
|
|
220d78cd26 | ||
|
|
09af1cb56c |
@@ -17,11 +17,9 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
@@ -82,7 +80,6 @@
|
||||
"field_incidentio_description": "Description",
|
||||
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
|
||||
"incidentio_required_fields": "Alert source URL and token are required",
|
||||
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
|
||||
"field_incidentio_metadata": "Additional metadata",
|
||||
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
|
||||
"placeholder_incidentio_metadata_key": "Key",
|
||||
@@ -92,8 +89,8 @@
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
"field_webhook_username": "User Name (optional)",
|
||||
"field_webhook_password": "Password (optional)",
|
||||
"field_webhook_username": "Username",
|
||||
"field_webhook_password": "Password",
|
||||
"field_pager_routing_key": "Routing Key",
|
||||
"field_pager_description": "Description",
|
||||
"field_pager_severity": "Severity",
|
||||
@@ -112,13 +109,10 @@
|
||||
"help_pager_group": "A cluster or grouping of sources",
|
||||
"help_pager_component": "The part or component of the affected system that is broke",
|
||||
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
|
||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||
"help_webhook_password": "Specify a password or bearer token",
|
||||
"help_pager_description": "Shows up as description in pagerduty",
|
||||
"channel_creation_done": "Successfully created the channel",
|
||||
"channel_edit_done": "Channels Edited Successfully",
|
||||
"selected_channel_invalid": "Channel type selected is invalid",
|
||||
"username_no_password": "A Password must be provided with user name",
|
||||
"channel_test_done": "An alert has been sent to this channel",
|
||||
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
|
||||
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
|
||||
|
||||
@@ -25,11 +25,9 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
@@ -90,7 +88,8 @@
|
||||
"field_incidentio_description": "Description",
|
||||
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
|
||||
"incidentio_required_fields": "Alert source URL and token are required",
|
||||
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
|
||||
"incidentio_token_bearer_prefix": "Paste the token only, without the Bearer prefix",
|
||||
"incidentio_token_whitespace": "Remove the spaces around the token",
|
||||
"field_incidentio_metadata": "Additional metadata",
|
||||
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
|
||||
"placeholder_incidentio_metadata_key": "Key",
|
||||
@@ -116,6 +115,7 @@
|
||||
"field_slack_field_short": "Short",
|
||||
"add_slack_field": "Add field",
|
||||
"remove_slack_field": "Remove field",
|
||||
"slack_field_value_required": "Enter a value for this field",
|
||||
"field_slack_actions": "Actions",
|
||||
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
|
||||
"label_slack_action_text": "Text",
|
||||
@@ -133,6 +133,7 @@
|
||||
"placeholder_slack_action_style": "Style: default, primary or danger",
|
||||
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
|
||||
"add_slack_action": "Add action",
|
||||
"slack_action_url_or_name_required": "Enter a URL or a name for this button",
|
||||
"remove_slack_action": "Remove action",
|
||||
"field_opsgenie_api_key": "API Key",
|
||||
"field_opsgenie_description": "Description",
|
||||
@@ -143,9 +144,15 @@
|
||||
"help_email_html": "Send email in html format",
|
||||
"field_email_html": "Email body template",
|
||||
"placeholder_email_html": "Email body template",
|
||||
"field_webhook_username": "User Name (optional)",
|
||||
"field_webhook_password": "Password (optional)",
|
||||
"field_webhook_bearer_token": "Bearer token (optional)",
|
||||
"field_webhook_username": "Username",
|
||||
"field_webhook_password": "Password",
|
||||
"field_webhook_bearer_token": "Bearer token",
|
||||
"field_webhook_auth": "Authentication",
|
||||
"option_webhook_auth_none": "None",
|
||||
"option_webhook_auth_basic": "Basic auth",
|
||||
"option_webhook_auth_bearer": "Bearer token",
|
||||
"webhook_basic_auth_required": "Enter a username or password",
|
||||
"webhook_bearer_token_required": "Enter a bearer token",
|
||||
"field_pager_routing_key": "Routing Key",
|
||||
"field_pager_description": "Description",
|
||||
"field_pager_severity": "Severity",
|
||||
@@ -168,9 +175,7 @@
|
||||
"help_pager_group": "A cluster or grouping of sources",
|
||||
"help_pager_component": "The part or component of the affected system that is broke",
|
||||
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
|
||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||
"help_webhook_password": "Specify a password or bearer token",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header.",
|
||||
"help_pager_description": "Shows up as description in pagerduty",
|
||||
"help_opsgenie_message": "Shows up as message in opsgenie",
|
||||
"help_opsgenie_priority": "Priority of the incident",
|
||||
@@ -178,7 +183,6 @@
|
||||
"channel_creation_done": "Successfully created the channel",
|
||||
"channel_edit_done": "Channels Edited Successfully",
|
||||
"selected_channel_invalid": "Channel type selected is invalid",
|
||||
"username_no_password": "A Password must be provided with user name",
|
||||
"channel_test_done": "An alert has been sent to this channel",
|
||||
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
|
||||
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
|
||||
|
||||
import { toChannelConfig, toPostableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
|
||||
const slackValues: ChannelFormValues = {
|
||||
name: 'prod alerts',
|
||||
@@ -56,6 +56,22 @@ describe('toChannelConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[WebhookAuth.none, {}],
|
||||
[WebhookAuth.basic, { username: 'u', password: 'p' }],
|
||||
[WebhookAuth.bearer, { bearerToken: 't' }],
|
||||
])('sends only the %s webhook credentials', (webhookAuth, credentials) => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.webhook, {
|
||||
url: 'https://example.com/hook',
|
||||
webhookAuth,
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
bearerToken: 't',
|
||||
}).spec,
|
||||
).toStrictEqual({ url: 'https://example.com/hook', ...credentials });
|
||||
});
|
||||
|
||||
it('drops an attachment list the user emptied instead of sending []', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.slack, {
|
||||
|
||||
@@ -18,8 +18,17 @@ import {
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { omitBlank } from 'utils/omitBlank';
|
||||
|
||||
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
|
||||
import { canonicalJiraReopenDuration, filledRows } from './utils';
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelKind,
|
||||
ChannelSpecFormValues,
|
||||
WebhookAuth,
|
||||
} from './types';
|
||||
import {
|
||||
canonicalJiraReopenDuration,
|
||||
filledRows,
|
||||
webhookAuthOf,
|
||||
} from './utils';
|
||||
|
||||
const SPEC_FIELDS = {
|
||||
[ChannelKind.slack]: [
|
||||
@@ -210,6 +219,17 @@ export function toChannelConfig(
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === ChannelKind.webhook) {
|
||||
const auth = webhookAuthOf(values);
|
||||
if (auth !== WebhookAuth.basic) {
|
||||
delete picked.username;
|
||||
delete picked.password;
|
||||
}
|
||||
if (auth !== WebhookAuth.bearer) {
|
||||
delete picked.bearerToken;
|
||||
}
|
||||
}
|
||||
|
||||
const spec: Record<string, unknown> = omitBlank(picked);
|
||||
|
||||
// the API answers a read with the duration prometheus would print, and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
import { webhookAuthOf } from './utils';
|
||||
|
||||
export interface ChannelFormState {
|
||||
kind: ChannelKind;
|
||||
@@ -22,11 +23,15 @@ function toChannelKind(kind: string): ChannelKind {
|
||||
export function toChannelFormState(
|
||||
channel: AlertmanagertypesGettableNotificationChannelDTO,
|
||||
): ChannelFormState {
|
||||
return {
|
||||
kind: toChannelKind(channel.config.kind),
|
||||
values: {
|
||||
...channel.config.spec,
|
||||
name: channel.displayName,
|
||||
},
|
||||
const kind = toChannelKind(channel.config.kind);
|
||||
const values: ChannelFormValues = {
|
||||
...channel.config.spec,
|
||||
name: channel.displayName,
|
||||
};
|
||||
|
||||
if (kind === ChannelKind.webhook) {
|
||||
values.webhookAuth = webhookAuthOf(values);
|
||||
}
|
||||
|
||||
return { kind, values };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import { get, set } from 'lodash-es';
|
||||
import type { FieldErrors, Resolver } from 'react-hook-form';
|
||||
import type { $ZodIssue } from 'zod/v4/core';
|
||||
|
||||
@@ -10,12 +11,12 @@ const toFieldErrors = (
|
||||
issues: $ZodIssue[],
|
||||
t: TFunction,
|
||||
): FieldErrors<ChannelFormValues> => {
|
||||
const errors: Record<string, { type: string; message: string }> = {};
|
||||
const errors = {};
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const field = issue.path.join('.') || 'name';
|
||||
if (!errors[field]) {
|
||||
errors[field] = { type: 'validation', message: t(issue.message) };
|
||||
const path = issue.path.length ? issue.path : ['name'];
|
||||
if (!get(errors, path)) {
|
||||
set(errors, path, { type: 'validation', message: t(issue.message) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
url: 'https://example.com/hook',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
bearerToken: 't',
|
||||
sendResolved: true,
|
||||
},
|
||||
email: {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidGoogleChatWebhookURL } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const googleChatSchema = z.object({
|
||||
webhookUrl: requiredString('webhook_url_required').refine(
|
||||
isValidGoogleChatWebhookURL,
|
||||
{ message: 'google_chat_webhook_url_invalid' },
|
||||
),
|
||||
webhookUrl: requiredString('webhook_url_required'),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidIncidentIOURL } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
const required = requiredString('incidentio_required_fields');
|
||||
|
||||
export const incidentIOSchema = z.object({
|
||||
url: required.refine(isValidIncidentIOURL, {
|
||||
message: 'incidentio_url_invalid',
|
||||
}),
|
||||
token: required,
|
||||
url: required,
|
||||
token: z
|
||||
.string({ error: 'incidentio_required_fields' })
|
||||
.refine((token) => token === token.trim(), {
|
||||
message: 'incidentio_token_whitespace',
|
||||
})
|
||||
.pipe(
|
||||
required.refine((token) => !/^bearer( |$)/i.test(token), {
|
||||
message: 'incidentio_token_bearer_prefix',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
|
||||
import { isValidJiraReopenDuration } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
const required = requiredString('jira_required_fields');
|
||||
|
||||
export const jiraSchema = z.object({
|
||||
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
|
||||
site: required,
|
||||
email: required,
|
||||
apiToken: required,
|
||||
project: required,
|
||||
|
||||
@@ -2,6 +2,30 @@ import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
// rows with a blank title or text are dropped on save, so only the rest are checked
|
||||
const fieldRow = z
|
||||
.object({ title: z.string().optional(), value: z.string().optional() })
|
||||
.refine((row) => !row.title?.trim() || !!row.value?.trim(), {
|
||||
message: 'slack_field_value_required',
|
||||
path: ['value'],
|
||||
});
|
||||
|
||||
const actionRow = z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(row) => !row.text?.trim() || !!row.url?.trim() || !!row.name?.trim(),
|
||||
{
|
||||
message: 'slack_action_url_or_name_required',
|
||||
path: ['url'],
|
||||
},
|
||||
);
|
||||
|
||||
export const slackSchema = z.object({
|
||||
apiUrl: requiredString('webhook_url_required'),
|
||||
fields: z.array(fieldRow).nullish(),
|
||||
actions: z.array(actionRow).nullish(),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WebhookAuth } from '../types';
|
||||
import { webhookAuthOf } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const webhookSchema = z
|
||||
.object({
|
||||
url: requiredString('webhook_url_required'),
|
||||
webhookAuth: z.enum(WebhookAuth).optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
bearerToken: z.string().optional(),
|
||||
})
|
||||
// the API allows bearer-only and no-auth webhooks, but a username without its
|
||||
// password is still an incomplete basic auth pair
|
||||
.refine((v) => !v.username || !!v.password, {
|
||||
message: 'username_no_password',
|
||||
path: ['password'],
|
||||
});
|
||||
.refine(
|
||||
(values) =>
|
||||
webhookAuthOf(values) !== WebhookAuth.basic ||
|
||||
!!values.username?.trim() ||
|
||||
!!values.password?.trim(),
|
||||
{ message: 'webhook_basic_auth_required', path: ['username'] },
|
||||
)
|
||||
.refine(
|
||||
(values) =>
|
||||
webhookAuthOf(values) !== WebhookAuth.bearer || !!values.bearerToken?.trim(),
|
||||
{ message: 'webhook_bearer_token_required', path: ['bearerToken'] },
|
||||
);
|
||||
|
||||
@@ -31,6 +31,15 @@ export type ChannelSpecFormValues = Partial<
|
||||
AlertmanagertypesChannelTelegramConfigDTO
|
||||
>;
|
||||
|
||||
export const WebhookAuth = {
|
||||
none: 'none',
|
||||
basic: 'basic',
|
||||
bearer: 'bearer',
|
||||
} as const;
|
||||
export type WebhookAuth = (typeof WebhookAuth)[keyof typeof WebhookAuth];
|
||||
|
||||
export type ChannelFormValues = ChannelSpecFormValues & {
|
||||
name?: string;
|
||||
/** Never sent: the API reads the mode from which credentials are set. */
|
||||
webhookAuth?: WebhookAuth;
|
||||
};
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
|
||||
|
||||
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
|
||||
|
||||
export const isValidJiraSiteURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
import { ChannelFormValues, WebhookAuth } from './types';
|
||||
|
||||
// mirrors go's prometheus model.Duration units
|
||||
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
|
||||
@@ -94,21 +69,21 @@ export const canonicalJiraReopenDuration = (value: string): string => {
|
||||
}, '');
|
||||
};
|
||||
|
||||
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
export const isValidIncidentIOURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, pathname } = new URL(url);
|
||||
const idx = pathname.indexOf(INCIDENTIO_EVENTS_PATH_PREFIX);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
idx !== -1 &&
|
||||
pathname.length > idx + INCIDENTIO_EVENTS_PATH_PREFIX.length
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
export const webhookAuthOf = (
|
||||
values: Pick<
|
||||
ChannelFormValues,
|
||||
'webhookAuth' | 'username' | 'password' | 'bearerToken'
|
||||
>,
|
||||
): WebhookAuth => {
|
||||
if (values.webhookAuth) {
|
||||
return values.webhookAuth;
|
||||
}
|
||||
if (values.bearerToken) {
|
||||
return WebhookAuth.bearer;
|
||||
}
|
||||
return values.username || values.password
|
||||
? WebhookAuth.basic
|
||||
: WebhookAuth.none;
|
||||
};
|
||||
|
||||
export const filledRows = <T>(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
import { validateChannel } from './validation';
|
||||
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
|
||||
describe('validateChannel', () => {
|
||||
it.each<[string, ChannelKind, ChannelFormValues, string | null]>([
|
||||
[
|
||||
'a google chat webhook behind a proxy',
|
||||
ChannelKind.googlechat,
|
||||
{ webhookUrl: 'http://relay.internal/chat' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'a jira site that is not on atlassian.net',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
site: 'https://jira-proxy.acme.com/',
|
||||
email: 'a@acme.com',
|
||||
apiToken: 't',
|
||||
project: 'OPS',
|
||||
issueType: 'Bug',
|
||||
},
|
||||
null,
|
||||
],
|
||||
[
|
||||
'any incident.io url',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'tok' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'an incident.io token pasted with its Bearer prefix',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'Bearer tok' },
|
||||
'incidentio_token_bearer_prefix',
|
||||
],
|
||||
[
|
||||
'an incident.io token with a trailing space',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'tok ' },
|
||||
'incidentio_token_whitespace',
|
||||
],
|
||||
[
|
||||
'a webhook username without a password',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.basic, username: 'u' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'basic auth with neither username nor password',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.basic },
|
||||
'webhook_basic_auth_required',
|
||||
],
|
||||
[
|
||||
'bearer auth without a token',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.bearer, username: 'u' },
|
||||
'webhook_bearer_token_required',
|
||||
],
|
||||
[
|
||||
'a slack field without a value',
|
||||
ChannelKind.slack,
|
||||
{ apiUrl: 'https://hooks', fields: [{ title: 'env', value: '' }] },
|
||||
'slack_field_value_required',
|
||||
],
|
||||
[
|
||||
'a slack button with neither url nor name',
|
||||
ChannelKind.slack,
|
||||
{
|
||||
apiUrl: 'https://hooks',
|
||||
actions: [{ type: 'button', text: 'Runbook', url: '' }],
|
||||
},
|
||||
'slack_action_url_or_name_required',
|
||||
],
|
||||
[
|
||||
'a slack button with a name only',
|
||||
ChannelKind.slack,
|
||||
{
|
||||
apiUrl: 'https://hooks',
|
||||
actions: [{ type: 'button', text: 'Ack', name: 'ack' }],
|
||||
},
|
||||
null,
|
||||
],
|
||||
])('%s', (_, kind, values, expected) => {
|
||||
expect(validateChannel(kind, { name: 'channel', ...values }, t)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,11 @@ const COLUMNS = [
|
||||
|
||||
function SlackActions(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
|
||||
|
||||
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
|
||||
@@ -51,6 +55,9 @@ function SlackActions(): JSX.Element {
|
||||
key={column}
|
||||
label={t(`label_slack_action_${keyFor(column)}`)}
|
||||
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
|
||||
error={
|
||||
column === 'url' ? errors.actions?.[index]?.url?.message : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...register(`actions.${index}.${column}` as const)}
|
||||
|
||||
@@ -14,7 +14,11 @@ import styles from './RowEditor.module.scss';
|
||||
// Slack renders these as the attachment's table of short or full-width entries.
|
||||
function SlackFields(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
|
||||
|
||||
return (
|
||||
@@ -46,6 +50,7 @@ function SlackFields(): JSX.Element {
|
||||
<FormField
|
||||
label={t('label_slack_field_value')}
|
||||
tooltip={t('tooltip_slack_field_value')}
|
||||
error={errors.fields?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.value`)}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.auth {
|
||||
--radio-group-display: flex;
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import {
|
||||
ChannelFormValues,
|
||||
WebhookAuth,
|
||||
} from '../../CreateAlertChannels/types';
|
||||
import { webhookAuthOf } from '../../CreateAlertChannels/utils';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
|
||||
import styles from './Webhook.module.scss';
|
||||
|
||||
function WebhookSettings(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
} = useFormContext<ChannelFormValues>();
|
||||
const [webhookAuth, username, password, bearerToken] = useWatch({
|
||||
control,
|
||||
name: ['webhookAuth', 'username', 'password', 'bearerToken'],
|
||||
});
|
||||
const auth = webhookAuthOf({ webhookAuth, username, password, bearerToken });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,35 +41,75 @@ function WebhookSettings(): JSX.Element {
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_webhook_username')}
|
||||
help={t('help_webhook_username')}
|
||||
error={errors.username?.message}
|
||||
>
|
||||
<Input {...register('username')} data-testid="webhook-username-textbox" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Password (optional)"
|
||||
help={t('help_webhook_password')}
|
||||
error={errors.password?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
error={errors.bearerToken?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('bearerToken')}
|
||||
type="password"
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
<FormField label={t('field_webhook_auth')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="webhookAuth"
|
||||
render={({ field }): JSX.Element => (
|
||||
<div className={styles.auth}>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
aria-label={t('field_webhook_auth')}
|
||||
value={auth}
|
||||
onChange={field.onChange}
|
||||
testId="webhook-auth-radio"
|
||||
items={[
|
||||
{
|
||||
value: WebhookAuth.none,
|
||||
label: t('option_webhook_auth_none'),
|
||||
},
|
||||
{
|
||||
value: WebhookAuth.basic,
|
||||
label: t('option_webhook_auth_basic'),
|
||||
},
|
||||
{
|
||||
value: WebhookAuth.bearer,
|
||||
label: t('option_webhook_auth_bearer'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
{auth === WebhookAuth.basic && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('field_webhook_username')}
|
||||
error={errors.username?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('username')}
|
||||
data-testid="webhook-username-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_webhook_password')}
|
||||
error={errors.password?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{auth === WebhookAuth.bearer && (
|
||||
<FormField
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
error={errors.bearerToken?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('bearerToken')}
|
||||
type="password"
|
||||
aria-required
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</FormCard>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
notificationChannelResponse,
|
||||
type ChannelActionOutcome,
|
||||
type ChannelType,
|
||||
WEBHOOK_AUTHS,
|
||||
type WebhookAuthChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
@@ -38,6 +40,13 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
options: CHANNEL_TYPES,
|
||||
value: 'slack',
|
||||
}),
|
||||
webhookAuth: choiceControl<WebhookAuthChoice>('Webhook credentials', {
|
||||
group: CHANNEL,
|
||||
description:
|
||||
'What the saved webhook authenticates with, which picks the auth option the form opens on. Only read when the channel type is webhook.',
|
||||
options: WEBHOOK_AUTHS,
|
||||
value: 'basic',
|
||||
}),
|
||||
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
@@ -53,7 +62,11 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
rest.get(
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
response.json((req) =>
|
||||
notificationChannelResponse(String(req.params.id), values.channelType),
|
||||
notificationChannelResponse(
|
||||
String(req.params.id),
|
||||
values.channelType,
|
||||
values.webhookAuth,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -117,6 +117,16 @@ export const Webhook: Story = {
|
||||
args: { channelType: 'webhook' },
|
||||
};
|
||||
|
||||
/** The webhook channel saved with a bearer token: only the token field shows. */
|
||||
export const WebhookBearerToken: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'bearer' },
|
||||
};
|
||||
|
||||
/** The webhook channel saved without credentials: the auth option is None. */
|
||||
export const WebhookWithoutAuth: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'none' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Opsgenie channel, whose form carries the integration API key and the
|
||||
* priority the alert is raised at.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -42,6 +42,24 @@ const selectChannelType = async (
|
||||
await within(trigger).findByText(label);
|
||||
};
|
||||
|
||||
const fillName = async (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await within(canvasElement).findByTestId('channel-name-textbox'),
|
||||
name,
|
||||
);
|
||||
};
|
||||
|
||||
const sendTest = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const test = within(canvasElement).getByTestId('test-channel-button');
|
||||
|
||||
await waitFor(() => expect(test).toHaveAttribute('aria-disabled', 'false'));
|
||||
await userEvent.click(test);
|
||||
await screen.findByText('An alert has been sent to this channel');
|
||||
};
|
||||
|
||||
/** A new notification channel, on the Slack form the page opens with. */
|
||||
export const Default: Story = {};
|
||||
|
||||
@@ -123,6 +141,79 @@ export const Webhook: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic auth picked on the webhook form: the username and password show, and
|
||||
* saving with both empty asks for one of them.
|
||||
*/
|
||||
export const WebhookBasicAuth: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await selectChannelType(canvasElement, /^Webhook$/);
|
||||
await fillName(canvasElement, 'alerts-webhook');
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('webhook-url-textbox'),
|
||||
'https://example.com/hook',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-basic'));
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await canvas.findByText('Enter a username or password');
|
||||
await expect(
|
||||
canvas.queryByTestId('webhook-bearer-token-textbox'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Bearer auth picked on the webhook form: only the token shows, and saving
|
||||
* without one asks for it.
|
||||
*/
|
||||
export const WebhookBearerAuth: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await selectChannelType(canvasElement, /^Webhook$/);
|
||||
await fillName(canvasElement, 'alerts-webhook');
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('webhook-url-textbox'),
|
||||
'https://example.com/hook',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-bearer'));
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await canvas.findByText('Enter a bearer token');
|
||||
await expect(
|
||||
canvas.queryByTestId('webhook-username-textbox'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Slack field with a title but no value, and a button with text but neither
|
||||
* a URL nor a name. Each is flagged under its row rather than sent for the API
|
||||
* to reject.
|
||||
*/
|
||||
export const SlackRowErrors: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await fillName(canvasElement, 'alerts-slack');
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('webhook-url-textbox'),
|
||||
'https://hooks.slack.com/services/T/B/X',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('slack-field-add'));
|
||||
await userEvent.type(canvas.getByTestId('slack-field-title-0'), 'Region');
|
||||
await userEvent.click(canvas.getByTestId('slack-action-add'));
|
||||
await userEvent.type(canvas.getByTestId('slack-action-text-0'), 'Dashboard');
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await canvas.findByText('Enter a value for this field');
|
||||
await canvas.findByText('Enter a URL or a name for this button');
|
||||
},
|
||||
};
|
||||
|
||||
/** The PagerDuty form: routing key, severity and the incident details. */
|
||||
export const PagerDuty: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
@@ -151,16 +242,30 @@ export const MicrosoftTeams: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
|
||||
* on `chat.googleapis.com`.
|
||||
*/
|
||||
/** The Google Chat form: the space's incoming webhook and the message text. */
|
||||
export const GoogleChat: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Google Chat$/);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Google Chat form pointed at a relay rather than `chat.googleapis.com`.
|
||||
* The API accepts any webhook URL, so the test send goes through.
|
||||
*/
|
||||
export const GoogleChatRelayURL: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Google Chat$/);
|
||||
await fillName(canvasElement, 'alerts-google-chat');
|
||||
await userEvent.type(
|
||||
within(canvasElement).getByTestId('webhook-url-textbox'),
|
||||
'http://chat-relay.internal/v1/spaces/OPS/messages',
|
||||
);
|
||||
|
||||
await sendTest(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira form: where the issue is filed, the transitions that close and
|
||||
* reopen it, and the Atlassian account the API token belongs to.
|
||||
@@ -171,6 +276,29 @@ export const Jira: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira form pointed at a proxy rather than an `atlassian.net` site. The API
|
||||
* accepts any site URL, so the test send goes through.
|
||||
*/
|
||||
export const JiraProxySite: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await selectChannelType(canvasElement, /^Jira$/);
|
||||
await fillName(canvasElement, 'alerts-jira');
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('jira-site-textbox'),
|
||||
'https://jira-proxy.acme.internal',
|
||||
);
|
||||
await userEvent.type(canvas.getByTestId('jira-email-textbox'), 'ops@acme.io');
|
||||
await userEvent.type(canvas.getByTestId('jira-api-token-textbox'), 'token');
|
||||
await userEvent.type(canvas.getByTestId('jira-project-textbox'), 'OPS');
|
||||
await userEvent.type(canvas.getByTestId('jira-issue-type-textbox'), 'Bug');
|
||||
|
||||
await sendTest(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The Jira Service Management Ops form: the API key, priority and tags. */
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
@@ -185,6 +313,31 @@ export const IncidentIO: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The incident.io form pointed at a relay, which the API accepts, with the token
|
||||
* pasted as the setup page shows it. The `Bearer ` prefix is flagged, since it
|
||||
* would be sent twice.
|
||||
*/
|
||||
export const IncidentIOBearerToken: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await selectChannelType(canvasElement, /^incident\.io$/);
|
||||
await fillName(canvasElement, 'alerts-incidentio');
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('incidentio-url-textbox'),
|
||||
'https://incidentio-relay.internal/alerts',
|
||||
);
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('incidentio-token-textbox'),
|
||||
'Bearer story-token',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await canvas.findByText('Paste the token only, without the Bearer prefix');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Telegram form: the bot token, the chat it posts to and the message. */
|
||||
export const Telegram: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -45,15 +45,18 @@ const CASES: PrefillCase[] = [
|
||||
url: 'https://example.com/hook',
|
||||
username: 'hook-user',
|
||||
password: 'hook-pass',
|
||||
bearerToken: 'hook-token',
|
||||
},
|
||||
fields: [
|
||||
['webhook-url-textbox', 'https://example.com/hook'],
|
||||
['webhook-username-textbox', 'hook-user'],
|
||||
['webhook-password-textbox', 'hook-pass'],
|
||||
['webhook-bearer-token-textbox', 'hook-token'],
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook', bearerToken: 'hook-token' },
|
||||
fields: [['webhook-bearer-token-textbox', 'hook-token']],
|
||||
},
|
||||
{
|
||||
kind: 'email',
|
||||
spec: { to: 'oncall@signoz.io', html: '<p>alert</p>' },
|
||||
|
||||
@@ -118,6 +118,104 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'sends only the credentials of the picked webhook auth',
|
||||
async () => {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChannelForm onDone={jest.fn()} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
|
||||
await user.click(await screen.findByRole('option', { name: /^Webhook$/ }));
|
||||
|
||||
await user.type(screen.getByTestId('channel-name-textbox'), 'hook alerts');
|
||||
await user.type(
|
||||
screen.getByTestId('webhook-url-textbox'),
|
||||
'https://example.com/hook',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('webhook-auth-radio-item-basic'));
|
||||
await user.type(screen.getByTestId('webhook-username-textbox'), 'hook-user');
|
||||
|
||||
await user.click(screen.getByTestId('webhook-auth-radio-item-bearer'));
|
||||
expect(
|
||||
screen.queryByTestId('webhook-username-textbox'),
|
||||
).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByTestId('webhook-bearer-token-textbox'),
|
||||
'hook-token',
|
||||
);
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(body).toBeDefined(), FIND);
|
||||
expect(
|
||||
(body as { config: { spec: Record<string, unknown> } }).config.spec,
|
||||
).toStrictEqual({
|
||||
url: 'https://example.com/hook',
|
||||
bearerToken: 'hook-token',
|
||||
sendResolved: true,
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'flags a slack field without a value instead of sending it',
|
||||
async () => {
|
||||
const onCreate = jest.fn();
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
(_, res, ctx) => {
|
||||
onCreate();
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChannelForm onDone={jest.fn()} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.type(
|
||||
await screen.findByTestId('channel-name-textbox', {}, FIND),
|
||||
'slack alerts',
|
||||
);
|
||||
await user.type(
|
||||
screen.getByTestId('webhook-url-textbox'),
|
||||
'https://hooks.slack.com/services/T/B/X',
|
||||
);
|
||||
await user.click(screen.getByTestId('slack-field-add'));
|
||||
await user.type(screen.getByTestId('slack-field-title-0'), 'env');
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
await user.click(save);
|
||||
|
||||
await screen.findByText('slack_field_value_required', {}, FIND);
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'updates with the config only, since both names are immutable',
|
||||
async () => {
|
||||
@@ -165,6 +263,74 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'keeps bearer auth while a saved token is cleared and replaced',
|
||||
async () => {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: '3',
|
||||
name: 'webhook-channel',
|
||||
displayName: 'webhook channel',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
config: {
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook', bearerToken: 'old-token' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.put(
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const onDone = jest.fn();
|
||||
render(<ChannelForm channelId="3" onDone={onDone} />);
|
||||
|
||||
const token = await screen.findByTestId(
|
||||
'webhook-bearer-token-textbox',
|
||||
{},
|
||||
FIND,
|
||||
);
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.clear(token);
|
||||
await user.type(
|
||||
screen.getByTestId('webhook-bearer-token-textbox'),
|
||||
'new-token',
|
||||
);
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
|
||||
expect(body).toStrictEqual({
|
||||
config: {
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook', bearerToken: 'new-token' },
|
||||
},
|
||||
});
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'opens read-only, with saving disabled rather than hidden, when update is denied',
|
||||
async () => {
|
||||
|
||||
@@ -781,6 +781,22 @@ export const channelNames = (count: number): string[] =>
|
||||
(_unused, index) => CHANNEL_SEEDS[index % CHANNEL_SEEDS.length].name,
|
||||
);
|
||||
|
||||
export const WEBHOOK_AUTHS = ['basic', 'bearer', 'none'] as const;
|
||||
|
||||
export type WebhookAuthChoice = (typeof WEBHOOK_AUTHS)[number];
|
||||
|
||||
const WEBHOOK_CREDENTIALS: Record<WebhookAuthChoice, Record<string, string>> = {
|
||||
basic: { username: 'story-user' },
|
||||
bearer: { bearerToken: 'story-bearer-token' },
|
||||
none: {},
|
||||
};
|
||||
|
||||
const webhookSpec = (auth: WebhookAuthChoice): Record<string, unknown> => ({
|
||||
url: 'https://example.com/story-hook',
|
||||
...WEBHOOK_CREDENTIALS[auth],
|
||||
sendResolved: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The v2 spec each kind is fetched with, so the edit form renders populated
|
||||
* fields. Keys mirror the generated channel spec schemas.
|
||||
@@ -809,11 +825,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
],
|
||||
sendResolved: true,
|
||||
},
|
||||
webhook: {
|
||||
url: 'https://example.com/story-hook',
|
||||
username: 'story-user',
|
||||
sendResolved: true,
|
||||
},
|
||||
webhook: webhookSpec('basic'),
|
||||
pagerduty: {
|
||||
routingKey: 'story-routing-key',
|
||||
description: '{{ .CommonLabels.alertname }}',
|
||||
@@ -877,6 +889,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
export const notificationChannelResponse = (
|
||||
id: string,
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice = 'basic',
|
||||
): {
|
||||
status: string;
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
@@ -897,7 +910,7 @@ export const notificationChannelResponse = (
|
||||
updatedAt: ago((index + 1) * DAY),
|
||||
config: {
|
||||
kind: type,
|
||||
spec: CHANNEL_SPECS[type],
|
||||
spec: type === 'webhook' ? webhookSpec(webhookAuth) : CHANNEL_SPECS[type],
|
||||
} as unknown as AlertmanagertypesChannelConfigDTO,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user