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:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user