chore: enable Gotify and Custom Webhhok notifications and improve payload validation

- Enhanced Notifications component tests to include support for Discord, Gotify, and Webhook provider types.
- Updated test cases to validate the correct handling of provider type options and ensure proper payload structure during creation, preview, and testing.
- Introduced new tests for Gotify token handling and ensured sensitive information is not exposed in the UI.
- Refactored existing tests for clarity and maintainability, including improved assertions and error handling.
- Added comprehensive coverage for payload validation scenarios, including malformed requests and security checks against SSRF and oversized payloads.
This commit is contained in:
GitHub Actions
2026-02-24 05:31:10 +00:00
parent 1329b00ed5
commit bc9f2cf882
31 changed files with 2412 additions and 1112 deletions
@@ -52,9 +52,9 @@ describe('notifications api', () => {
await testProvider({ id: '2', name: 'test', type: 'discord' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'discord' })
await expect(createProvider({ name: 'x', type: 'slack' })).rejects.toThrow('Only discord notification providers are supported')
await expect(updateProvider('2', { name: 'updated', type: 'generic' })).rejects.toThrow('Only discord notification providers are supported')
await expect(testProvider({ id: '2', name: 'test', type: 'telegram' })).rejects.toThrow('Only discord notification providers are supported')
await expect(createProvider({ name: 'x', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
await expect(updateProvider('2', { name: 'updated', type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
await expect(testProvider({ id: '2', name: 'test', type: 'telegram' })).rejects.toThrow('Unsupported notification provider type: telegram')
})
it('templates and previews use merged payloads', async () => {
@@ -68,7 +68,10 @@ describe('notifications api', () => {
expect(preview).toEqual({ preview: 'ok' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'discord', data: { user: 'alice' } })
await expect(previewProvider({ name: 'provider', type: 'webhook' }, { user: 'alice' })).rejects.toThrow('Only discord notification providers are supported')
vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'webhook-ok' } })
const webhookPreview = await previewProvider({ name: 'provider', type: 'webhook' }, { user: 'alice' })
expect(webhookPreview).toEqual({ preview: 'webhook-ok' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'webhook', data: { user: 'alice' } })
})
it('external template endpoints shape payloads', async () => {
+31 -7
View File
@@ -88,14 +88,38 @@ describe('notifications api', () => {
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/providers/new')
})
it('rejects non-discord type before submit for provider mutations and preview', async () => {
await expect(createProvider({ name: 'Bad', type: 'slack' })).rejects.toThrow('Only discord notification providers are supported')
await expect(updateProvider('bad', { type: 'generic' })).rejects.toThrow('Only discord notification providers are supported')
await expect(testProvider({ id: 'bad', type: 'email' })).rejects.toThrow('Only discord notification providers are supported')
await expect(previewProvider({ id: 'bad', type: 'gotify' })).rejects.toThrow('Only discord notification providers are supported')
it('supports discord, gotify, and webhook while enforcing token payload contract', async () => {
mockedClient.post.mockResolvedValue({ data: { id: 'ok' } })
mockedClient.put.mockResolvedValue({ data: { id: 'ok' } })
expect(mockedClient.post).not.toHaveBeenCalled()
expect(mockedClient.put).not.toHaveBeenCalled()
await createProvider({ name: 'Gotify', type: 'gotify', gotify_token: 'secret-token' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', {
name: 'Gotify',
type: 'gotify',
token: 'secret-token',
})
await updateProvider('ok', { type: 'webhook', url: 'https://example.com/webhook', gotify_token: 'should-not-send' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/ok', {
type: 'webhook',
url: 'https://example.com/webhook',
})
await testProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/test', {
id: 'ok',
type: 'gotify',
})
await previewProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', {
id: 'ok',
type: 'gotify',
})
await expect(createProvider({ name: 'Bad', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
await expect(updateProvider('bad', { type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
await expect(testProvider({ id: 'bad', type: 'email' })).rejects.toThrow('Unsupported notification provider type: email')
})
it('fetches templates and previews provider payloads with data', async () => {
+54 -18
View File
@@ -1,6 +1,24 @@
import client from './client';
const DISCORD_PROVIDER_TYPE = 'discord' as const;
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook'] as const;
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
const isSupportedNotificationProviderType = (type: string | undefined): type is SupportedNotificationProviderType =>
typeof type === 'string' && SUPPORTED_NOTIFICATION_PROVIDER_TYPES.includes(type.toLowerCase() as SupportedNotificationProviderType);
const resolveProviderTypeOrThrow = (type: string | undefined): SupportedNotificationProviderType => {
if (typeof type === 'undefined') {
return DEFAULT_PROVIDER_TYPE;
}
const normalizedType = type.toLowerCase();
if (isSupportedNotificationProviderType(normalizedType)) {
return normalizedType;
}
throw new Error(`Unsupported notification provider type: ${type}`);
};
/** Notification provider configuration. */
export interface NotificationProvider {
@@ -10,6 +28,8 @@ export interface NotificationProvider {
url: string;
config?: string;
template?: string;
gotify_token?: string;
token?: string;
enabled: boolean;
notify_proxy_hosts: boolean;
notify_remote_servers: boolean;
@@ -23,19 +43,39 @@ export interface NotificationProvider {
created_at: string;
}
const withDiscordType = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const normalizedType = typeof data.type === 'string' ? data.type.toLowerCase() : undefined;
if (normalizedType !== DISCORD_PROVIDER_TYPE) {
return { ...data, type: DISCORD_PROVIDER_TYPE };
const sanitizeProviderForWriteAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const type = resolveProviderTypeOrThrow(data.type);
const payload: Partial<NotificationProvider> = {
...data,
type,
};
const normalizedToken = typeof payload.gotify_token === 'string' && payload.gotify_token.trim().length > 0
? payload.gotify_token.trim()
: typeof payload.token === 'string' && payload.token.trim().length > 0
? payload.token.trim()
: undefined;
delete payload.gotify_token;
if (type !== 'gotify') {
delete payload.token;
return payload;
}
return { ...data, type: DISCORD_PROVIDER_TYPE };
if (normalizedToken) {
payload.token = normalizedToken;
} else {
delete payload.token;
}
return payload;
};
const assertDiscordOnlyInput = (data: Partial<NotificationProvider>): void => {
if (typeof data.type === 'string' && data.type.toLowerCase() !== DISCORD_PROVIDER_TYPE) {
throw new Error('Only discord notification providers are supported');
}
const sanitizeProviderForReadLikeAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const payload = sanitizeProviderForWriteAction(data);
delete payload.token;
return payload;
};
/**
@@ -55,8 +95,7 @@ export const getProviders = async () => {
* @throws {AxiosError} If creation fails
*/
export const createProvider = async (data: Partial<NotificationProvider>) => {
assertDiscordOnlyInput(data);
const response = await client.post<NotificationProvider>('/notifications/providers', withDiscordType(data));
const response = await client.post<NotificationProvider>('/notifications/providers', sanitizeProviderForWriteAction(data));
return response.data;
};
@@ -68,8 +107,7 @@ export const createProvider = async (data: Partial<NotificationProvider>) => {
* @throws {AxiosError} If update fails or provider not found
*/
export const updateProvider = async (id: string, data: Partial<NotificationProvider>) => {
assertDiscordOnlyInput(data);
const response = await client.put<NotificationProvider>(`/notifications/providers/${id}`, withDiscordType(data));
const response = await client.put<NotificationProvider>(`/notifications/providers/${id}`, sanitizeProviderForWriteAction(data));
return response.data;
};
@@ -88,8 +126,7 @@ export const deleteProvider = async (id: string) => {
* @throws {AxiosError} If test fails
*/
export const testProvider = async (provider: Partial<NotificationProvider>) => {
assertDiscordOnlyInput(provider);
await client.post('/notifications/providers/test', withDiscordType(provider));
await client.post('/notifications/providers/test', sanitizeProviderForReadLikeAction(provider));
};
/**
@@ -116,8 +153,7 @@ export interface NotificationTemplate {
* @throws {AxiosError} If preview fails
*/
export const previewProvider = async (provider: Partial<NotificationProvider>, data?: Record<string, unknown>) => {
assertDiscordOnlyInput(provider);
const payload: Record<string, unknown> = withDiscordType(provider) as Record<string, unknown>;
const payload: Record<string, unknown> = sanitizeProviderForReadLikeAction(provider) as Record<string, unknown>;
if (data) payload.data = data;
const response = await client.post('/notifications/providers/preview', payload);
return response.data;
@@ -78,14 +78,15 @@ describe('Security Notification Settings on Notifications page', () => {
expect(document.querySelector('.fixed.inset-0')).toBeNull();
});
it('keeps provider setup focused on the Discord webhook flow', async () => {
it('defaults to Discord webhook flow while exposing supported provider modes', async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByTestId('add-provider-btn'));
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement;
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord']);
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook']);
expect(typeSelect.value).toBe('discord');
const webhookInput = screen.getByTestId('provider-url') as HTMLInputElement;
expect(webhookInput.placeholder).toContain('discord.com/api/webhooks');
+3
View File
@@ -542,6 +542,9 @@
"providerName": "Name",
"urlWebhook": "URL / Webhook",
"urlRequired": "URL is required",
"gotifyToken": "Gotify Token",
"gotifyTokenPlaceholder": "Enter new token",
"gotifyTokenWriteOnlyHint": "Token is write-only and only sent on save.",
"invalidUrl": "Please enter a valid URL starting with http:// or https://",
"genericWebhook": "Generic Webhook",
"customWebhook": "Custom Webhook (JSON)",
+76 -30
View File
@@ -1,14 +1,22 @@
import { useEffect, useState, type FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, NotificationTemplate } from '../api/notifications';
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, NotificationTemplate, SUPPORTED_NOTIFICATION_PROVIDER_TYPES, type SupportedNotificationProviderType } from '../api/notifications';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from '../utils/toast';
const DISCORD_PROVIDER_TYPE = 'discord' as const;
const DISCORD_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
const isSupportedProviderType = (providerType: string | undefined): providerType is SupportedNotificationProviderType => {
if (!providerType) {
return false;
}
return SUPPORTED_NOTIFICATION_PROVIDER_TYPES.includes(providerType.toLowerCase() as SupportedNotificationProviderType);
};
// supportsJSONTemplates returns true if the provider type can use JSON templates
const supportsJSONTemplates = (providerType: string | undefined): boolean => {
@@ -16,26 +24,44 @@ const supportsJSONTemplates = (providerType: string | undefined): boolean => {
return providerType.toLowerCase() === DISCORD_PROVIDER_TYPE;
};
const isNonDiscordProvider = (providerType: string | undefined): boolean => {
if (!providerType) {
return false;
}
const isUnsupportedProviderType = (providerType: string | undefined): boolean => !isSupportedProviderType(providerType);
return providerType.toLowerCase() !== DISCORD_PROVIDER_TYPE;
};
const normalizeProviderType = (providerType: string | undefined): typeof DISCORD_PROVIDER_TYPE => {
if (!providerType || providerType.toLowerCase() !== DISCORD_PROVIDER_TYPE) {
const normalizeProviderType = (providerType: string | undefined): SupportedNotificationProviderType => {
if (!isSupportedProviderType(providerType)) {
return DISCORD_PROVIDER_TYPE;
}
return DISCORD_PROVIDER_TYPE;
return providerType.toLowerCase() as SupportedNotificationProviderType;
};
const normalizeProviderPayloadForSubmit = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const type = normalizeProviderType(data.type);
const payload: Partial<NotificationProvider> = {
...data,
type,
};
if (type === 'gotify') {
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
if (normalizedToken.length > 0) {
payload.token = normalizedToken;
} else {
delete payload.token;
}
} else {
delete payload.token;
}
delete payload.gotify_token;
return payload;
};
const defaultProviderValues: Partial<NotificationProvider> = {
type: DISCORD_PROVIDER_TYPE,
enabled: true,
config: '',
gotify_token: '',
template: 'minimal',
notify_proxy_hosts: true,
notify_remote_servers: true,
@@ -64,7 +90,7 @@ const ProviderForm: FC<{
useEffect(() => {
// Reset form state per open/edit to avoid event checkbox leakage between runs.
const normalizedInitialData = initialData
? { ...defaultProviderValues, ...initialData, type: normalizeProviderType(initialData.type) }
? { ...defaultProviderValues, ...initialData, type: normalizeProviderType(initialData.type), gotify_token: '' }
: defaultProviderValues;
reset(normalizedInitialData);
@@ -87,7 +113,7 @@ const ProviderForm: FC<{
const handleTest = () => {
const formData = watch();
testMutation.mutate({ ...formData, type: DISCORD_PROVIDER_TYPE } as Partial<NotificationProvider>);
testMutation.mutate({ ...formData, type: normalizeProviderType(formData.type) } as Partial<NotificationProvider>);
};
const handlePreview = async () => {
@@ -100,7 +126,7 @@ const ProviderForm: FC<{
const res = await previewExternalTemplate(formData.template, undefined, undefined);
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
} else {
const res = await previewProvider({ ...formData, type: DISCORD_PROVIDER_TYPE } as Partial<NotificationProvider>);
const res = await previewProvider({ ...formData, type: normalizeProviderType(formData.type) } as Partial<NotificationProvider>);
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
}
} catch (err: unknown) {
@@ -109,10 +135,11 @@ const ProviderForm: FC<{
}
};
const type = watch('type');
const type = normalizeProviderType(watch('type'));
const isGotify = type === 'gotify';
useEffect(() => {
if (type !== DISCORD_PROVIDER_TYPE) {
setValue('type', DISCORD_PROVIDER_TYPE, { shouldDirty: false, shouldTouch: false });
if (type !== 'gotify') {
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
}
}, [type, setValue]);
@@ -141,9 +168,9 @@ const ProviderForm: FC<{
};
return (
<form onSubmit={handleSubmit((data) => onSubmit({ ...data, type: DISCORD_PROVIDER_TYPE }))} className="space-y-4">
<form onSubmit={handleSubmit((data) => onSubmit(normalizeProviderPayloadForSubmit(data as Partial<NotificationProvider>)))} className="space-y-4">
<div>
<label htmlFor="provider-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.providerName')}</label>
<label htmlFor="provider-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.providerName')} <span aria-hidden="true">*</span></label>
<input
id="provider-name"
{...register('name', { required: t('errors.required') as string })}
@@ -155,20 +182,21 @@ const ProviderForm: FC<{
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('common.type')}</label>
<label htmlFor="provider-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('common.type')}</label>
<select
id="provider-type"
{...register('type')}
data-testid="provider-type"
disabled
aria-readonly="true"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
>
<option value="discord">Discord</option>
<option value="gotify">Gotify</option>
<option value="webhook">{t('notificationProviders.genericWebhook')}</option>
</select>
</div>
<div>
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.urlWebhook')}</label>
<label htmlFor="provider-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></label>
<input
id="provider-url"
{...register('url', {
@@ -176,7 +204,7 @@ const ProviderForm: FC<{
validate: validateUrl,
})}
data-testid="provider-url"
placeholder="https://discord.com/api/webhooks/..."
placeholder={type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm ${errors.url ? 'border-red-500' : ''}`}
aria-invalid={errors.url ? 'true' : 'false'}
aria-describedby={errors.url ? 'provider-url-error' : undefined}
@@ -188,6 +216,24 @@ const ProviderForm: FC<{
)}
</div>
{isGotify && (
<div>
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('notificationProviders.gotifyToken')}
</label>
<input
id="provider-gotify-token"
type="password"
autoComplete="new-password"
{...register('gotify_token')}
data-testid="provider-gotify-token"
placeholder={t('notificationProviders.gotifyTokenPlaceholder')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
/>
<p className="text-xs text-gray-500 mt-1">{t('notificationProviders.gotifyTokenWriteOnlyHint')}</p>
</div>
)}
{supportsJSONTemplates(type) && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('notificationProviders.jsonPayloadTemplate')}</label>
@@ -563,7 +609,7 @@ const Notifications: FC = () => {
<div className="grid gap-4">
{providers?.map((provider) => (
<Card key={provider.id} className="p-4" data-testid={`provider-row-${provider.id}`}>
{editingId === provider.id && !isNonDiscordProvider(provider.type) ? (
{editingId === provider.id && !isUnsupportedProviderType(provider.type) ? (
<ProviderForm
initialData={provider}
onClose={() => setEditingId(null)}
@@ -582,7 +628,7 @@ const Notifications: FC = () => {
{t('common.saved')}
</span>
)}
{isNonDiscordProvider(provider.type) && (
{isUnsupportedProviderType(provider.type) && (
<div className="mt-2 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span
@@ -616,18 +662,18 @@ const Notifications: FC = () => {
</div>
<div className="flex items-center gap-2">
{!isNonDiscordProvider(provider.type) && (
{!isUnsupportedProviderType(provider.type) && (
<Button
variant="secondary"
size="sm"
onClick={() => testMutation.mutate({ ...provider, type: DISCORD_PROVIDER_TYPE })}
onClick={() => testMutation.mutate({ ...provider, type: normalizeProviderType(provider.type) })}
isLoading={testMutation.isPending}
title={t('notificationProviders.sendTest')}
>
<Send className="w-4 h-4" />
</Button>
)}
{!isNonDiscordProvider(provider.type) && (
{!isUnsupportedProviderType(provider.type) && (
<Button variant="secondary" size="sm" onClick={() => setEditingId(provider.id)}>
<Edit2 className="w-4 h-4" />
</Button>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Notifications from '../Notifications'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
@@ -14,6 +14,7 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('../../api/notifications', () => ({
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook'],
getProviders: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
@@ -62,10 +63,13 @@ const setupMocks = (providers: NotificationProvider[] = []) => {
vi.mocked(notificationsApi.updateProvider).mockResolvedValue(baseProvider)
}
let user: ReturnType<typeof userEvent.setup>
describe('Notifications', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
user = userEvent.setup()
})
afterEach(() => {
@@ -73,7 +77,6 @@ describe('Notifications', () => {
})
it('rejects invalid protocol URLs', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
@@ -134,7 +137,7 @@ describe('Notifications', () => {
expect(payload.type).toBe('discord')
})
it('shows Discord as the only provider type option', async () => {
it('shows supported provider type options', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
@@ -143,21 +146,32 @@ describe('Notifications', () => {
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
const options = Array.from(typeSelect.options)
expect(options).toHaveLength(1)
expect(options[0].value).toBe('discord')
expect(typeSelect.disabled).toBe(true)
expect(options).toHaveLength(3)
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook'])
expect(typeSelect.disabled).toBe(false)
})
it('normalizes stale non-discord type to discord on submit', async () => {
it('associates provider type label with select control', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
const typeSelect = screen.getByTestId('provider-type')
expect(typeSelect).toHaveAttribute('id', 'provider-type')
expect(screen.getByLabelText('common.type')).toBe(typeSelect)
})
it('submits selected provider type without forcing discord', async () => {
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.selectOptions(screen.getByTestId('provider-type'), 'webhook')
await user.type(screen.getByTestId('provider-name'), 'Normalized Provider')
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
expect(typeSelect.value).toBe('discord')
expect(typeSelect.value).toBe('webhook')
await user.click(screen.getByTestId('provider-save-btn'))
@@ -166,7 +180,7 @@ describe('Notifications', () => {
})
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
expect(payload.type).toBe('discord')
expect(payload.type).toBe('webhook')
})
it('shows and hides the update indicator after save', async () => {
@@ -324,11 +338,53 @@ describe('Notifications', () => {
await user.click(await screen.findByTestId('add-provider-btn'))
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord'])
expect(typeSelect.value).toBe('discord')
expect(screen.getByTestId('provider-url')).toHaveAttribute('placeholder', 'https://discord.com/api/webhooks/...')
expect(screen.queryByRole('link')).toBeNull()
})
it('submits gotify token on create for gotify provider mode', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.selectOptions(screen.getByTestId('provider-type'), 'gotify')
await user.type(screen.getByTestId('provider-name'), 'Gotify Alerts')
await user.type(screen.getByTestId('provider-url'), 'https://gotify.example.com/message')
await user.type(screen.getByTestId('provider-gotify-token'), 'super-secret-token')
await user.click(screen.getByTestId('provider-save-btn'))
await waitFor(() => {
expect(notificationsApi.createProvider).toHaveBeenCalled()
})
const payload = vi.mocked(notificationsApi.createProvider).mock.calls[0][0]
expect(payload.type).toBe('gotify')
expect(payload.token).toBe('super-secret-token')
})
it('uses masked gotify token input and never pre-fills token on edit', async () => {
const gotifyProvider: NotificationProvider = {
...baseProvider,
id: 'provider-gotify',
type: 'gotify',
url: 'https://gotify.example.com/message',
}
setupMocks([gotifyProvider])
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
const row = await screen.findByTestId('provider-row-provider-gotify')
const buttons = within(row).getAllByRole('button')
await user.click(buttons[1])
const tokenInput = screen.getByTestId('provider-gotify-token') as HTMLInputElement
expect(tokenInput.type).toBe('password')
expect(tokenInput.value).toBe('')
})
it('renders external template action buttons and skips delete when confirm is cancelled', async () => {
const template = {
id: 'template-cancel',
@@ -425,7 +481,7 @@ describe('Notifications', () => {
})
})
it('treats empty legacy type as editable and enforces discord type in form', async () => {
it('treats empty legacy type as unsupported and keeps row read-only', async () => {
const emptyTypeProvider: NotificationProvider = {
...baseProvider,
id: 'provider-empty-type',
@@ -434,23 +490,12 @@ describe('Notifications', () => {
setupMocks([emptyTypeProvider])
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
const row = await screen.findByTestId('provider-row-provider-empty-type')
const buttons = within(row).getAllByRole('button')
expect(buttons).toHaveLength(3)
await user.click(buttons[1])
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
expect(typeSelect.value).toBe('discord')
fireEvent.change(typeSelect, { target: { value: 'slack' } })
await waitFor(() => {
expect(typeSelect.value).toBe('discord')
})
expect(buttons).toHaveLength(1)
expect(screen.getByTestId('provider-deprecated-status-provider-empty-type')).toHaveTextContent('notificationProviders.deprecatedReadOnly')
})
it('triggers row-level send test action with discord payload', async () => {