feat(security): add WAF configuration page with rule set management and tests
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
435
frontend/src/pages/WafConfig.tsx
Normal file
435
frontend/src/pages/WafConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
464
frontend/src/pages/__tests__/WafConfig.spec.tsx
Normal file
464
frontend/src/pages/__tests__/WafConfig.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user