feat(security): add WAF configuration page with rule set management and tests

This commit is contained in:
GitHub Actions
2025-12-02 01:53:28 +00:00
parent 8c015bceba
commit 44c4d955f5
6 changed files with 978 additions and 7 deletions

View File

@@ -25,6 +25,7 @@ const Logs = lazy(() => import('./pages/Logs'))
const Domains = lazy(() => import('./pages/Domains'))
const Security = lazy(() => import('./pages/Security'))
const AccessLists = lazy(() => import('./pages/AccessLists'))
const WafConfig = lazy(() => import('./pages/WafConfig'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
const Login = lazy(() => import('./pages/Login'))
@@ -56,7 +57,7 @@ export default function App() {
<Route path="security/access-lists" element={<AccessLists />} />
<Route path="security/crowdsec" element={<CrowdSecConfig />} />
<Route path="security/rate-limiting" element={<SystemSettings />} />
<Route path="security/waf" element={<div className="p-6">WAF has no configuration at this time.</div>} />
<Route path="security/waf" element={<WafConfig />} />
<Route path="access-lists" element={<AccessLists />} />
<Route path="uptime" element={<Uptime />} />
<Route path="notifications" element={<Notifications />} />

View File

@@ -75,12 +75,40 @@ export const createDecision = async (payload: any) => {
return response.data
}
export const getRuleSets = async () => {
const response = await client.get('/security/rulesets')
// WAF Ruleset types
export interface SecurityRuleSet {
id: number
uuid: string
name: string
source_url: string
mode: string
last_updated: string
content: string
}
export interface RuleSetsResponse {
rulesets: SecurityRuleSet[]
}
export interface UpsertRuleSetPayload {
id?: number
name: string
content?: string
source_url?: string
mode?: 'blocking' | 'detection'
}
export const getRuleSets = async (): Promise<RuleSetsResponse> => {
const response = await client.get<RuleSetsResponse>('/security/rulesets')
return response.data
}
export const upsertRuleSet = async (payload: any) => {
export const upsertRuleSet = async (payload: UpsertRuleSetPayload) => {
const response = await client.post('/security/rulesets', payload)
return response.data
}
export const deleteRuleSet = async (id: number) => {
const response = await client.delete(`/security/rulesets/${id}`)
return response.data
}

View File

@@ -1,5 +1,18 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus, getDecisions, createDecision, getRuleSets, upsertRuleSet } from '../api/security'
import {
getSecurityStatus,
getSecurityConfig,
updateSecurityConfig,
generateBreakGlassToken,
enableCerberus,
disableCerberus,
getDecisions,
createDecision,
getRuleSets,
upsertRuleSet,
deleteRuleSet,
type UpsertRuleSetPayload,
} from '../api/security'
import toast from 'react-hot-toast'
export function useSecurityStatus() {
@@ -48,8 +61,28 @@ export function useRuleSets() {
export function useUpsertRuleSet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: any) => upsertRuleSet(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['securityRulesets'] }),
mutationFn: (payload: UpsertRuleSetPayload) => upsertRuleSet(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['securityRulesets'] })
toast.success('Rule set saved successfully')
},
onError: (err: Error) => {
toast.error(`Failed to save rule set: ${err.message}`)
},
})
}
export function useDeleteRuleSet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteRuleSet(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['securityRulesets'] })
toast.success('Rule set deleted')
},
onError: (err: Error) => {
toast.error(`Failed to delete rule set: ${err.message}`)
},
})
}

View File

