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:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user