Files
Charon/frontend/src/components/__tests__/SecurityHeaderProfileForm.test.tsx
GitHub Actions 10582872f9 fix(tests): Enhance CrowdSecConfig with new input fields and improve accessibility
- Added IDs to input fields in CrowdSecConfig for better accessibility.
- Updated labels to use <label> elements for checkboxes and inputs.
- Improved error handling and user feedback in the CrowdSecConfig tests.
- Enhanced test coverage for console enrollment and banned IP functionalities.

fix: Update SecurityHeaders to include aria-label for delete button

- Added aria-label to the delete button for better screen reader support.

test: Add comprehensive tests for proxyHostsHelpers and validation utilities

- Implemented tests for formatting and help text functions in proxyHostsHelpers.
- Added validation tests for email and IP address formats.

chore: Update vitest configuration for dynamic coverage thresholds

- Adjusted coverage thresholds to be dynamic based on environment variables.
- Included additional coverage reporters.

chore: Update frontend-test-coverage script to reflect new coverage threshold

- Increased minimum coverage requirement from 85% to 87.5%.

fix: Ensure tests pass with consistent data in passwd file

- Updated tests/etc/passwd to ensure consistent content.
2026-02-06 17:38:08 +00:00

436 lines
14 KiB
TypeScript

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, type SecurityHeaderProfile } from '../../api/securityHeaders';
vi.mock('../../api/securityHeaders');
// Mock child components that are complex or have their own tests
vi.mock('../CSPBuilder', () => ({
CSPBuilder: ({
value,
onChange,
onValidate,
}: {
value: string;
onChange: (v: string) => void;
onValidate: (v: boolean, e: string[]) => void;
}) => (
<div data-testid="csp-builder">
<input
data-testid="csp-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button type="button" data-testid="csp-valid" onClick={() => onValidate(true, [])}>
Set Valid
</button>
<button type="button" data-testid="csp-invalid" onClick={() => onValidate(false, ['Error'])}>
Set Invalid
</button>
</div>
),
}));
vi.mock('../PermissionsPolicyBuilder', () => ({
PermissionsPolicyBuilder: ({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) => (
<div data-testid="permissions-builder">
<input
data-testid="permissions-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
),
}));
vi.mock('../SecurityScoreDisplay', () => ({
SecurityScoreDisplay: ({
score,
maxScore,
}: {
score: number;
maxScore: number;
}) => (
<div data-testid="security-score">
Score: {score}/{maxScore}
</div>
),
}));
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();
expect(
screen.getByText('Profile Information')
).toBeInTheDocument();
});
it('should render with initial data', () => {
const initialData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Test Profile',
description: 'Test description',
hsts_enabled: true,
hsts_max_age: 31536000,
security_score: 85,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={initialData as SecurityHeaderProfile}
/>,
{ 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 call onDelete when delete button clicked', () => {
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={{ id: 1, name: 'Test', is_preset: false } as SecurityHeaderProfile}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
const deleteButton = screen.getByRole('button', { name: /Delete Profile/ });
fireEvent.click(deleteButton);
expect(mockOnDelete).toHaveBeenCalled();
});
it('should toggle HSTS enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// HSTS is true by default
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 and handle updates', async () => {
const initialData: Partial<SecurityHeaderProfile> = {
hsts_enabled: true,
hsts_max_age: 1000,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as SecurityHeaderProfile} />,
{ wrapper: createWrapper() }
);
const maxAgeInput = screen.getByDisplayValue('1000');
fireEvent.change(maxAgeInput, { target: { value: '63072000' } });
// Try include subdomains toggle
const includeSubdomainsText = screen.getByText('Include Subdomains');
const includeSubdomainsContainer = includeSubdomainsText.closest('div')?.parentElement;
const includeSubdomainsToggle = includeSubdomainsContainer?.querySelector('input[type="checkbox"]');
if(includeSubdomainsToggle) {
fireEvent.click(includeSubdomainsToggle);
}
// Check submit gets updated values
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'HSTS Update' } });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
await waitFor(() => {
const submitted = mockOnSubmit.mock.calls[0][0];
expect(submitted.hsts_max_age).toBe(63072000);
});
});
it('should show preload warning when enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const preloadText = screen.getByText('Preload');
const preloadContainer = preloadText.closest('div')?.parentElement;
const preloadSwitch = preloadContainer?.querySelector('input[type="checkbox"]');
if (preloadSwitch) {
fireEvent.click(preloadSwitch);
}
await waitFor(() => {
expect(screen.getByText(/Warning: HSTS Preload is Permanent/)).toBeInTheDocument();
});
});
it('should toggle CSP enabled and show CSP builder', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const cspSection = screen
.getByText('Content Security Policy (CSP)')
.closest('div');
const cspCheckbox = cspSection?.querySelector('input[type="checkbox"]');
if (cspCheckbox) {
fireEvent.click(cspCheckbox); // Enable CSP (default is false)
}
await waitFor(() => {
expect(screen.getByTestId('csp-builder')).toBeInTheDocument();
});
// Test that submit button is disabled when CSP is invalid
const invalidButton = screen.getByTestId('csp-invalid');
fireEvent.click(invalidButton);
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
expect(submitButton).toBeDisabled();
// Re-enable
const validButton = screen.getByTestId('csp-valid');
fireEvent.click(validButton);
expect(submitButton).not.toBeDisabled();
// Update CSP value through mock
const cspInput = screen.getByTestId('csp-input');
fireEvent.change(cspInput, { target: { value: '{"test": "val"}' } });
});
it('should handle CSP report only URI', async () => {
const initialData: Partial<SecurityHeaderProfile> = {
csp_enabled: true,
csp_report_only: true, // Report only enabled
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as SecurityHeaderProfile} />,
{ wrapper: createWrapper() }
);
const reportUriInput = screen.getByPlaceholderText(/example.com\/csp-report/);
fireEvent.change(reportUriInput, { target: { value: 'https://test.com/report' } });
expect(reportUriInput).toHaveValue('https://test.com/report');
// Verify toggle for report only
const reportOnlyText = screen.getByText('Report-Only Mode');
const reportOnlyContainer = reportOnlyText.closest('div')?.parentElement;
const reportOnlySwitch = reportOnlyContainer?.querySelector('input[type="checkbox"]');
if(reportOnlySwitch) {
fireEvent.click(reportOnlySwitch); // Disable
expect(screen.queryByPlaceholderText(/example.com\/csp-report/)).not.toBeInTheDocument();
}
});
it('should disable form for presets', () => {
const presetData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={presetData as SecurityHeaderProfile}
/>,
{ 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();
expect(screen.queryByRole('button', { name: /Delete Profile/ })).not.toBeInTheDocument();
});
it('should handle cross origin policies', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Use traversing to find selects since labels are not associated
// Order: X-Frame, Referrer, Opener, Resource, Embedder
const selects = screen.getAllByRole('combobox');
// Verify we have the expected number of selects (5 standard + potential others?)
// X-Frame-Options is index 0
// Referrer-Policy is index 1
// Cross-Origin-Opener-Policy is index 2
// Cross-Origin-Resource-Policy is index 3
// Cross-Origin-Embedder-Policy is index 4
expect(selects.length).toBeGreaterThanOrEqual(5);
const openerPolicy = selects[2];
expect(openerPolicy).toHaveValue('same-origin');
fireEvent.change(openerPolicy, { target: { value: 'unsafe-none' } });
expect(openerPolicy).toHaveValue('unsafe-none');
const resourcePolicy = selects[3];
expect(resourcePolicy).toHaveValue('same-origin');
fireEvent.change(resourcePolicy, { target: { value: 'same-site' } });
expect(resourcePolicy).toHaveValue('same-site');
const embedderPolicy = selects[4];
// Default is likely empty string per component default
fireEvent.change(embedderPolicy, { target: { value: 'require-corp' } });
expect(embedderPolicy).toHaveValue('require-corp');
});
it('should handle additional options', () => {
// xss_protection defaults to true
// cache_control_no_store defaults to false
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const xssSection = screen.getByText('X-XSS-Protection').closest('div')?.parentElement;
const xssSwitch = xssSection?.querySelector('input[type="checkbox"]');
expect(xssSwitch).toBeChecked(); // Default true
if(xssSwitch) fireEvent.click(xssSwitch);
expect(xssSwitch).not.toBeChecked();
const cacheSection = screen.getByText('Cache-Control: no-store').closest('div')?.parentElement;
const cacheSwitch = cacheSection?.querySelector('input[type="checkbox"]');
expect(cacheSwitch).not.toBeChecked(); // Default false
if(cacheSwitch) fireEvent.click(cacheSwitch);
expect(cacheSwitch).toBeChecked();
});
it('should update permissions policy', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const permissionsInput = screen.getByTestId('permissions-input');
fireEvent.change(permissionsInput, { target: { value: 'geolocation=()' } });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'PP Update' } });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
const submitted = mockOnSubmit.mock.calls[0][0];
expect(submitted.permissions_policy).toBe('geolocation=()');
});
it('should show security score', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('security-score')).toBeInTheDocument();
expect(screen.getByText('Score: 85/100')).toBeInTheDocument();
});
});
it('should calculate score after debounce', async () => {
// Use real timers for simplicity with debounce
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Clear initial calls from mount
vi.clearAllMocks();
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'Checking Debounce' } });
// Should not have called immediately
expect(securityHeadersApi.calculateScore).not.toHaveBeenCalled();
// Wait for debounce (500ms) + buffer
await waitFor(() => {
expect(securityHeadersApi.calculateScore).toHaveBeenCalled();
}, { timeout: 1500 });
});
});