Files
Charon/frontend/src/components/__tests__/CSPBuilder.test.tsx
GitHub Actions 8cf762164f 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
2025-12-19 18:55:48 +00:00

236 lines
7.7 KiB
TypeScript

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();
});
});
});