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:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -467,6 +467,7 @@
|
||||
"providerName": "名称",
|
||||
"urlWebhook": "URL / Webhook",
|
||||
"urlRequired": "URL是必填项",
|
||||
"invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL",
|
||||
"genericWebhook": "通用 Webhook (Shoutrrr)",
|
||||
"customWebhook": "自定义 Webhook (JSON)",
|
||||
"shoutrrrHelp": "有关 Shoutrrr 格式,请参阅",
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -59,6 +59,9 @@ vi.mock('react-i18next', async () => {
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user