@@ -292,6 +292,16 @@ export default function Security() {
<p className="text-xs text-gray-500 dark:text-gray-400">
OWASP Core Rule Set
</p>
<div className="mt-4">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/security/waf')}
>
Configure
</Button>
</div>
</div>
</Card>

View File

@@ -0,0 +1,435 @@
import { useState } from 'react'
import { Shield, Plus, Pencil, Trash2, ExternalLink, FileCode2 } from 'lucide-react'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { useRuleSets, useUpsertRuleSet, useDeleteRuleSet } from '../hooks/useSecurity'
import type { SecurityRuleSet, UpsertRuleSetPayload } from '../api/security'
/**
* Confirmation dialog for destructive actions
*/
function ConfirmDialog({
isOpen,
title,
message,
confirmLabel,
onConfirm,
onCancel,
isLoading,
}: {
isOpen: boolean
title: string
message: string
confirmLabel: string
onConfirm: () => void
onCancel: () => void
isLoading?: boolean
}) {
if (!isOpen) return null
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onCancel}
data-testid="confirm-dialog-backdrop"
>
<div
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-white mb-2">{title}</h2>
<p className="text-gray-400 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button
variant="danger"
onClick={onConfirm}
disabled={isLoading}
data-testid="confirm-delete-btn"
>
{isLoading ? 'Deleting...' : confirmLabel}
</Button>
</div>
</div>
</div>
)
}
/**
* Form for creating/editing a WAF rule set
*/
function RuleSetForm({
initialData,
onSubmit,
onCancel,
isLoading,
}: {
initialData?: SecurityRuleSet
onSubmit: (data: UpsertRuleSetPayload) => void
onCancel: () => void
isLoading?: boolean
}) {
const [name, setName] = useState(initialData?.name || '')
const [sourceUrl, setSourceUrl] = useState(initialData?.source_url || '')
const [content, setContent] = useState(initialData?.content || '')
const [mode, setMode] = useState<'blocking' | 'detection'>(
initialData?.mode === 'detection' ? 'detection' : 'blocking'
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
id: initialData?.id,
name: name.trim(),
source_url: sourceUrl.trim() || undefined,
content: content.trim() || undefined,
mode,
})
}
const isValid = name.trim().length > 0 && (content.trim().length > 0 || sourceUrl.trim().length > 0)
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Rule Set Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., OWASP CRS"
required
data-testid="ruleset-name-input"
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">Mode</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mode"
value="blocking"
checked={mode === 'blocking'}
onChange={() => setMode('blocking')}
className="text-blue-600 focus:ring-blue-500"
data-testid="mode-blocking"
/>
<span className="text-sm text-gray-300">Blocking</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="mode"
value="detection"
checked={mode === 'detection'}
onChange={() => setMode('detection')}
className="text-blue-600 focus:ring-blue-500"
data-testid="mode-detection"
/>
<span className="text-sm text-gray-300">Detection Only</span>
</label>
</div>
<p className="mt-1 text-xs text-gray-500">
{mode === 'blocking'
? 'Malicious requests will be blocked with HTTP 403'
: 'Malicious requests will be logged but not blocked'}
</p>
</div>
<Input
label="Source URL (optional)"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
placeholder="https://example.com/rules.conf"
helperText="URL to fetch rules from. Leave empty to use inline content."
data-testid="ruleset-url-input"
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Rule Content {!sourceUrl && <span className="text-red-400">*</span>}
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={`SecRule REQUEST_URI "@contains /admin" "id:1000,phase:1,deny,status:403"`}
rows={10}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-testid="ruleset-content-input"
/>
<p className="mt-1 text-xs text-gray-500">
ModSecurity/Coraza rule syntax. Each SecRule should be on its own line.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={!isValid || isLoading} isLoading={isLoading}>
{initialData ? 'Update Rule Set' : 'Create Rule Set'}
</Button>
</div>
</form>
)
}
/**
* WAF Configuration Page - Manage Coraza rule sets
*/
export default function WafConfig() {
const { data: ruleSets, isLoading, error } = useRuleSets()
const upsertMutation = useUpsertRuleSet()
const deleteMutation = useDeleteRuleSet()
const [showCreateForm, setShowCreateForm] = useState(false)
const [editingRuleSet, setEditingRuleSet] = useState<SecurityRuleSet | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<SecurityRuleSet | null>(null)
const handleCreate = (data: UpsertRuleSetPayload) => {
upsertMutation.mutate(data, {
onSuccess: () => setShowCreateForm(false),
})
}
const handleUpdate = (data: UpsertRuleSetPayload) => {
upsertMutation.mutate(data, {
onSuccess: () => setEditingRuleSet(null),
})
}
const handleDelete = () => {
if (!deleteConfirm) return
deleteMutation.mutate(deleteConfirm.id, {
onSuccess: () => {
setDeleteConfirm(null)
setEditingRuleSet(null)
},
})
}
if (isLoading) {
return (
<div className="p-8 text-center text-white" data-testid="waf-loading">
Loading WAF configuration...
</div>
)
}
if (error) {
return (
<div className="p-8 text-center text-red-400" data-testid="waf-error">
Failed to load WAF configuration: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)
}
const ruleSetList = ruleSets?.rulesets || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield className="w-7 h-7 text-blue-400" />
WAF Configuration
</h1>
<p className="text-gray-400 mt-1">
Manage Coraza Web Application Firewall rule sets
</p>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() =>
window.open('https://coraza.io/docs/seclang/directives/', '_blank')
}
>
<ExternalLink className="h-4 w-4 mr-2" />
Rule Syntax
</Button>
<Button onClick={() => setShowCreateForm(true)} data-testid="create-ruleset-btn">
<Plus className="h-4 w-4 mr-2" />
Add Rule Set
</Button>
</div>
</div>
{/* Info Banner */}
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
<div className="flex items-start gap-3">
<FileCode2 className="h-5 w-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-blue-300 mb-1">
About WAF Rule Sets
</h3>
<p className="text-sm text-blue-200/90">
Rule sets define ModSecurity/Coraza rules that inspect and filter HTTP
requests. The WAF automatically enables <code>SecRuleEngine On</code> and{' '}
<code>SecRequestBodyAccess On</code> for your rules.
</p>
</div>
</div>
</div>
{/* Create Form */}
{showCreateForm && (
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">Create Rule Set</h2>
<RuleSetForm
onSubmit={handleCreate}
onCancel={() => setShowCreateForm(false)}
isLoading={upsertMutation.isPending}
/>
</div>
)}
{/* Edit Form */}
{editingRuleSet && (
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">Edit Rule Set</h2>
<Button
variant="danger"
size="sm"
onClick={() => setDeleteConfirm(editingRuleSet)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
<RuleSetForm
initialData={editingRuleSet}
onSubmit={handleUpdate}
onCancel={() => setEditingRuleSet(null)}
isLoading={upsertMutation.isPending}
/>
</div>
)}
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm !== null}
title="Delete Rule Set"
message={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
onCancel={() => setDeleteConfirm(null)}
isLoading={deleteMutation.isPending}
/>
{/* Empty State */}
{ruleSetList.length === 0 && !showCreateForm && !editingRuleSet && (
<div
className="bg-dark-card border border-gray-800 rounded-lg p-12 text-center"
data-testid="waf-empty-state"
>
<div className="text-gray-500 mb-4 text-4xl">🛡</div>
<h3 className="text-lg font-semibold text-white mb-2">No Rule Sets</h3>
<p className="text-gray-400 mb-4">
Create your first WAF rule set to protect your services from web attacks
</p>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Rule Set
</Button>
</div>
)}
{/* Rule Sets Table */}
{ruleSetList.length > 0 && !showCreateForm && !editingRuleSet && (
<div className="bg-dark-card border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full" data-testid="rulesets-table">
<thead className="bg-gray-900/50 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
Mode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">
Last Updated
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{ruleSetList.map((rs) => (
<tr key={rs.id} className="hover:bg-gray-900/30">
<td className="px-6 py-4">
<p className="font-medium text-white">{rs.name}</p>
{rs.content && (
<p className="text-xs text-gray-500 mt-1">
{rs.content.split('\n').filter((l) => l.trim()).length} rule(s)
</p>
)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${
rs.mode === 'blocking'
? 'bg-red-900/30 text-red-300'
: 'bg-yellow-900/30 text-yellow-300'
}`}
>
{rs.mode === 'blocking' ? 'Blocking' : 'Detection'}
</span>
</td>
<td className="px-6 py-4">
{rs.source_url ? (
<a
href={rs.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:underline flex items-center gap-1"
>
URL
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-sm text-gray-500">Inline</span>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-400">
{rs.last_updated
? new Date(rs.last_updated).toLocaleDateString()
: '-'}
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<button
onClick={() => setEditingRuleSet(rs)}
className="text-gray-400 hover:text-blue-400"
title="Edit"
data-testid={`edit-ruleset-${rs.id}`}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(rs)}
className="text-gray-400 hover:text-red-400"
title="Delete"
data-testid={`delete-ruleset-${rs.id}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,464 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import WafConfig from '../WafConfig'
import * as securityApi from '../../api/security'
import type { SecurityRuleSet, RuleSetsResponse } from '../../api/security'
vi.mock('../../api/security')
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>{ui}</BrowserRouter>
</QueryClientProvider>
)
}
const mockRuleSet: SecurityRuleSet = {
id: 1,
uuid: 'uuid-1',
name: 'OWASP CRS',
source_url: '',
mode: 'blocking',
last_updated: '2024-01-15T10:00:00Z',
content: 'SecRule REQUEST_URI "@contains /admin" "id:1000,phase:1,deny,status:403"',
}
describe('WafConfig page', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('shows loading state while fetching rulesets', async () => {
// Keep the promise pending to test loading state
vi.mocked(securityApi.getRuleSets).mockReturnValue(new Promise(() => {}))
renderWithProviders(<WafConfig />)
expect(screen.getByTestId('waf-loading')).toBeInTheDocument()
expect(screen.getByText('Loading WAF configuration...')).toBeInTheDocument()
})
it('shows error state when fetch fails', async () => {
vi.mocked(securityApi.getRuleSets).mockRejectedValue(new Error('Network error'))
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('waf-error')).toBeInTheDocument()
})
expect(screen.getByText(/Failed to load WAF configuration/)).toBeInTheDocument()
expect(screen.getByText(/Network error/)).toBeInTheDocument()
})
it('shows empty state when no rulesets exist', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
})
expect(screen.getByText('No Rule Sets')).toBeInTheDocument()
expect(screen.getByText(/Create your first WAF rule set/)).toBeInTheDocument()
})
it('renders rulesets table when data exists', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
expect(screen.getByText('OWASP CRS')).toBeInTheDocument()
expect(screen.getByText('Blocking')).toBeInTheDocument()
expect(screen.getByText('Inline')).toBeInTheDocument()
})
it('shows create form when Add Rule Set button is clicked', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
expect(screen.getByTestId('ruleset-name-input')).toBeInTheDocument()
expect(screen.getByTestId('ruleset-content-input')).toBeInTheDocument()
})
it('submits new ruleset and closes form on success', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
// Fill in the form
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test Rules')
await userEvent.type(
screen.getByTestId('ruleset-content-input'),
'SecRule ARGS "@contains test" "id:1,phase:1,deny"'
)
// Submit
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
await userEvent.click(submitBtn)
await waitFor(() => {
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
id: undefined,
name: 'Test Rules',
source_url: undefined,
content: 'SecRule ARGS "@contains test" "id:1,phase:1,deny"',
mode: 'blocking',
})
})
})
it('opens edit form when edit button is clicked', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
expect(screen.getByText('Edit Rule Set')).toBeInTheDocument()
expect(screen.getByDisplayValue('OWASP CRS')).toBeInTheDocument()
})
it('opens delete confirmation dialog and deletes on confirm', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
vi.mocked(securityApi.deleteRuleSet).mockResolvedValue(undefined)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
// Click delete button
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
// Confirm dialog should appear
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
expect(screen.getByText(/Are you sure you want to delete "OWASP CRS"/)).toBeInTheDocument()
// Confirm deletion
await userEvent.click(screen.getByTestId('confirm-delete-btn'))
await waitFor(() => {
expect(securityApi.deleteRuleSet).toHaveBeenCalledWith(1)
})
})
it('cancels delete when clicking cancel button', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
// Click delete button
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
// Click cancel
await userEvent.click(screen.getByText('Cancel'))
// Dialog should be closed
await waitFor(() => {
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
})
expect(securityApi.deleteRuleSet).not.toHaveBeenCalled()
})
it('cancels delete when clicking backdrop', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
// Click delete button
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
// Click backdrop
await userEvent.click(screen.getByTestId('confirm-dialog-backdrop'))
// Dialog should be closed
await waitFor(() => {
expect(screen.queryByText('Delete Rule Set')).not.toBeInTheDocument()
})
})
it('displays mode correctly for detection-only rulesets', async () => {
const detectionRuleset: SecurityRuleSet = {
...mockRuleSet,
mode: 'detection',
}
const response: RuleSetsResponse = { rulesets: [detectionRuleset] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
expect(screen.getByText('Detection')).toBeInTheDocument()
})
it('displays URL link when source_url is provided', async () => {
const urlRuleset: SecurityRuleSet = {
...mockRuleSet,
source_url: 'https://example.com/rules.conf',
content: '',
}
const response: RuleSetsResponse = { rulesets: [urlRuleset] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
const urlLink = screen.getByText('URL')
expect(urlLink).toHaveAttribute('href', 'https://example.com/rules.conf')
expect(urlLink).toHaveAttribute('target', '_blank')
})
it('validates form - submit disabled without name', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
// Only add content, no name
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
expect(submitBtn).toBeDisabled()
})
it('validates form - submit disabled without content or URL', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
// Only add name, no content or URL
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
expect(submitBtn).toBeDisabled()
})
it('allows form submission with URL instead of content', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
// Add name and URL, no content
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Remote Rules')
await userEvent.type(screen.getByTestId('ruleset-url-input'), 'https://example.com/rules.conf')
const submitBtn = screen.getByRole('button', { name: 'Create Rule Set' })
expect(submitBtn).not.toBeDisabled()
await userEvent.click(submitBtn)
await waitFor(() => {
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith({
id: undefined,
name: 'Remote Rules',
source_url: 'https://example.com/rules.conf',
content: undefined,
mode: 'blocking',
})
})
})
it('toggles between blocking and detection mode', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
// Fill required fields
await userEvent.type(screen.getByTestId('ruleset-name-input'), 'Test')
await userEvent.type(screen.getByTestId('ruleset-content-input'), 'SecRule test')
// Select detection mode
await userEvent.click(screen.getByTestId('mode-detection'))
// Verify mode description changed
expect(screen.getByText(/Malicious requests will be logged but not blocked/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Create Rule Set' }))
await waitFor(() => {
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'detection' })
)
})
})
it('hides form when cancel is clicked', async () => {
const response: RuleSetsResponse = { rulesets: [] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('create-ruleset-btn'))
expect(screen.getByRole('heading', { name: 'Create Rule Set' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
// Form should be hidden, empty state visible
await waitFor(() => {
expect(screen.getByTestId('waf-empty-state')).toBeInTheDocument()
})
})
it('updates existing ruleset correctly', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ id: 1 })
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
// Open edit form
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
// Update name
const nameInput = screen.getByTestId('ruleset-name-input')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Updated CRS')
// Submit
await userEvent.click(screen.getByText('Update Rule Set'))
await waitFor(() => {
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(
expect.objectContaining({
id: 1,
name: 'Updated CRS',
})
)
})
})
it('opens delete from edit form', async () => {
const response: RuleSetsResponse = { rulesets: [mockRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
// Open edit form
await userEvent.click(screen.getByTestId('edit-ruleset-1'))
// Click delete button in edit form header
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
// Confirm dialog should appear
expect(screen.getByText('Delete Rule Set')).toBeInTheDocument()
})
it('counts rules correctly in table', async () => {
const multiRuleSet: SecurityRuleSet = {
...mockRuleSet,
content: `SecRule ARGS "@contains test1" "id:1,phase:1,deny"
SecRule ARGS "@contains test2" "id:2,phase:1,deny"
SecRule ARGS "@contains test3" "id:3,phase:1,deny"`,
}
const response: RuleSetsResponse = { rulesets: [multiRuleSet] }
vi.mocked(securityApi.getRuleSets).mockResolvedValue(response)
renderWithProviders(<WafConfig />)
await waitFor(() => {
expect(screen.getByTestId('rulesets-table')).toBeInTheDocument()
})
expect(screen.getByText('3 rule(s)')).toBeInTheDocument()
})
})