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:
235
frontend/src/components/__tests__/CSPBuilder.test.tsx
Normal file
235
frontend/src/components/__tests__/CSPBuilder.test.tsx
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
152
frontend/src/components/__tests__/SecurityScoreDisplay.test.tsx
Normal file
152
frontend/src/components/__tests__/SecurityScoreDisplay.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user