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

View File

@@ -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 () => {

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 () => {

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;