fix: enhance notifications and validation features

- Added URL validation for notification providers to ensure only valid http/https URLs are accepted.
- Implemented tests for URL validation scenarios in the Notifications component.
- Updated translations for error messages related to invalid URLs in multiple languages.
- Introduced new hooks for managing security headers and access lists in tests.
- Enhanced the ProviderForm component to reset state correctly when switching between add and edit modes.
- Improved user feedback with update indicators after saving changes to notification providers.
- Added mock implementations for new hooks in various test files to ensure consistent testing behavior.
This commit is contained in:
GitHub Actions
2026-02-10 22:01:45 +00:00
parent d29b8e9ce4
commit 2b2d907b0c
39 changed files with 2953 additions and 619 deletions
@@ -51,7 +51,15 @@ vi.mock('../../hooks/useSecurity', () => ({
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({
profiles: [],
data: [],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(() => ({
data: [],
isLoading: false,
error: null,
})),
@@ -31,6 +31,27 @@ vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
}))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(() => ({
data: [],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useDNSDetection', () => ({
useDetectDNSProvider: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
data: undefined,
reset: vi.fn(),
})),
}))
// stub global fetch for health endpoint
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
@@ -154,11 +154,10 @@ describe('SecurityNotificationSettingsModal', () => {
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
expect(screen.getByLabelText('Enable Notifications')).toBeChecked();
});
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
// Disable notifications
await user.click(enableSwitch);
+1
View File
@@ -467,6 +467,7 @@
"providerName": "Name",
"urlWebhook": "URL / Webhook",
"urlRequired": "URL ist erforderlich",
"invalidUrl": "Bitte geben Sie eine gültige URL ein, die mit http:// oder https:// beginnt",
"genericWebhook": "Generischer Webhook (Shoutrrr)",
"customWebhook": "Benutzerdefinierter Webhook (JSON)",
"shoutrrrHelp": "Für das Shoutrrr-Format siehe",
+1
View File
@@ -542,6 +542,7 @@
"providerName": "Name",
"urlWebhook": "URL / Webhook",
"urlRequired": "URL is required",
"invalidUrl": "Please enter a valid URL starting with http:// or https://",
"genericWebhook": "Generic Webhook (Shoutrrr)",
"customWebhook": "Custom Webhook (JSON)",
"shoutrrrHelp": "For Shoutrrr format, see",
+1
View File
@@ -467,6 +467,7 @@
"providerName": "Nombre",
"urlWebhook": "URL / Webhook",
"urlRequired": "URL es requerida",
"invalidUrl": "Ingrese una URL válida que comience con http:// o https://",
"genericWebhook": "Webhook Genérico (Shoutrrr)",
"customWebhook": "Webhook Personalizado (JSON)",
"shoutrrrHelp": "Para el formato Shoutrrr, ver",
+1
View File
@@ -467,6 +467,7 @@
"providerName": "Nom",
"urlWebhook": "URL / Webhook",
"urlRequired": "L'URL est requise",
"invalidUrl": "Veuillez entrer une URL valide commençant par http:// ou https://",
"genericWebhook": "Webhook Générique (Shoutrrr)",
"customWebhook": "Webhook Personnalisé (JSON)",
"shoutrrrHelp": "Pour le format Shoutrrr, voir",
+1
View File
@@ -467,6 +467,7 @@
"providerName": "名称",
"urlWebhook": "URL / Webhook",
"urlRequired": "URL是必填项",
"invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL",
"genericWebhook": "通用 Webhook (Shoutrrr)",
"customWebhook": "自定义 Webhook (JSON)",
"shoutrrrHelp": "有关 Shoutrrr 格式,请参阅",
+92 -24
View File
@@ -1,4 +1,4 @@
import { useState, type FC } from 'react';
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';
@@ -6,6 +6,7 @@ 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';
// supportsJSONTemplates returns true if the provider type can use JSON templates
const supportsJSONTemplates = (providerType: string | undefined): boolean => {
@@ -24,30 +25,40 @@ const supportsJSONTemplates = (providerType: string | undefined): boolean => {
}
};
const defaultProviderValues: Partial<NotificationProvider> = {
type: 'discord',
enabled: true,
config: '',
template: 'minimal',
notify_proxy_hosts: true,
notify_remote_servers: true,
notify_domains: true,
notify_certs: true,
notify_uptime: true,
};
const ProviderForm: FC<{
initialData?: Partial<NotificationProvider>;
onClose: () => void;
onSubmit: (data: Partial<NotificationProvider>) => void;
}> = ({ initialData, onClose, onSubmit }) => {
const { t } = useTranslation();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
defaultValues: initialData || {
type: 'discord',
enabled: true,
config: '',
template: 'minimal',
notify_proxy_hosts: true,
notify_remote_servers: true,
notify_domains: true,
notify_certs: true,
notify_uptime: true
}
const { register, handleSubmit, watch, setValue, reset, formState: { errors } } = useForm({
defaultValues: defaultProviderValues,
});
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
useEffect(() => {
// Reset form state per open/edit to avoid event checkbox leakage between runs.
reset(initialData ? { ...defaultProviderValues, ...initialData } : defaultProviderValues);
setTestStatus('idle');
setPreviewContent(null);
setPreviewError(null);
}, [initialData, reset]);
const testMutation = useMutation({
mutationFn: testProvider,
onSuccess: () => {
@@ -95,14 +106,30 @@ const ProviderForm: FC<{
setValue('config', templateStr);
};
// Client-side URL validation keeps the form open and prevents navigation on invalid input.
const validateUrl = (value: string | undefined) => {
if (!value) return true;
try {
const parsed = new URL(value);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return t('notificationProviders.invalidUrl');
}
return true;
} catch {
return t('notificationProviders.invalidUrl');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label 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')}</label>
<input
id="provider-name"
{...register('name', { required: t('errors.required') as string })}
data-testid="provider-name"
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"
aria-invalid={errors.name ? 'true' : 'false'}
/>
{errors.name && <span className="text-red-500 text-xs">{errors.name.message as string}</span>}
</div>
@@ -124,13 +151,24 @@ const ProviderForm: FC<{
</div>
<div>
<label 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')}</label>
<input
{...register('url', { required: t('notificationProviders.urlRequired') as string })}
id="provider-url"
{...register('url', {
required: t('notificationProviders.urlRequired') as string,
validate: validateUrl,
})}
data-testid="provider-url"
placeholder="https://discord.com/api/webhooks/..."
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"
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}
/>
{errors.url && (
<span id="provider-url-error" data-testid="provider-url-error" className="text-red-500 text-xs">
{errors.url.message as string}
</span>
)}
{!supportsJSONTemplates(type) && (
<p className="text-xs text-gray-500 mt-1">
{t('notificationProviders.shoutrrrHelp')} <a href="https://containrrr.dev/shoutrrr/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">{t('common.docs')}</a>.
@@ -323,13 +361,14 @@ const Notifications: FC = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const [managingTemplates, setManagingTemplates] = useState(false);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [updateIndicatorId, setUpdateIndicatorId] = useState<string | null>(null);
const { data: providers, isLoading } = useQuery({
queryKey: ['notificationProviders'],
queryFn: getProviders,
});
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
const { data: externalTemplates, isLoading: externalTemplatesLoading } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
const createMutation = useMutation({
mutationFn: createProvider,
@@ -341,12 +380,21 @@ const Notifications: FC = () => {
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<NotificationProvider> }) => updateProvider(id, data),
onSuccess: () => {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
setEditingId(null);
// Keep a deterministic update indicator for UI feedback and E2E stability.
setUpdateIndicatorId(variables.id);
toast.success(t('common.saved'));
},
});
useEffect(() => {
if (!updateIndicatorId) return;
const timer = window.setTimeout(() => setUpdateIndicatorId(null), 3000);
return () => window.clearTimeout(timer);
}, [updateIndicatorId]);
const deleteMutation = useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
@@ -431,19 +479,34 @@ const Notifications: FC = () => {
)}
{/* List of templates */}
<div className="grid gap-3">
<div className="grid gap-3" data-testid="external-templates-list">
{externalTemplatesLoading && (
<div className="text-sm text-gray-500" data-testid="external-templates-loading">
{t('common.loading')}
</div>
)}
{externalTemplates?.map((t_template: ExternalTemplate) => (
<Card key={t_template.id} className="p-4 flex justify-between items-start">
<Card
key={t_template.id}
className="p-4 flex justify-between items-start"
data-testid={`external-template-row-${t_template.id}`}
>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{t_template.name}</h4>
<p className="text-sm text-gray-500 mt-1">{t_template.description}</p>
<pre className="mt-2 text-xs font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded max-h-44 overflow-auto">{t_template.config}</pre>
</div>
<div className="flex flex-col gap-2 ml-4">
<Button size="sm" variant="secondary" onClick={() => setEditingTemplateId(t_template.id)}>
<Button size="sm" variant="secondary" onClick={() => setEditingTemplateId(t_template.id)} data-testid={`external-template-edit-${t_template.id}`}>
<Edit2 className="w-4 h-4" />
</Button>
<Button size="sm" variant="danger" onClick={() => { if (confirm(t('notificationProviders.deleteTemplateConfirm'))) deleteTemplateMutation.mutate(t_template.id); }}>
{/* Stable test hook for async template list rendering. */}
<Button
size="sm"
variant="danger"
data-testid={`external-template-delete-${t_template.id}`}
onClick={() => { if (confirm(t('notificationProviders.deleteTemplateConfirm'))) deleteTemplateMutation.mutate(t_template.id); }}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
@@ -468,7 +531,7 @@ const Notifications: FC = () => {
<div className="grid gap-4">
{providers?.map((provider) => (
<Card key={provider.id} className="p-4">
<Card key={provider.id} className="p-4" data-testid={`provider-row-${provider.id}`}>
{editingId === provider.id ? (
<ProviderForm
initialData={provider}
@@ -483,6 +546,11 @@ const Notifications: FC = () => {
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
{updateIndicatorId === provider.id && (
<span className="text-xs text-green-600" data-testid={`provider-update-indicator-${provider.id}`}>
{t('common.saved')}
</span>
)}
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span className="uppercase text-xs font-bold bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{provider.type}
@@ -9,6 +9,7 @@ import * as crowdsecApi from '../../api/crowdsec'
import * as presetsApi from '../../api/presets'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
import * as featureFlagsApi from '../../api/featureFlags'
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
import { renderWithQueryClient, createTestQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
@@ -19,6 +20,38 @@ vi.mock('../../api/crowdsec')
vi.mock('../../api/presets')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/featureFlags')
vi.mock('../../hooks/useConsoleEnrollment', () => ({
useConsoleStatus: vi.fn(() => ({
data: {
status: 'not_enrolled',
tenant: 'default',
agent_name: 'charon-agent',
last_error: null,
last_attempt_at: null,
enrolled_at: null,
last_heartbeat_at: null,
key_present: false,
correlation_id: 'corr-1',
},
isLoading: false,
isRefetching: false,
})),
useEnrollConsole: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({
status: 'enrolling',
key_present: false,
}),
isPending: false,
})),
useClearConsoleEnrollment: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
})),
}))
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
CrowdSecBouncerKeyDisplay: () => null,
}))
vi.mock('../../utils/crowdsecExport', () => ({
buildCrowdsecExportFilename: vi.fn(() => 'crowdsec-default.tar.gz'),
promptCrowdsecFilename: vi.fn(() => 'crowdsec.tar.gz'),
@@ -68,6 +101,7 @@ describe('CrowdSecConfig coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123, lapi_ready: true })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: defaultFileList })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'file-content' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue(undefined)
@@ -116,6 +150,9 @@ describe('CrowdSecConfig coverage', () => {
})
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.crowdsec.console_enrollment': false,
})
})
it('renders loading and error boundaries', async () => {
@@ -10,8 +10,8 @@ import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as presetsApi from '../../api/presets'
import * as featureFlagsApi from '../../api/featureFlags'
import * as consoleApi from '../../api/consoleEnrollment'
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
import type { ConsoleEnrollmentStatus } from '../../api/consoleEnrollment'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
@@ -19,7 +19,28 @@ vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/presets')
vi.mock('../../api/featureFlags')
vi.mock('../../api/consoleEnrollment')
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
CrowdSecBouncerKeyDisplay: () => null,
}))
const consoleStatusMock = vi.fn<() => ConsoleEnrollmentStatus>(() => ({ status: 'not_enrolled', key_present: false }))
const enrollConsoleMock = vi.fn()
const clearConsoleEnrollmentMock = vi.fn()
vi.mock('../../hooks/useConsoleEnrollment', () => ({
useConsoleStatus: vi.fn(() => ({
data: consoleStatusMock(),
isLoading: false,
isRefetching: false,
})),
useEnrollConsole: vi.fn(() => ({
mutateAsync: enrollConsoleMock,
isPending: false,
})),
useClearConsoleEnrollment: vi.fn(() => ({
mutate: clearConsoleEnrollmentMock,
isPending: false,
})),
}))
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
@@ -70,8 +91,8 @@ describe('CrowdSecConfig', () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.crowdsec.console_enrollment': false,
})
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'not_enrolled', key_present: false })
vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolling', key_present: true })
consoleStatusMock.mockReturnValue({ status: 'not_enrolled', key_present: false })
enrollConsoleMock.mockResolvedValue({ status: 'enrolling', key_present: true })
})
it('exports config when clicking Export', async () => {
@@ -146,14 +167,14 @@ describe('CrowdSecConfig', () => {
// Should show validation errors for missing fields
const errors = await screen.findAllByTestId('console-enroll-error')
expect(errors.length).toBeGreaterThan(0)
expect(consoleApi.enrollConsole).not.toHaveBeenCalled()
expect(enrollConsoleMock).not.toHaveBeenCalled()
})
it('submits console enrollment payload with snake_case fields', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(consoleApi.enrollConsole).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'agent-one', tenant: 'tenant-inc' })
enrollConsoleMock.mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'agent-one', tenant: 'tenant-inc' })
renderWithProviders(<CrowdSecConfig />)
@@ -165,7 +186,7 @@ describe('CrowdSecConfig', () => {
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
await userEvent.click(screen.getByTestId('console-enroll-btn'))
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith({
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith({
enrollment_key: 'secret-1234567890',
agent_name: 'agent-one',
tenant: 'tenant-inc',
@@ -179,7 +200,7 @@ describe('CrowdSecConfig', () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true, agent_name: 'a1', tenant: 't1', last_heartbeat_at: '2024-01-01T00:00:00Z' })
consoleStatusMock.mockReturnValue({ status: 'enrolled', key_present: true, agent_name: 'a1', tenant: 't1', last_heartbeat_at: '2024-01-01T00:00:00Z' })
renderWithProviders(<CrowdSecConfig />)
@@ -190,8 +211,7 @@ describe('CrowdSecConfig', () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({ 'feature.crowdsec.console_enrollment': true })
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValueOnce({ status: 'failed', key_present: true, last_error: 'network' })
vi.mocked(consoleApi.getConsoleStatus).mockResolvedValue({ status: 'enrolled', key_present: true })
consoleStatusMock.mockReturnValue({ status: 'failed', key_present: true, last_error: 'network' })
renderWithProviders(<CrowdSecConfig />)
@@ -199,12 +219,12 @@ describe('CrowdSecConfig', () => {
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'another-secret-123456')
await userEvent.click(screen.getByTestId('console-ack-checkbox'))
await userEvent.click(screen.getByTestId('console-retry-btn'))
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({ force: true })))
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith(expect.objectContaining({ force: true })))
await waitFor(() => expect(screen.getByTestId('console-rotate-btn')).not.toBeDisabled())
await userEvent.type(screen.getByTestId('console-enrollment-token'), 'rotate-token-987654321')
await userEvent.click(screen.getByTestId('console-rotate-btn'))
await waitFor(() => expect(consoleApi.enrollConsole).toHaveBeenCalledWith(expect.objectContaining({
await waitFor(() => expect(enrollConsoleMock).toHaveBeenCalledWith(expect.objectContaining({
enrollment_key: 'rotate-token-987654321',
force: true,
})))
@@ -16,6 +16,37 @@ vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/presets')
vi.mock('../../api/featureFlags')
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
CrowdSecBouncerKeyDisplay: () => null,
}))
vi.mock('../../hooks/useConsoleEnrollment', () => ({
useConsoleStatus: vi.fn(() => ({
data: {
status: 'not_enrolled',
tenant: 'default',
agent_name: 'charon-agent',
last_error: null,
last_attempt_at: null,
enrolled_at: null,
last_heartbeat_at: null,
key_present: false,
correlation_id: 'corr-1',
},
isLoading: false,
isRefetching: false,
})),
useEnrollConsole: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({
status: 'enrolling',
key_present: false,
}),
isPending: false,
})),
useClearConsoleEnrollment: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
})),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import Notifications from '../Notifications'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import * as notificationsApi from '../../api/notifications'
import { toast } from '../../utils/toast'
import type { NotificationProvider } from '../../api/notifications'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../../api/notifications', () => ({
getProviders: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
testProvider: vi.fn(),
getTemplates: vi.fn(),
previewProvider: vi.fn(),
getExternalTemplates: vi.fn(),
previewExternalTemplate: vi.fn(),
createExternalTemplate: vi.fn(),
updateExternalTemplate: vi.fn(),
deleteExternalTemplate: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
const baseProvider: NotificationProvider = {
id: 'provider-1',
name: 'Discord Alerts',
type: 'discord',
url: 'https://discord.com/api/webhooks/abc',
config: '{"message":"test"}',
template: 'minimal',
enabled: true,
notify_proxy_hosts: true,
notify_remote_servers: true,
notify_domains: true,
notify_certs: true,
notify_uptime: true,
created_at: '2024-01-01T00:00:00Z',
}
const setupMocks = (providers: NotificationProvider[] = []) => {
vi.mocked(notificationsApi.getProviders).mockResolvedValue(providers)
vi.mocked(notificationsApi.getTemplates).mockResolvedValue([])
vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([])
vi.mocked(notificationsApi.createProvider).mockResolvedValue(baseProvider)
vi.mocked(notificationsApi.updateProvider).mockResolvedValue(baseProvider)
}
describe('Notifications', () => {
beforeEach(() => {
vi.clearAllMocks()
setupMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('rejects invalid protocol URLs', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.type(screen.getByTestId('provider-name'), 'Webhook')
await user.type(screen.getByTestId('provider-url'), 'ftp://example.com/hook')
await user.click(screen.getByTestId('provider-save-btn'))
expect(screen.getByTestId('provider-url-error')).toHaveTextContent('notificationProviders.invalidUrl')
expect(notificationsApi.createProvider).not.toHaveBeenCalled()
})
it('rejects malformed URLs', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.type(screen.getByTestId('provider-name'), 'Webhook')
await user.type(screen.getByTestId('provider-url'), 'not-a-url')
await user.click(screen.getByTestId('provider-save-btn'))
expect(screen.getByTestId('provider-url-error')).toHaveTextContent('notificationProviders.invalidUrl')
expect(notificationsApi.createProvider).not.toHaveBeenCalled()
})
it('accepts a valid https URL', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.type(screen.getByTestId('provider-name'), 'Webhook')
await user.type(screen.getByTestId('provider-url'), 'https://example.com/webhook')
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.url).toBe('https://example.com/webhook')
})
it('accepts a valid http URL', async () => {
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
await user.click(await screen.findByTestId('add-provider-btn'))
await user.type(screen.getByTestId('provider-name'), 'Webhook')
await user.type(screen.getByTestId('provider-url'), 'http://example.com/webhook')
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.url).toBe('http://example.com/webhook')
})
it('shows and hides the update indicator after save', async () => {
vi.useFakeTimers()
setupMocks([baseProvider])
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderWithQueryClient(<Notifications />)
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
const buttons = within(row).getAllByRole('button')
await user.click(buttons[1])
await user.click(screen.getByTestId('provider-save-btn'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(notificationsApi.updateProvider).toHaveBeenCalled()
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
expect(toast.success).toHaveBeenCalledWith('common.saved')
await act(async () => {
await vi.advanceTimersByTimeAsync(3000)
})
expect(screen.queryByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeNull()
})
it('cleans up the update indicator timer on unmount', async () => {
vi.useFakeTimers()
setupMocks([baseProvider])
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { unmount } = renderWithQueryClient(<Notifications />)
const row = await screen.findByTestId(`provider-row-${baseProvider.id}`)
const buttons = within(row).getAllByRole('button')
await user.click(buttons[1])
await user.click(screen.getByTestId('provider-save-btn'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(notificationsApi.updateProvider).toHaveBeenCalled()
expect(screen.getByTestId(`provider-update-indicator-${baseProvider.id}`)).toBeInTheDocument()
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
it('resets event checkboxes when switching from edit to add', async () => {
const providerWithDisabledEvents: NotificationProvider = {
...baseProvider,
notify_proxy_hosts: false,
notify_remote_servers: false,
}
setupMocks([providerWithDisabledEvents])
const user = userEvent.setup()
renderWithQueryClient(<Notifications />)
const row = await screen.findByTestId(`provider-row-${providerWithDisabledEvents.id}`)
const buttons = within(row).getAllByRole('button')
await user.click(buttons[1])
const notifyProxyHosts = screen.getByTestId('notify-proxy-hosts') as HTMLInputElement
expect(notifyProxyHosts.checked).toBe(false)
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
await user.click(await screen.findByTestId('add-provider-btn'))
const resetNotifyProxyHosts = screen.getByTestId('notify-proxy-hosts') as HTMLInputElement
expect(resetNotifyProxyHosts.checked).toBe(true)
})
})
@@ -58,6 +58,10 @@ vi.mock('../../api/settings', () => ({
getSettings: vi.fn(),
}));
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}));
const mockProxyHosts = [
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
@@ -18,6 +18,9 @@ vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}));
const hosts = [
createMockProxyHost({ uuid: 'h1', name: 'Host 1', domain_names: 'one.example.com' }),
@@ -18,6 +18,9 @@ vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
const hosts = [
createMockProxyHost({ uuid: 'p1', name: 'Progress 1', domain_names: 'p1.example.com' }),
@@ -30,6 +30,9 @@ vi.mock('../../api/proxyHosts', () => ({
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}));
const mockProxyHosts = [
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
@@ -59,6 +59,10 @@ vi.mock('../../api/settings', () => ({
getSettings: vi.fn(),
}));
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}));
const mockProxyHosts = [
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
@@ -39,6 +39,9 @@ vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
const createQueryClient = () => new QueryClient({
defaultOptions: {
@@ -79,6 +79,10 @@ describe('ProxyHosts page - coverage targets (isolated)', () => {
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
vi.doMock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null }))
}))
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) }))
// Import page after mocks are in place
@@ -33,6 +33,9 @@ vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
@@ -17,6 +17,9 @@ import { toast } from 'react-hot-toast'
vi.mock('../../hooks/useProxyHosts', () => ({ useProxyHosts: vi.fn() }))
vi.mock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn() }))
vi.mock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn() }))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
@@ -27,6 +27,9 @@ vi.mock('../../api/proxyHosts', () => ({
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({ data: [], isLoading: false, error: null })),
}))
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
@@ -17,6 +17,24 @@ import * as settingsApi from '../../api/settings'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/settings')
vi.mock('../../hooks/useNotifications', () => ({
useSecurityNotificationSettings: vi.fn(() => ({
data: {
enabled: false,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: true,
webhook_url: '',
email_recipients: '',
},
isLoading: false,
})),
useUpdateSecurityNotificationSettings: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
})),
}))
// Mock i18n translation
vi.mock('react-i18next', () => ({
+3
View File
@@ -59,6 +59,9 @@ vi.mock('react-i18next', async () => {
// Cleanup after each test
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.clearAllMocks()
cleanup()
})