feat: implement HTTP Security Headers management (Issue #20)

Add comprehensive security header management system with reusable
profiles, interactive builders, and security scoring.

Features:
- SecurityHeaderProfile model with 11+ header types
- CRUD API with 10 endpoints (/api/v1/security/headers/*)
- Caddy integration for automatic header injection
- 3 built-in presets (Basic, Strict, Paranoid)
- Security score calculator (0-100) with suggestions
- Interactive CSP builder with validation
- Permissions-Policy builder
- Real-time security score preview
- Per-host profile assignment

Headers Supported:
- HSTS with preload support
- Content-Security-Policy with report-only mode
- X-Frame-Options, X-Content-Type-Options
- Referrer-Policy, Permissions-Policy
- Cross-Origin-Opener/Resource/Embedder-Policy
- X-XSS-Protection, Cache-Control security

Implementation:
- Backend: models, handlers, services (85% coverage)
- Frontend: React components, hooks (87.46% coverage)
- Tests: 1,163 total tests passing
- Docs: Comprehensive feature documentation

Closes #20
This commit is contained in:
GitHub Actions
2025-12-18 02:58:26 +00:00
parent 01ec910d58
commit 8cf762164f
33 changed files with 7978 additions and 69 deletions
+2
View File
@@ -31,6 +31,7 @@ const RateLimiting = lazy(() => import('./pages/RateLimiting'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
const UsersPage = lazy(() => import('./pages/UsersPage'))
const SecurityHeaders = lazy(() => import('./pages/SecurityHeaders'))
const Login = lazy(() => import('./pages/Login'))
const Setup = lazy(() => import('./pages/Setup'))
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
@@ -63,6 +64,7 @@ export default function App() {
<Route path="security/crowdsec" element={<CrowdSecConfig />} />
<Route path="security/rate-limiting" element={<RateLimiting />} />
<Route path="security/waf" element={<WafConfig />} />
<Route path="security/headers" element={<SecurityHeaders />} />
<Route path="access-lists" element={<AccessLists />} />
<Route path="uptime" element={<Uptime />} />
<Route path="notifications" element={<Notifications />} />
+160
View File
@@ -0,0 +1,160 @@
import client from './client';
// Types
export interface SecurityHeaderProfile {
id: number;
uuid: string;
name: string;
hsts_enabled: boolean;
hsts_max_age: number;
hsts_include_subdomains: boolean;
hsts_preload: boolean;
csp_enabled: boolean;
csp_directives: string;
csp_report_only: boolean;
csp_report_uri: string;
x_frame_options: string;
x_content_type_options: boolean;
referrer_policy: string;
permissions_policy: string;
cross_origin_opener_policy: string;
cross_origin_resource_policy: string;
cross_origin_embedder_policy: string;
xss_protection: boolean;
cache_control_no_store: boolean;
security_score: number;
is_preset: boolean;
preset_type: string;
description: string;
created_at: string;
updated_at: string;
}
export interface SecurityHeaderPreset {
type: 'basic' | 'strict' | 'paranoid';
name: string;
description: string;
score: number;
config: Partial<SecurityHeaderProfile>;
}
export interface ScoreBreakdown {
score: number;
max_score: number;
breakdown: Record<string, number>;
suggestions: string[];
}
export interface CSPDirective {
directive: string;
values: string[];
}
export interface CreateProfileRequest {
name: string;
description?: string;
hsts_enabled?: boolean;
hsts_max_age?: number;
hsts_include_subdomains?: boolean;
hsts_preload?: boolean;
csp_enabled?: boolean;
csp_directives?: string;
csp_report_only?: boolean;
csp_report_uri?: string;
x_frame_options?: string;
x_content_type_options?: boolean;
referrer_policy?: string;
permissions_policy?: string;
cross_origin_opener_policy?: string;
cross_origin_resource_policy?: string;
cross_origin_embedder_policy?: string;
xss_protection?: boolean;
cache_control_no_store?: boolean;
}
export interface ApplyPresetRequest {
preset_type: string;
name: string;
}
// API Functions
export const securityHeadersApi = {
/**
* List all security header profiles
*/
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles');
return response.data;
},
/**
* Get a single profile by ID or UUID
*/
async getProfile(id: number | string): Promise<SecurityHeaderProfile> {
const response = await client.get<SecurityHeaderProfile>(`/security/headers/profiles/${id}`);
return response.data;
},
/**
* Create a new security header profile
*/
async createProfile(data: CreateProfileRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<SecurityHeaderProfile>('/security/headers/profiles', data);
return response.data;
},
/**
* Update an existing profile
*/
async updateProfile(id: number, data: Partial<CreateProfileRequest>): Promise<SecurityHeaderProfile> {
const response = await client.put<SecurityHeaderProfile>(`/security/headers/profiles/${id}`, data);
return response.data;
},
/**
* Delete a profile (not presets)
*/
async deleteProfile(id: number): Promise<void> {
await client.delete(`/security/headers/profiles/${id}`);
},
/**
* Get built-in presets
*/
async getPresets(): Promise<SecurityHeaderPreset[]> {
const response = await client.get<SecurityHeaderPreset[]>('/security/headers/presets');
return response.data;
},
/**
* Apply a preset to create/update a profile
*/
async applyPreset(data: ApplyPresetRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<SecurityHeaderProfile>('/security/headers/presets/apply', data);
return response.data;
},
/**
* Calculate security score for given settings
*/
async calculateScore(config: Partial<CreateProfileRequest>): Promise<ScoreBreakdown> {
const response = await client.post<ScoreBreakdown>('/security/headers/score', config);
return response.data;
},
/**
* Validate a CSP string
*/
async validateCSP(csp: string): Promise<{ valid: boolean; errors: string[] }> {
const response = await client.post<{ valid: boolean; errors: string[] }>('/security/headers/csp/validate', { csp });
return response.data;
},
/**
* Build a CSP string from directives
*/
async buildCSP(directives: CSPDirective[]): Promise<{ csp: string }> {
const response = await client.post<{ csp: string }>('/security/headers/csp/build', { directives });
return response.data;
},
};
+332
View File
@@ -0,0 +1,332 @@
import { useState, useEffect } from 'react';
import { Plus, X, AlertCircle, Check, Code } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Alert } from './ui/Alert';
import type { CSPDirective } from '../api/securityHeaders';
interface CSPBuilderProps {
value: string; // JSON string of CSPDirective[]
onChange: (value: string) => void;
onValidate?: (valid: boolean, errors: string[]) => void;
}
const CSP_DIRECTIVES = [
'default-src',
'script-src',
'style-src',
'img-src',
'font-src',
'connect-src',
'frame-src',
'object-src',
'media-src',
'worker-src',
'form-action',
'base-uri',
'frame-ancestors',
'manifest-src',
'prefetch-src',
];
const CSP_VALUES = [
"'self'",
"'none'",
"'unsafe-inline'",
"'unsafe-eval'",
'data:',
'https:',
'http:',
'blob:',
'filesystem:',
"'strict-dynamic'",
"'report-sample'",
"'unsafe-hashes'",
];
const CSP_PRESETS: Record<string, CSPDirective[]> = {
'Strict Default': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
{ directive: 'connect-src', values: ["'self'"] },
{ directive: 'frame-src', values: ["'none'"] },
{ directive: 'object-src', values: ["'none'"] },
],
'Allow Inline Styles': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
],
'Development Mode': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'", "'unsafe-inline'", "'unsafe-eval'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:', 'http:'] },
],
};
export function CSPBuilder({ value, onChange, onValidate }: CSPBuilderProps) {
const [directives, setDirectives] = useState<CSPDirective[]>([]);
const [newDirective, setNewDirective] = useState('default-src');
const [newValue, setNewValue] = useState('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [showPreview, setShowPreview] = useState(false);
// Parse initial value
useEffect(() => {
try {
if (value) {
const parsed = JSON.parse(value) as CSPDirective[];
setDirectives(parsed);
} else {
setDirectives([]);
}
} catch {
setDirectives([]);
}
}, [value]);
// Generate CSP string preview
const generateCSPString = (dirs: CSPDirective[]): string => {
return dirs
.map((dir) => `${dir.directive} ${dir.values.join(' ')}`)
.join('; ');
};
const cspString = generateCSPString(directives);
// Update parent component
const updateDirectives = (newDirectives: CSPDirective[]) => {
setDirectives(newDirectives);
onChange(JSON.stringify(newDirectives));
validateCSP(newDirectives);
};
const validateCSP = (dirs: CSPDirective[]) => {
const errors: string[] = [];
// Check for duplicate directives
const directiveNames = dirs.map((d) => d.directive);
const duplicates = directiveNames.filter((name, index) => directiveNames.indexOf(name) !== index);
if (duplicates.length > 0) {
errors.push(`Duplicate directives found: ${duplicates.join(', ')}`);
}
// Check for dangerous combinations
const hasUnsafeInline = dirs.some((d) =>
d.values.some((v) => v === "'unsafe-inline'" || v === "'unsafe-eval'")
);
if (hasUnsafeInline) {
errors.push('Using unsafe-inline or unsafe-eval weakens CSP protection');
}
// Check if default-src is set
const hasDefaultSrc = dirs.some((d) => d.directive === 'default-src');
if (!hasDefaultSrc && dirs.length > 0) {
errors.push('Consider setting default-src as a fallback for all directives');
}
setValidationErrors(errors);
onValidate?.(errors.length === 0, errors);
};
const handleAddDirective = () => {
if (!newValue.trim()) return;
const existingIndex = directives.findIndex((d) => d.directive === newDirective);
let updated: CSPDirective[];
if (existingIndex >= 0) {
// Add to existing directive
const existing = directives[existingIndex];
if (!existing.values.includes(newValue.trim())) {
const updatedDirective = {
...existing,
values: [...existing.values, newValue.trim()],
};
updated = [
...directives.slice(0, existingIndex),
updatedDirective,
...directives.slice(existingIndex + 1),
];
} else {
return; // Value already exists
}
} else {
// Create new directive
updated = [...directives, { directive: newDirective, values: [newValue.trim()] }];
}
updateDirectives(updated);
setNewValue('');
};
const handleRemoveDirective = (directive: string) => {
updateDirectives(directives.filter((d) => d.directive !== directive));
};
const handleRemoveValue = (directive: string, value: string) => {
updateDirectives(
directives.map((d) =>
d.directive === directive
? { ...d, values: d.values.filter((v) => v !== value) }
: d
).filter((d) => d.values.length > 0)
);
};
const handleApplyPreset = (presetName: string) => {
const preset = CSP_PRESETS[presetName];
if (preset) {
updateDirectives(preset);
}
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Content Security Policy Builder</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Code className="w-4 h-4 mr-2" />
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
{/* Preset Buttons */}
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 self-center">Quick Presets:</span>
{Object.keys(CSP_PRESETS).map((presetName) => (
<Button
key={presetName}
variant="outline"
size="sm"
onClick={() => handleApplyPreset(presetName)}
>
{presetName}
</Button>
))}
</div>
{/* Add Directive Form */}
<div className="flex gap-2">
<NativeSelect
value={newDirective}
onChange={(e) => setNewDirective(e.target.value)}
className="w-48"
>
{CSP_DIRECTIVES.map((dir) => (
<option key={dir} value={dir}>
{dir}
</option>
))}
</NativeSelect>
<div className="flex-1 flex gap-2">
<Input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddDirective()}
placeholder="Enter value or select from suggestions..."
list="csp-values"
/>
<datalist id="csp-values">
{CSP_VALUES.map((val) => (
<option key={val} value={val} />
))}
</datalist>
<Button onClick={handleAddDirective} disabled={!newValue.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Current Directives */}
<div className="space-y-2">
{directives.length === 0 ? (
<Alert variant="info">
<AlertCircle className="w-4 h-4" />
<span>No CSP directives configured. Add directives above to build your policy.</span>
</Alert>
) : (
directives.map((dir) => (
<div key={dir.directive} className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{dir.directive}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveDirective(dir.directive)}
className="ml-auto"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1">
{dir.values.map((val) => (
<Badge
key={val}
variant="outline"
className="flex items-center gap-1 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => handleRemoveValue(dir.directive, val)}
>
<span className="font-mono text-xs">{val}</span>
<X className="w-3 h-3" />
</Badge>
))}
</div>
</div>
</div>
))
)}
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<Alert variant="warning">
<AlertCircle className="w-4 h-4" />
<div>
<p className="font-semibold mb-1">CSP Validation Warnings:</p>
<ul className="list-disc list-inside text-sm space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</Alert>
)}
{validationErrors.length === 0 && directives.length > 0 && (
<Alert variant="success">
<Check className="w-4 h-4" />
<span>CSP configuration looks good!</span>
</Alert>
)}
{/* CSP String Preview */}
{showPreview && cspString && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated CSP Header:</label>
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
{cspString || '(empty)'}
</pre>
</div>
)}
</Card>
);
}
+1
View File
@@ -68,6 +68,7 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'Access Lists', path: '/security/access-lists', icon: '🔒' },
{ name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' },
{ name: 'Coraza', path: '/security/waf', icon: '🛡️' },
{ name: 'Security Headers', path: '/security/headers', icon: '🔐' },
]},
{ name: 'Notifications', path: '/notifications', icon: '🔔' },
// Import group moved under Tasks
@@ -0,0 +1,269 @@
import { useState, useEffect } from 'react';
import { Plus, X, Code } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Alert } from './ui/Alert';
interface PermissionsPolicyItem {
feature: string;
allowlist: string[];
}
interface PermissionsPolicyBuilderProps {
value: string; // JSON string of PermissionsPolicyItem[]
onChange: (value: string) => void;
}
const FEATURES = [
'accelerometer',
'ambient-light-sensor',
'autoplay',
'battery',
'camera',
'display-capture',
'document-domain',
'encrypted-media',
'fullscreen',
'geolocation',
'gyroscope',
'magnetometer',
'microphone',
'midi',
'payment',
'picture-in-picture',
'publickey-credentials-get',
'screen-wake-lock',
'sync-xhr',
'usb',
'web-share',
'xr-spatial-tracking',
];
const ALLOWLIST_PRESETS = [
{ label: 'None (disable)', value: '' },
{ label: 'Self', value: 'self' },
{ label: 'All (*)', value: '*' },
];
export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyBuilderProps) {
const [policies, setPolicies] = useState<PermissionsPolicyItem[]>([]);
const [newFeature, setNewFeature] = useState('camera');
const [newAllowlist, setNewAllowlist] = useState('');
const [customOrigin, setCustomOrigin] = useState('');
const [showPreview, setShowPreview] = useState(false);
// Parse initial value
useEffect(() => {
try {
if (value) {
const parsed = JSON.parse(value) as PermissionsPolicyItem[];
setPolicies(parsed);
} else {
setPolicies([]);
}
} catch {
setPolicies([]);
}
}, [value]);
// Generate Permissions-Policy string preview
const generatePolicyString = (pols: PermissionsPolicyItem[]): string => {
return pols
.map((pol) => {
if (pol.allowlist.length === 0) {
return `${pol.feature}=()`;
}
const allowlistStr = pol.allowlist.join(' ');
return `${pol.feature}=(${allowlistStr})`;
})
.join(', ');
};
const policyString = generatePolicyString(policies);
// Update parent component
const updatePolicies = (newPolicies: PermissionsPolicyItem[]) => {
setPolicies(newPolicies);
onChange(JSON.stringify(newPolicies));
};
const handleAddFeature = () => {
const existingIndex = policies.findIndex((p) => p.feature === newFeature);
let allowlist: string[] = [];
if (newAllowlist === 'self') {
allowlist = ['self'];
} else if (newAllowlist === '*') {
allowlist = ['*'];
} else if (customOrigin.trim()) {
allowlist = [customOrigin.trim()];
}
if (existingIndex >= 0) {
// Update existing
const updated = [...policies];
updated[existingIndex] = { feature: newFeature, allowlist };
updatePolicies(updated);
} else {
// Add new
updatePolicies([...policies, { feature: newFeature, allowlist }]);
}
setCustomOrigin('');
};
const handleRemoveFeature = (feature: string) => {
updatePolicies(policies.filter((p) => p.feature !== feature));
};
const handleQuickAdd = (features: string[]) => {
const newPolicies = features.map((feature) => ({
feature,
allowlist: [],
}));
// Merge with existing (don't duplicate)
const merged = [...policies];
newPolicies.forEach((newPolicy) => {
if (!merged.some((p) => p.feature === newPolicy.feature)) {
merged.push(newPolicy);
}
});
updatePolicies(merged);
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Permissions Policy Builder</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Code className="w-4 h-4 mr-2" />
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
{/* Quick Add Buttons */}
<div className="space-y-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Quick Add:</span>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAdd(['camera', 'microphone', 'geolocation'])}
>
Disable Common Features
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAdd(['payment', 'usb', 'midi'])}
>
Disable Sensitive APIs
</Button>
</div>
</div>
{/* Add Feature Form */}
<div className="space-y-2">
<div className="flex gap-2">
<NativeSelect
value={newFeature}
onChange={(e) => setNewFeature(e.target.value)}
className="w-48"
>
{FEATURES.map((feature) => (
<option key={feature} value={feature}>
{feature}
</option>
))}
</NativeSelect>
<NativeSelect
value={newAllowlist}
onChange={(e) => setNewAllowlist(e.target.value)}
className="w-40"
>
{ALLOWLIST_PRESETS.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</NativeSelect>
{newAllowlist === '' && (
<Input
type="text"
value={customOrigin}
onChange={(e) => setCustomOrigin(e.target.value)}
placeholder="or enter origin (e.g., https://example.com)"
className="flex-1"
/>
)}
<Button onClick={handleAddFeature}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Current Policies */}
<div className="space-y-2">
{policies.length === 0 ? (
<Alert variant="info">
<span>No permissions policies configured. Add features above to restrict browser capabilities.</span>
</Alert>
) : (
policies.map((policy) => (
<div key={policy.feature} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white flex-shrink-0">
{policy.feature}
</span>
<div className="flex-1">
{policy.allowlist.length === 0 ? (
<Badge variant="error">Disabled</Badge>
) : policy.allowlist.includes('*') ? (
<Badge variant="success">Allowed (all origins)</Badge>
) : policy.allowlist.includes('self') ? (
<Badge variant="outline">Self only</Badge>
) : (
<div className="flex flex-wrap gap-1">
{policy.allowlist.map((origin) => (
<Badge key={origin} variant="outline" className="font-mono text-xs">
{origin}
</Badge>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFeature(policy.feature)}
>
<X className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
{/* Policy String Preview */}
{showPreview && policyString && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated Permissions-Policy Header:</label>
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
{policyString || '(empty)'}
</pre>
</div>
)}
</Card>
);
}
@@ -0,0 +1,466 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Save, X } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Textarea } from './ui/Textarea';
import { Switch } from './ui/Switch';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Alert } from './ui/Alert';
import { CSPBuilder } from './CSPBuilder';
import { PermissionsPolicyBuilder } from './PermissionsPolicyBuilder';
import { SecurityScoreDisplay } from './SecurityScoreDisplay';
import { useCalculateSecurityScore } from '../hooks/useSecurityHeaders';
import type { SecurityHeaderProfile, CreateProfileRequest } from '../api/securityHeaders';
interface SecurityHeaderProfileFormProps {
initialData?: SecurityHeaderProfile;
onSubmit: (data: CreateProfileRequest) => void;
onCancel: () => void;
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
}
export function SecurityHeaderProfileForm({
initialData,
onSubmit,
onCancel,
onDelete,
isLoading,
isDeleting,
}: SecurityHeaderProfileFormProps) {
const [formData, setFormData] = useState<CreateProfileRequest>({
name: initialData?.name || '',
description: initialData?.description || '',
hsts_enabled: initialData?.hsts_enabled ?? true,
hsts_max_age: initialData?.hsts_max_age || 31536000,
hsts_include_subdomains: initialData?.hsts_include_subdomains ?? true,
hsts_preload: initialData?.hsts_preload ?? false,
csp_enabled: initialData?.csp_enabled ?? false,
csp_directives: initialData?.csp_directives || '',
csp_report_only: initialData?.csp_report_only ?? false,
csp_report_uri: initialData?.csp_report_uri || '',
x_frame_options: initialData?.x_frame_options || 'DENY',
x_content_type_options: initialData?.x_content_type_options ?? true,
referrer_policy: initialData?.referrer_policy || 'strict-origin-when-cross-origin',
permissions_policy: initialData?.permissions_policy || '',
cross_origin_opener_policy: initialData?.cross_origin_opener_policy || 'same-origin',
cross_origin_resource_policy: initialData?.cross_origin_resource_policy || 'same-origin',
cross_origin_embedder_policy: initialData?.cross_origin_embedder_policy || '',
xss_protection: initialData?.xss_protection ?? true,
cache_control_no_store: initialData?.cache_control_no_store ?? false,
});
const [cspValid, setCspValid] = useState(true);
const [, setCspErrors] = useState<string[]>([]);
const calculateScoreMutation = useCalculateSecurityScore();
// Calculate score when form data changes
useEffect(() => {
const timer = setTimeout(() => {
calculateScoreMutation.mutate(formData);
}, 500);
return () => clearTimeout(timer);
}, [formData]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
return;
}
onSubmit(formData);
};
const updateField = <K extends keyof CreateProfileRequest>(
field: K,
value: CreateProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const isPreset = initialData?.is_preset ?? false;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Profile Name *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="e.g., Production Security Headers"
required
disabled={isPreset}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<Textarea
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Optional description of this security profile..."
rows={2}
disabled={isPreset}
/>
</div>
{isPreset && (
<Alert variant="info">
This is a system preset and cannot be modified. Clone it to create a custom profile.
</Alert>
)}
</Card>
{/* Live Security Score */}
{calculateScoreMutation.data && (
<SecurityScoreDisplay
score={calculateScoreMutation.data.score}
maxScore={calculateScoreMutation.data.max_score}
breakdown={calculateScoreMutation.data.breakdown}
suggestions={calculateScoreMutation.data.suggestions}
size="md"
showDetails={true}
/>
)}
{/* HSTS Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
HTTP Strict Transport Security (HSTS)
</h3>
<Switch
checked={formData.hsts_enabled}
onCheckedChange={(checked) => updateField('hsts_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_enabled && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Age (seconds)
</label>
<Input
type="number"
value={formData.hsts_max_age}
onChange={(e) => updateField('hsts_max_age', parseInt(e.target.value) || 0)}
min={0}
disabled={isPreset}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Recommended: 31536000 (1 year) or 63072000 (2 years)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Include Subdomains
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Apply HSTS to all subdomains
</p>
</div>
<Switch
checked={formData.hsts_include_subdomains}
onCheckedChange={(checked) => updateField('hsts_include_subdomains', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Preload
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Submit to browser preload lists
</p>
</div>
<Switch
checked={formData.hsts_preload}
onCheckedChange={(checked) => updateField('hsts_preload', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_preload && (
<Alert variant="warning">
<AlertTriangle className="w-4 h-4" />
<div>
<p className="font-semibold">Warning: HSTS Preload is Permanent</p>
<p className="text-sm mt-1">
Once submitted to browser preload lists, removal can take months. Only enable if you're
committed to HTTPS forever.
</p>
</div>
</Alert>
)}
</>
)}
</Card>
{/* CSP Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Content Security Policy (CSP)
</h3>
<Switch
checked={formData.csp_enabled}
onCheckedChange={(checked) => updateField('csp_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_enabled && (
<>
<CSPBuilder
value={formData.csp_directives || ''}
onChange={(value) => updateField('csp_directives', value)}
onValidate={(valid, errors) => {
setCspValid(valid);
setCspErrors(errors);
}}
/>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Report-Only Mode
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Test CSP without blocking content
</p>
</div>
<Switch
checked={formData.csp_report_only}
onCheckedChange={(checked) => updateField('csp_report_only', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_report_only && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report URI (optional)
</label>
<Input
type="url"
value={formData.csp_report_uri || ''}
onChange={(e) => updateField('csp_report_uri', e.target.value)}
placeholder="https://example.com/csp-report"
disabled={isPreset}
/>
</div>
)}
</>
)}
</Card>
{/* Frame Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Clickjacking Protection</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
X-Frame-Options
</label>
<NativeSelect
value={formData.x_frame_options}
onChange={(e) => updateField('x_frame_options', e.target.value)}
disabled={isPreset}
>
<option value="DENY">DENY (Recommended - no framing allowed)</option>
<option value="SAMEORIGIN">SAMEORIGIN (allow same origin framing)</option>
<option value="">None (allow all framing - not recommended)</option>
</NativeSelect>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-Content-Type-Options: nosniff
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent MIME type sniffing attacks
</p>
</div>
<Switch
checked={formData.x_content_type_options}
onCheckedChange={(checked) => updateField('x_content_type_options', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Privacy Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Privacy Controls</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Referrer-Policy
</label>
<NativeSelect
value={formData.referrer_policy}
onChange={(e) => updateField('referrer_policy', e.target.value)}
disabled={isPreset}
>
<option value="no-referrer">no-referrer (Most Private)</option>
<option value="no-referrer-when-downgrade">no-referrer-when-downgrade</option>
<option value="origin">origin</option>
<option value="origin-when-cross-origin">origin-when-cross-origin</option>
<option value="same-origin">same-origin</option>
<option value="strict-origin">strict-origin</option>
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin (Recommended)</option>
<option value="unsafe-url">unsafe-url (Least Private)</option>
</NativeSelect>
</div>
</Card>
{/* Permissions Policy */}
<PermissionsPolicyBuilder
value={formData.permissions_policy || ''}
onChange={(value) => updateField('permissions_policy', value)}
/>
{/* Cross-Origin Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Cross-Origin Isolation</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Opener-Policy
</label>
<NativeSelect
value={formData.cross_origin_opener_policy}
onChange={(e) => updateField('cross_origin_opener_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="unsafe-none">unsafe-none</option>
<option value="same-origin-allow-popups">same-origin-allow-popups</option>
<option value="same-origin">same-origin (Recommended)</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Resource-Policy
</label>
<NativeSelect
value={formData.cross_origin_resource_policy}
onChange={(e) => updateField('cross_origin_resource_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="same-site">same-site</option>
<option value="same-origin">same-origin (Recommended)</option>
<option value="cross-origin">cross-origin</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Embedder-Policy
</label>
<NativeSelect
value={formData.cross_origin_embedder_policy}
onChange={(e) => updateField('cross_origin_embedder_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None (Default)</option>
<option value="require-corp">require-corp (Strict)</option>
</NativeSelect>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Only enable if you need SharedArrayBuffer or high-resolution timers
</p>
</div>
</Card>
{/* Additional Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Additional Options</h3>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-XSS-Protection
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Legacy XSS protection header
</p>
</div>
<Switch
checked={formData.xss_protection}
onCheckedChange={(checked) => updateField('xss_protection', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cache-Control: no-store
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent caching of sensitive content
</p>
</div>
<Switch
checked={formData.cache_control_no_store}
onCheckedChange={(checked) => updateField('cache_control_no_store', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Form Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{onDelete && !isPreset && (
<Button
type="button"
variant="danger"
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Profile'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || isPreset || (!cspValid && formData.csp_enabled)}
>
<Save className="w-4 h-4 mr-2" />
{isLoading ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Shield, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Progress } from './ui/Progress';
interface SecurityScoreDisplayProps {
score: number;
maxScore?: number;
breakdown?: Record<string, number>;
suggestions?: string[];
size?: 'sm' | 'md' | 'lg';
showDetails?: boolean;
}
const CATEGORY_LABELS: Record<string, string> = {
hsts: 'HSTS',
csp: 'Content Security Policy',
x_frame_options: 'X-Frame-Options',
x_content_type_options: 'X-Content-Type-Options',
referrer_policy: 'Referrer Policy',
permissions_policy: 'Permissions Policy',
cross_origin: 'Cross-Origin Headers',
};
const CATEGORY_DESCRIPTIONS: Record<string, string> = {
hsts: 'HTTP Strict Transport Security enforces HTTPS connections',
csp: 'Content Security Policy prevents XSS and injection attacks',
x_frame_options: 'Prevents clickjacking by controlling iframe embedding',
x_content_type_options: 'Prevents MIME type sniffing attacks',
referrer_policy: 'Controls referrer information sent with requests',
permissions_policy: 'Restricts browser features and APIs',
cross_origin: 'Cross-Origin isolation headers for enhanced security',
};
export function SecurityScoreDisplay({
score,
maxScore = 100,
breakdown = {},
suggestions = [],
size = 'md',
showDetails = true,
}: SecurityScoreDisplayProps) {
const [expandedBreakdown, setExpandedBreakdown] = useState(false);
const [expandedSuggestions, setExpandedSuggestions] = useState(false);
const percentage = Math.round((score / maxScore) * 100);
const getScoreColor = () => {
if (percentage >= 75) return 'text-green-600 dark:text-green-400';
if (percentage >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
const getScoreBgColor = () => {
if (percentage >= 75) return 'bg-green-100 dark:bg-green-900/20';
if (percentage >= 50) return 'bg-yellow-100 dark:bg-yellow-900/20';
return 'bg-red-100 dark:bg-red-900/20';
};
const getScoreVariant = (): 'success' | 'warning' | 'error' => {
if (percentage >= 75) return 'success';
if (percentage >= 50) return 'warning';
return 'error';
};
const sizeClasses = {
sm: 'w-12 h-12 text-sm',
md: 'w-20 h-20 text-2xl',
lg: 'w-32 h-32 text-4xl',
};
if (size === 'sm') {
return (
<div className="flex items-center gap-2">
<div
className={`${sizeClasses[size]} rounded-full ${getScoreBgColor()} flex items-center justify-center font-bold ${getScoreColor()}`}
>
{score}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">/ {maxScore}</div>
</div>
);
}
return (
<Card className="p-4">
<div className="flex items-start gap-4">
{/* Circular Score Display */}
<div className="flex-shrink-0">
<div
className={`${sizeClasses[size]} rounded-full ${getScoreBgColor()} flex flex-col items-center justify-center font-bold ${getScoreColor()}`}
>
<div className="flex items-baseline">
<span>{score}</span>
<span className="text-sm opacity-75">/{maxScore}</span>
</div>
<div className="text-xs font-normal opacity-75">Security</div>
</div>
</div>
{/* Score Info */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Shield className="w-5 h-5 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Security Score</h3>
<Badge variant={getScoreVariant()}>{percentage}%</Badge>
</div>
{/* Overall Progress Bar */}
<Progress value={percentage} variant={getScoreVariant()} className="mb-4" />
{showDetails && (
<>
{/* Breakdown Section */}
{Object.keys(breakdown).length > 0 && (
<div className="mt-4">
<button
onClick={() => setExpandedBreakdown(!expandedBreakdown)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{expandedBreakdown ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
Score Breakdown by Category
</button>
{expandedBreakdown && (
<div className="mt-3 space-y-3 pl-6">
{Object.entries(breakdown).map(([category, categoryScore]) => {
const categoryMax = getCategoryMax(category);
const categoryPercent = Math.round((categoryScore / categoryMax) * 100);
return (
<div key={category} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span
className="text-gray-700 dark:text-gray-300"
title={CATEGORY_DESCRIPTIONS[category]}
>
{CATEGORY_LABELS[category] || category}
</span>
<span className="text-gray-600 dark:text-gray-400 font-mono">
{categoryScore}/{categoryMax}
</span>
</div>
<Progress
value={categoryPercent}
variant={categoryPercent >= 70 ? 'success' : categoryPercent >= 40 ? 'warning' : 'error'}
/>
</div>
);
})}
</div>
)}
</div>
)}
{/* Suggestions Section */}
{suggestions.length > 0 && (
<div className="mt-4">
<button
onClick={() => setExpandedSuggestions(!expandedSuggestions)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{expandedSuggestions ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
Security Suggestions ({suggestions.length})
</button>
{expandedSuggestions && (
<ul className="mt-3 space-y-2 pl-6">
{suggestions.map((suggestion, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<AlertCircle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<span>{suggestion}</span>
</li>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
</div>
</Card>
);
}
// Helper function to determine max score for each category
function getCategoryMax(category: string): number {
const maxScores: Record<string, number> = {
hsts: 25,
csp: 25,
x_frame_options: 10,
x_content_type_options: 10,
referrer_policy: 10,
permissions_policy: 10,
cross_origin: 10,
};
return maxScores[category] || 10;
}
@@ -0,0 +1,235 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CSPBuilder } from '../CSPBuilder';
describe('CSPBuilder', () => {
const mockOnChange = vi.fn();
const mockOnValidate = vi.fn();
const defaultProps = {
value: '',
onChange: mockOnChange,
onValidate: mockOnValidate,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with empty directives', () => {
render(<CSPBuilder {...defaultProps} />);
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
expect(screen.getByText('No CSP directives configured. Add directives above to build your policy.')).toBeInTheDocument();
});
it('should add a directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getByRole('button', { name: '' }); // Plus button
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.click(addButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed).toEqual([
{ directive: 'default-src', values: ["'self'"] },
]);
});
it('should remove a directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
await waitFor(() => {
const directiveElements = screen.getAllByText('default-src');
expect(directiveElements.length).toBeGreaterThan(0);
});
// Find the X button in the directive row (not in the select)
const allButtons = screen.getAllByRole('button');
const removeButton = allButtons.find(btn => {
const svg = btn.querySelector('svg');
return svg && btn.closest('.bg-gray-50, .dark\\:bg-gray-800');
});
if (removeButton) {
fireEvent.click(removeButton);
}
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should apply preset', async () => {
render(<CSPBuilder {...defaultProps} />);
const presetButton = screen.getByRole('button', { name: 'Strict Default' });
fireEvent.click(presetButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed.length).toBeGreaterThan(0);
expect(parsed[0].directive).toBe('default-src');
});
it('should toggle preview display', () => {
render(<CSPBuilder {...defaultProps} />);
const previewButton = screen.getByRole('button', { name: /Show Preview/ });
expect(screen.queryByText('Generated CSP Header:')).not.toBeInTheDocument();
fireEvent.click(previewButton);
expect(screen.getByRole('button', { name: /Hide Preview/ })).toBeInTheDocument();
});
it('should validate CSP and show warnings', async () => {
render(<CSPBuilder {...defaultProps} />);
// Add an unsafe directive to trigger validation
const directiveSelect = screen.getAllByRole('combobox')[0];
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('.lucide-plus'));
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
fireEvent.change(valueInput, { target: { value: "'unsafe-inline'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
expect(mockOnValidate).toHaveBeenCalled();
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall).toBeDefined();
});
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall?.[0]).toBe(false);
expect(validateCall?.[1]).toContain('Using unsafe-inline or unsafe-eval weakens CSP protection');
});
it('should not add duplicate values to same directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const allButtons = screen.getAllByRole('button');
const addButton = allButtons.find(btn => btn.querySelector('.lucide-plus'));
// Try to add the same value again
fireEvent.change(valueInput, { target: { value: "'self'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
// Should not call onChange since it's a duplicate
const calls = mockOnChange.mock.calls.filter(call => {
const parsed = JSON.parse(call[0]);
return parsed[0].values.filter((v: string) => v === "'self'").length > 1;
});
expect(calls.length).toBe(0);
});
});
it('should parse initial value correctly', () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:'] },
{ directive: 'script-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
// Use getAllByText since these appear in both the select and the directive list
const defaultSrcElements = screen.getAllByText('default-src');
expect(defaultSrcElements.length).toBeGreaterThan(0);
const scriptSrcElements = screen.getAllByText('script-src');
expect(scriptSrcElements.length).toBeGreaterThan(0);
const selfElements = screen.getAllByText("'self'");
expect(selfElements.length).toBeGreaterThan(0);
});
it('should change directive selector', () => {
render(<CSPBuilder {...defaultProps} />);
// Get the first combobox (the directive selector)
const allSelects = screen.getAllByRole('combobox');
const directiveSelect = allSelects[0];
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
expect(directiveSelect).toHaveValue('script-src');
});
it('should handle Enter key to add directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.keyDown(valueInput, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should not add empty values', () => {
render(<CSPBuilder {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '' });
fireEvent.click(addButton);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should remove individual values from directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:', 'data:'] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const selfBadge = screen.getByText("'self'");
fireEvent.click(selfBadge);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
const parsed = JSON.parse(callArg);
expect(parsed[0].values).not.toContain("'self'");
expect(parsed[0].values).toContain('https:');
});
it('should show success alert when valid', async () => {
const validValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={validValue} />);
await waitFor(() => {
expect(screen.getByText('CSP configuration looks good!')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,280 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import { SecurityHeaderProfileForm } from '../SecurityHeaderProfileForm';
import { securityHeadersApi } from '../../api/securityHeaders';
vi.mock('../../api/securityHeaders');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('SecurityHeaderProfileForm', () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
const mockOnDelete = vi.fn();
const defaultProps = {
onSubmit: mockOnSubmit,
onCancel: mockOnCancel,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
score: 85,
max_score: 100,
breakdown: {},
suggestions: [],
});
});
it('should render with empty form', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByPlaceholderText(/Production Security Headers/)).toBeInTheDocument();
expect(screen.getByText('HTTP Strict Transport Security (HSTS)')).toBeInTheDocument();
});
it('should render with initial data', () => {
const initialData = {
id: 1,
name: 'Test Profile',
description: 'Test description',
hsts_enabled: true,
hsts_max_age: 31536000,
security_score: 85,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as any} />,
{ wrapper: createWrapper() }
);
expect(screen.getByDisplayValue('Test Profile')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test description')).toBeInTheDocument();
});
it('should submit form with valid data', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'New Profile' } });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled();
});
const submitData = mockOnSubmit.mock.calls[0][0];
expect(submitData.name).toBe('New Profile');
});
it('should not submit with empty name', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should call onCancel when cancel button clicked', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const cancelButton = screen.getByRole('button', { name: /Cancel/ });
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('should toggle HSTS enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Switch component uses checkbox with sr-only class
const hstsSection = screen.getByText('HTTP Strict Transport Security (HSTS)').closest('div');
const hstsToggle = hstsSection?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(hstsToggle).toBeTruthy();
expect(hstsToggle.checked).toBe(true);
fireEvent.click(hstsToggle);
expect(hstsToggle.checked).toBe(false);
});
it('should show HSTS options when enabled', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/Max Age \(seconds\)/)).toBeInTheDocument();
expect(screen.getByText('Include Subdomains')).toBeInTheDocument();
expect(screen.getByText('Preload')).toBeInTheDocument();
});
it('should show preload warning when enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Find the preload switch by finding the parent container with the "Preload" label
const preloadText = screen.getByText('Preload');
const preloadContainer = preloadText.closest('div')?.parentElement; // Go up to the flex container
const preloadSwitch = preloadContainer?.querySelector('input[type="checkbox"]');
expect(preloadSwitch).toBeTruthy();
if (preloadSwitch) {
fireEvent.click(preloadSwitch);
}
await waitFor(() => {
expect(screen.getByText(/Warning: HSTS Preload is Permanent/)).toBeInTheDocument();
});
});
it('should toggle CSP enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// CSP is disabled by default, so builder should not be visible
expect(screen.queryByText('Content Security Policy Builder')).not.toBeInTheDocument();
// Find and click the CSP toggle switch (checkbox with sr-only class)
const cspSection = screen.getByText('Content Security Policy (CSP)').closest('div');
const cspCheckbox = cspSection?.querySelector('input[type="checkbox"]');
if (cspCheckbox) {
fireEvent.click(cspCheckbox);
}
// Builder should now be visible
await waitFor(() => {
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
});
});
it('should disable form for presets', () => {
const presetData = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={presetData as any} />,
{ wrapper: createWrapper() }
);
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
expect(nameInput).toBeDisabled();
expect(screen.getByText(/This is a system preset and cannot be modified/)).toBeInTheDocument();
});
it('should show delete button for non-presets', () => {
const profileData = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as any}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('button', { name: /Delete Profile/ })).toBeInTheDocument();
});
it('should not show delete button for presets', () => {
const presetData = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={presetData as any}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.queryByRole('button', { name: /Delete Profile/ })).not.toBeInTheDocument();
});
it('should change referrer policy', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const referrerSelect = screen.getAllByRole('combobox')[1]; // Referrer policy is second select
fireEvent.change(referrerSelect, { target: { value: 'no-referrer' } });
expect(referrerSelect).toHaveValue('no-referrer');
});
it('should change x-frame-options', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const xfoSelect = screen.getAllByRole('combobox')[0]; // X-Frame-Options is first select
fireEvent.change(xfoSelect, { target: { value: 'SAMEORIGIN' } });
expect(xfoSelect).toHaveValue('SAMEORIGIN');
});
it('should show loading state', () => {
render(<SecurityHeaderProfileForm {...defaultProps} isLoading={true} />, { wrapper: createWrapper() });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('should show deleting state', () => {
const profileData = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as any}
onDelete={mockOnDelete}
isDeleting={true}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Deleting...')).toBeInTheDocument();
});
it('should calculate security score on form changes', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'Test' } });
await waitFor(() => {
expect(securityHeadersApi.calculateScore).toHaveBeenCalled();
}, { timeout: 1000 });
});
});
@@ -0,0 +1,152 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SecurityScoreDisplay } from '../SecurityScoreDisplay';
describe('SecurityScoreDisplay', () => {
const mockBreakdown = {
hsts: 25,
csp: 20,
x_frame_options: 10,
x_content_type_options: 10,
};
const mockSuggestions = [
'Enable HSTS to enforce HTTPS',
'Add Content-Security-Policy',
];
it('should render with basic score', () => {
render(<SecurityScoreDisplay score={85} />);
expect(screen.getByText('85')).toBeInTheDocument();
expect(screen.getByText('/100')).toBeInTheDocument();
});
it('should render small size variant', () => {
render(<SecurityScoreDisplay score={50} size="sm" showDetails={false} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('Security Score')).not.toBeInTheDocument();
});
it('should show correct color for high score', () => {
const { container } = render(<SecurityScoreDisplay score={85} maxScore={100} />);
const scoreElement = container.querySelector('.text-green-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for medium score', () => {
const { container } = render(<SecurityScoreDisplay score={60} maxScore={100} />);
const scoreElement = container.querySelector('.text-yellow-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for low score', () => {
const { container } = render(<SecurityScoreDisplay score={30} maxScore={100} />);
const scoreElement = container.querySelector('.text-red-600');
expect(scoreElement).toBeInTheDocument();
});
it('should display breakdown when provided', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
expect(screen.getByText('Score Breakdown by Category')).toBeInTheDocument();
});
it('should toggle breakdown visibility', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
const breakdownButton = screen.getByText('Score Breakdown by Category');
expect(screen.queryByText('HSTS')).not.toBeInTheDocument();
fireEvent.click(breakdownButton);
expect(screen.getByText('HSTS')).toBeInTheDocument();
});
it('should display suggestions when provided', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
expect(screen.getByText(/Security Suggestions \(2\)/)).toBeInTheDocument();
});
it('should toggle suggestions visibility', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
const suggestionsButton = screen.getByText(/Security Suggestions/);
expect(screen.queryByText('Enable HSTS to enforce HTTPS')).not.toBeInTheDocument();
fireEvent.click(suggestionsButton);
expect(screen.getByText('Enable HSTS to enforce HTTPS')).toBeInTheDocument();
});
it('should not show details when showDetails is false', () => {
render(
<SecurityScoreDisplay
score={75}
breakdown={mockBreakdown}
suggestions={mockSuggestions}
showDetails={false}
/>
);
expect(screen.queryByText('Score Breakdown by Category')).not.toBeInTheDocument();
expect(screen.queryByText('Security Suggestions')).not.toBeInTheDocument();
});
it('should display custom max score', () => {
render(<SecurityScoreDisplay score={40} maxScore={50} />);
expect(screen.getByText('40')).toBeInTheDocument();
expect(screen.getByText('/50')).toBeInTheDocument();
});
it('should calculate percentage correctly', () => {
render(<SecurityScoreDisplay score={75} maxScore={100} />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('should render all breakdown categories', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
fireEvent.click(screen.getByText('Score Breakdown by Category'));
expect(screen.getByText('HSTS')).toBeInTheDocument();
expect(screen.getByText('Content Security Policy')).toBeInTheDocument();
expect(screen.getByText('X-Frame-Options')).toBeInTheDocument();
expect(screen.getByText('X-Content-Type-Options')).toBeInTheDocument();
});
});
@@ -0,0 +1,32 @@
import { forwardRef } from 'react';
import { cn } from '../../utils/cn';
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
error?: boolean;
}
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
({ className, error, ...props }, ref) => {
return (
<select
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-surface-base text-content-primary text-sm',
'placeholder:text-content-muted',
'transition-colors duration-fast',
error
? 'border-error focus:ring-error'
: 'border-border hover:border-border-strong focus:border-brand-500',
'focus:outline-none focus:ring-2 focus:ring-brand-500/20',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
);
}
);
NativeSelect.displayName = 'NativeSelect';
@@ -0,0 +1,296 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
useSecurityHeaderProfiles,
useSecurityHeaderProfile,
useCreateSecurityHeaderProfile,
useUpdateSecurityHeaderProfile,
useDeleteSecurityHeaderProfile,
useSecurityHeaderPresets,
useApplySecurityHeaderPreset,
useCalculateSecurityScore,
useValidateCSP,
useBuildCSP,
} from '../useSecurityHeaders';
import { securityHeadersApi } from '../../api/securityHeaders';
import toast from 'react-hot-toast';
vi.mock('../../api/securityHeaders');
vi.mock('react-hot-toast');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useSecurityHeaders', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useSecurityHeaderProfiles', () => {
it('should fetch profiles successfully', async () => {
const mockProfiles = [
{ id: 1, name: 'Profile 1', security_score: 85 },
{ id: 2, name: 'Profile 2', security_score: 90 },
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const { result } = renderHook(() => useSecurityHeaderProfiles(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProfiles);
expect(securityHeadersApi.listProfiles).toHaveBeenCalledTimes(1);
});
it('should handle error when fetching profiles', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useSecurityHeaderProfiles(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(Error);
});
});
describe('useSecurityHeaderProfile', () => {
it('should fetch a single profile', async () => {
const mockProfile = { id: 1, name: 'Profile 1', security_score: 85 };
vi.mocked(securityHeadersApi.getProfile).mockResolvedValue(mockProfile as any);
const { result } = renderHook(() => useSecurityHeaderProfile(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProfile);
expect(securityHeadersApi.getProfile).toHaveBeenCalledWith(1);
});
it('should not fetch when id is undefined', () => {
const { result } = renderHook(() => useSecurityHeaderProfile(undefined), {
wrapper: createWrapper(),
});
expect(result.current.data).toBeUndefined();
expect(securityHeadersApi.getProfile).not.toHaveBeenCalled();
});
});
describe('useCreateSecurityHeaderProfile', () => {
it('should create a profile successfully', async () => {
const newProfile = { name: 'New Profile', hsts_enabled: true };
const createdProfile = { id: 1, ...newProfile, security_score: 80 };
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue(createdProfile as any);
const { result } = renderHook(() => useCreateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(newProfile as any);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.createProfile).toHaveBeenCalledWith(newProfile);
expect(toast.success).toHaveBeenCalledWith('Security header profile created successfully');
});
it('should handle error when creating profile', async () => {
vi.mocked(securityHeadersApi.createProfile).mockRejectedValue(new Error('Validation error'));
const { result } = renderHook(() => useCreateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ name: 'Test' } as any);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to create profile: Validation error');
});
});
describe('useUpdateSecurityHeaderProfile', () => {
it('should update a profile successfully', async () => {
const updateData = { name: 'Updated Profile' };
const updatedProfile = { id: 1, ...updateData, security_score: 85 };
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(updatedProfile as any);
const { result } = renderHook(() => useUpdateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, data: updateData as any });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.updateProfile).toHaveBeenCalledWith(1, updateData);
expect(toast.success).toHaveBeenCalledWith('Security header profile updated successfully');
});
it('should handle error when updating profile', async () => {
vi.mocked(securityHeadersApi.updateProfile).mockRejectedValue(new Error('Not found'));
const { result } = renderHook(() => useUpdateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, data: { name: 'Test' } as any });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to update profile: Not found');
});
});
describe('useDeleteSecurityHeaderProfile', () => {
it('should delete a profile successfully', async () => {
vi.mocked(securityHeadersApi.deleteProfile).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.deleteProfile).toHaveBeenCalledWith(1);
expect(toast.success).toHaveBeenCalledWith('Security header profile deleted successfully');
});
it('should handle error when deleting profile', async () => {
vi.mocked(securityHeadersApi.deleteProfile).mockRejectedValue(new Error('Cannot delete preset'));
const { result } = renderHook(() => useDeleteSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to delete profile: Cannot delete preset');
});
});
describe('useSecurityHeaderPresets', () => {
it('should fetch presets successfully', async () => {
const mockPresets = [
{ type: 'basic', name: 'Basic Security', score: 65 },
{ type: 'strict', name: 'Strict Security', score: 85 },
];
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue(mockPresets as any);
const { result } = renderHook(() => useSecurityHeaderPresets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockPresets);
});
});
describe('useApplySecurityHeaderPreset', () => {
it('should apply preset successfully', async () => {
const appliedProfile = { id: 1, name: 'Basic Security', security_score: 65 };
vi.mocked(securityHeadersApi.applyPreset).mockResolvedValue(appliedProfile as any);
const { result } = renderHook(() => useApplySecurityHeaderPreset(), {
wrapper: createWrapper(),
});
result.current.mutate({ preset_type: 'basic', name: 'Basic Security' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.applyPreset).toHaveBeenCalledWith({ preset_type: 'basic', name: 'Basic Security' });
expect(toast.success).toHaveBeenCalledWith('Preset applied successfully');
});
});
describe('useCalculateSecurityScore', () => {
it('should calculate score successfully', async () => {
const mockScore = {
score: 85,
max_score: 100,
breakdown: { hsts: 25, csp: 20 },
suggestions: ['Enable CSP'],
};
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(mockScore);
const { result } = renderHook(() => useCalculateSecurityScore(), {
wrapper: createWrapper(),
});
result.current.mutate({ hsts_enabled: true } as any);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockScore);
});
});
describe('useValidateCSP', () => {
it('should validate CSP successfully', async () => {
const mockValidation = { valid: true, errors: [] };
vi.mocked(securityHeadersApi.validateCSP).mockResolvedValue(mockValidation);
const { result } = renderHook(() => useValidateCSP(), {
wrapper: createWrapper(),
});
result.current.mutate("default-src 'self'");
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockValidation);
});
});
describe('useBuildCSP', () => {
it('should build CSP string successfully', async () => {
const mockDirectives = [
{ directive: 'default-src', values: ["'self'"] },
];
const mockResult = { csp: "default-src 'self'" };
vi.mocked(securityHeadersApi.buildCSP).mockResolvedValue(mockResult);
const { result } = renderHook(() => useBuildCSP(), {
wrapper: createWrapper(),
});
result.current.mutate(mockDirectives);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResult);
});
});
});
+107
View File
@@ -0,0 +1,107 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { securityHeadersApi } from '../api/securityHeaders';
import type { CreateProfileRequest, ApplyPresetRequest } from '../api/securityHeaders';
import toast from 'react-hot-toast';
export function useSecurityHeaderProfiles() {
return useQuery({
queryKey: ['securityHeaderProfiles'],
queryFn: securityHeadersApi.listProfiles,
});
}
export function useSecurityHeaderProfile(id: number | string | undefined) {
return useQuery({
queryKey: ['securityHeaderProfile', id],
queryFn: () => securityHeadersApi.getProfile(id!),
enabled: !!id,
});
}
export function useCreateSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProfileRequest) => securityHeadersApi.createProfile(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Security header profile created successfully');
},
onError: (error: Error) => {
toast.error(`Failed to create profile: ${error.message}`);
},
});
}
export function useUpdateSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<CreateProfileRequest> }) =>
securityHeadersApi.updateProfile(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfile', variables.id] });
toast.success('Security header profile updated successfully');
},
onError: (error: Error) => {
toast.error(`Failed to update profile: ${error.message}`);
},
});
}
export function useDeleteSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => securityHeadersApi.deleteProfile(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Security header profile deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete profile: ${error.message}`);
},
});
}
export function useSecurityHeaderPresets() {
return useQuery({
queryKey: ['securityHeaderPresets'],
queryFn: securityHeadersApi.getPresets,
});
}
export function useApplySecurityHeaderPreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ApplyPresetRequest) => securityHeadersApi.applyPreset(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Preset applied successfully');
},
onError: (error: Error) => {
toast.error(`Failed to apply preset: ${error.message}`);
},
});
}
export function useCalculateSecurityScore() {
return useMutation({
mutationFn: (config: Partial<CreateProfileRequest>) => securityHeadersApi.calculateScore(config),
});
}
export function useValidateCSP() {
return useMutation({
mutationFn: (csp: string) => securityHeadersApi.validateCSP(csp),
});
}
export function useBuildCSP() {
return useMutation({
mutationFn: (directives: { directive: string; values: string[] }[]) =>
securityHeadersApi.buildCSP(directives),
});
}
+345
View File
@@ -0,0 +1,345 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, Shield, Copy, Download } from 'lucide-react';
import {
useSecurityHeaderProfiles,
useSecurityHeaderPresets,
useCreateSecurityHeaderProfile,
useUpdateSecurityHeaderProfile,
useDeleteSecurityHeaderProfile,
useApplySecurityHeaderPreset,
} from '../hooks/useSecurityHeaders';
import { SecurityHeaderProfileForm } from '../components/SecurityHeaderProfileForm';
import { SecurityScoreDisplay } from '../components/SecurityScoreDisplay';
import type { SecurityHeaderProfile, CreateProfileRequest } from '../api/securityHeaders';
import { createBackup } from '../api/backups';
import toast from 'react-hot-toast';
import { PageShell } from '../components/layout/PageShell';
import {
Badge,
Button,
Alert,
Card,
EmptyState,
SkeletonTable,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../components/ui';
export default function SecurityHeaders() {
const { data: profiles, isLoading } = useSecurityHeaderProfiles();
const { data: presets } = useSecurityHeaderPresets();
const createMutation = useCreateSecurityHeaderProfile();
const updateMutation = useUpdateSecurityHeaderProfile();
const deleteMutation = useDeleteSecurityHeaderProfile();
const applyPresetMutation = useApplySecurityHeaderPreset();
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingProfile, setEditingProfile] = useState<SecurityHeaderProfile | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<SecurityHeaderProfile | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleCreate = (data: CreateProfileRequest) => {
createMutation.mutate(data, {
onSuccess: () => setShowCreateForm(false),
});
};
const handleUpdate = (data: CreateProfileRequest) => {
if (!editingProfile) return;
updateMutation.mutate(
{ id: editingProfile.id, data },
{
onSuccess: () => setEditingProfile(null),
}
);
};
const handleDeleteWithBackup = async (profile: SecurityHeaderProfile) => {
setIsDeleting(true);
try {
toast.loading('Creating backup before deletion...', { id: 'backup-toast' });
await createBackup();
toast.success('Backup created', { id: 'backup-toast' });
deleteMutation.mutate(profile.id, {
onSuccess: () => {
setShowDeleteConfirm(null);
setEditingProfile(null);
toast.success(`"${profile.name}" deleted. A backup was created before deletion.`);
},
onError: (error) => {
toast.error(`Failed to delete: ${error.message}`);
},
onSettled: () => {
setIsDeleting(false);
},
});
} catch {
toast.error('Failed to create backup', { id: 'backup-toast' });
setIsDeleting(false);
}
};
const handleApplyPreset = (presetType: string) => {
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
applyPresetMutation.mutate({ preset_type: presetType, name });
};
const handleCloneProfile = (profile: SecurityHeaderProfile) => {
const clonedData: CreateProfileRequest = {
name: `${profile.name} (Copy)`,
description: profile.description,
hsts_enabled: profile.hsts_enabled,
hsts_max_age: profile.hsts_max_age,
hsts_include_subdomains: profile.hsts_include_subdomains,
hsts_preload: profile.hsts_preload,
csp_enabled: profile.csp_enabled,
csp_directives: profile.csp_directives,
csp_report_only: profile.csp_report_only,
csp_report_uri: profile.csp_report_uri,
x_frame_options: profile.x_frame_options,
x_content_type_options: profile.x_content_type_options,
referrer_policy: profile.referrer_policy,
permissions_policy: profile.permissions_policy,
cross_origin_opener_policy: profile.cross_origin_opener_policy,
cross_origin_resource_policy: profile.cross_origin_resource_policy,
cross_origin_embedder_policy: profile.cross_origin_embedder_policy,
xss_protection: profile.xss_protection,
cache_control_no_store: profile.cache_control_no_store,
};
createMutation.mutate(clonedData);
};
const customProfiles = profiles?.filter((p) => !p.is_preset) || [];
const presetProfiles = profiles?.filter((p) => p.is_preset) || [];
return (
<PageShell
title="Security Headers"
description="Configure HTTP security headers for your proxy hosts"
actions={
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
}
>
{/* Info Alert */}
<Alert variant="info" className="mb-6">
<Shield className="w-4 h-4" />
<div>
<p className="font-semibold">Secure Your Applications</p>
<p className="text-sm mt-1">
Security headers protect against common web vulnerabilities. Use presets for quick setup or create custom
profiles for fine-grained control.
</p>
</div>
</Alert>
{/* Presets Section */}
{presets && presets.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Quick Start Presets</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{presets.map((preset) => (
<Card key={preset.type} className="p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">{preset.name}</h3>
<Badge variant={preset.type === 'basic' ? 'outline' : preset.type === 'strict' ? 'warning' : 'error'} className="mt-1">
{preset.type}
</Badge>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{preset.score}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Score</div>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{preset.description}</p>
<Button
variant="outline"
size="sm"
onClick={() => handleApplyPreset(preset.type)}
disabled={applyPresetMutation.isPending}
className="w-full"
>
<Download className="w-4 h-4 mr-2" />
Apply Preset
</Button>
</Card>
))}
</div>
</div>
)}
{/* System Presets (Read-Only) */}
{presetProfiles.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">System Presets</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presetProfiles.map((profile) => (
<Card key={profile.id} className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
<Badge variant="outline" className="mt-1">System Preset</Badge>
</div>
<SecurityScoreDisplay
score={profile.security_score}
size="sm"
showDetails={false}
/>
</div>
{profile.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{profile.description}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(profile)}
className="flex-1"
>
<Pencil className="w-3 h-3 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCloneProfile(profile)}
className="flex-1"
>
<Copy className="w-3 h-3 mr-1" />
Clone
</Button>
</div>
</Card>
))}
</div>
</div>
)}
{/* Custom Profiles Section */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Custom Profiles</h2>
{isLoading ? (
<SkeletonTable rows={3} />
) : customProfiles.length === 0 ? (
<EmptyState
icon={<Shield className="w-12 h-12" />}
title="No custom profiles yet"
description="Create a custom security header profile or apply a preset to get started"
action={{
label: 'Create Profile',
onClick: () => setShowCreateForm(true),
}}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{customProfiles.map((profile) => (
<Card key={profile.id} className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Updated {new Date(profile.updated_at).toLocaleDateString()}
</p>
</div>
<SecurityScoreDisplay
score={profile.security_score}
size="sm"
showDetails={false}
/>
</div>
{profile.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{profile.description}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(profile)}
className="flex-1"
>
<Pencil className="w-3 h-3 mr-1" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCloneProfile(profile)}
>
<Copy className="w-3 h-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(profile)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</Card>
))}
</div>
)}
</div>
{/* Create/Edit Dialog */}
<Dialog open={showCreateForm || editingProfile !== null} onOpenChange={(open) => {
if (!open) {
setShowCreateForm(false);
setEditingProfile(null);
}
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingProfile ? (editingProfile.is_preset ? 'View' : 'Edit') : 'Create'} Security Header Profile
</DialogTitle>
</DialogHeader>
<SecurityHeaderProfileForm
initialData={editingProfile || undefined}
onSubmit={editingProfile ? handleUpdate : handleCreate}
onCancel={() => {
setShowCreateForm(false);
setEditingProfile(null);
}}
onDelete={editingProfile && !editingProfile.is_preset ? () => setShowDeleteConfirm(editingProfile) : undefined}
isLoading={createMutation.isPending || updateMutation.isPending}
isDeleting={isDeleting}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm !== null} onOpenChange={(open) => !open && setShowDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 dark:text-gray-400">
Are you sure you want to delete "{showDeleteConfirm?.name}"? A backup will be created before deletion.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteConfirm(null)} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="danger"
onClick={() => showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageShell>
);
}
@@ -0,0 +1,342 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import SecurityHeaders from '../../pages/SecurityHeaders';
import { securityHeadersApi } from '../../api/securityHeaders';
import { createBackup } from '../../api/backups';
vi.mock('../../api/securityHeaders');
vi.mock('../../api/backups');
vi.mock('react-hot-toast');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
};
describe('SecurityHeaders', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render loading state', () => {
vi.mocked(securityHeadersApi.listProfiles).mockImplementation(() => new Promise(() => {}));
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
expect(screen.getByText('Security Headers')).toBeInTheDocument();
});
it('should render empty state', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('No custom profiles yet')).toBeInTheDocument();
});
});
it('should render list of profiles', async () => {
const mockProfiles = [
{
id: 1,
name: 'Profile 1',
is_preset: false,
security_score: 85,
updated_at: '2025-12-18T00:00:00Z',
},
{
id: 2,
name: 'Profile 2',
is_preset: false,
security_score: 90,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Profile 1')).toBeInTheDocument();
expect(screen.getByText('Profile 2')).toBeInTheDocument();
});
});
it('should render presets', async () => {
const mockPresets = [
{
type: 'basic' as const,
name: 'Basic Security',
description: 'Essential headers',
score: 65,
config: {},
},
{
type: 'strict' as const,
name: 'Strict Security',
description: 'Strong security',
score: 85,
config: {},
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue(mockPresets);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Basic Security')).toBeInTheDocument();
expect(screen.getByText('Strict Security')).toBeInTheDocument();
});
});
it('should open create form dialog', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /Create Profile/ })).toBeInTheDocument();
});
const createButton = screen.getAllByRole('button', { name: /Create Profile/ })[0];
fireEvent.click(createButton);
await waitFor(() => {
expect(screen.getByText(/Create Security Header Profile/)).toBeInTheDocument();
});
});
it('should open edit dialog', async () => {
const mockProfiles = [
{
id: 1,
name: 'Test Profile',
is_preset: false,
security_score: 85,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
score: 85,
max_score: 100,
breakdown: {},
suggestions: [],
});
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Test Profile')).toBeInTheDocument();
});
const editButton = screen.getByRole('button', { name: /Edit/ });
fireEvent.click(editButton);
await waitFor(() => {
expect(screen.getByText(/Edit Security Header Profile/)).toBeInTheDocument();
});
});
it('should apply preset', async () => {
const mockPresets = [
{
type: 'basic' as const,
name: 'Basic Security',
description: 'Essential headers',
score: 65,
config: {},
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue(mockPresets);
vi.mocked(securityHeadersApi.applyPreset).mockResolvedValue({
id: 1,
name: 'Basic Security Profile',
security_score: 65,
} as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Basic Security')).toBeInTheDocument();
});
const applyButton = screen.getByRole('button', { name: /Apply Preset/ });
fireEvent.click(applyButton);
await waitFor(() => {
expect(securityHeadersApi.applyPreset).toHaveBeenCalledWith({
preset_type: 'basic',
name: 'Basic Security Profile',
});
});
});
it('should clone profile', async () => {
const mockProfiles = [
{
id: 1,
name: 'Original Profile',
description: 'Test description',
is_preset: false,
security_score: 85,
hsts_enabled: true,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({
id: 2,
name: 'Original Profile (Copy)',
security_score: 85,
} as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Original Profile')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const cloneButton = buttons.find(btn => btn.querySelector('.lucide-copy'));
if (cloneButton) {
fireEvent.click(cloneButton);
}
await waitFor(() => {
expect(securityHeadersApi.createProfile).toHaveBeenCalled();
});
const createCall = vi.mocked(securityHeadersApi.createProfile).mock.calls[0][0];
expect(createCall.name).toBe('Original Profile (Copy)');
});
it('should delete profile with backup', async () => {
const mockProfiles = [
{
id: 1,
name: 'Test Profile',
is_preset: false,
security_score: 85,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockResolvedValue({ id: 1 } as any);
vi.mocked(securityHeadersApi.deleteProfile).mockResolvedValue(undefined);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Test Profile')).toBeInTheDocument();
});
// Click delete button
const buttons = screen.getAllByRole('button');
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
if (deleteButton) {
fireEvent.click(deleteButton);
}
// Confirm deletion - wait for the dialog to appear
await waitFor(() => {
const headings = screen.getAllByText(/Confirm Deletion/i);
expect(headings.length).toBeGreaterThan(0);
}, { timeout: 2000 });
const confirmButton = screen.getByRole('button', { name: /Delete/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(createBackup).toHaveBeenCalled();
expect(securityHeadersApi.deleteProfile).toHaveBeenCalledWith(1);
});
});
it('should separate system presets from custom profiles', async () => {
const mockProfiles = [
{
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 85,
updated_at: '2025-12-18T00:00:00Z',
},
{
id: 2,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('System Presets')).toBeInTheDocument();
expect(screen.getByText('Custom Profiles')).toBeInTheDocument();
});
// System preset should have "View" and "Clone" buttons
const presetCard = screen.getByText('Basic Security').closest('div');
expect(presetCard?.textContent).toContain('System Preset');
// Custom profile should have "Edit" and delete buttons
const customCard = screen.getByText('Custom Profile').closest('div');
expect(customCard?.textContent).toContain('Custom Profile');
});
it('should display security scores', async () => {
const mockProfiles = [
{
id: 1,
name: 'High Score Profile',
is_preset: false,
security_score: 95,
updated_at: '2025-12-18T00:00:00Z',
},
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('95')).toBeInTheDocument();
});
});
});