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

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

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

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

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

Closes #20
This commit is contained in:
GitHub Actions
2025-12-18 02:58:26 +00:00
parent 01ec910d58
commit 8cf762164f
33 changed files with 7978 additions and 69 deletions
@@ -0,0 +1,296 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
useSecurityHeaderProfiles,
useSecurityHeaderProfile,
useCreateSecurityHeaderProfile,
useUpdateSecurityHeaderProfile,
useDeleteSecurityHeaderProfile,
useSecurityHeaderPresets,
useApplySecurityHeaderPreset,
useCalculateSecurityScore,
useValidateCSP,
useBuildCSP,
} from '../useSecurityHeaders';
import { securityHeadersApi } from '../../api/securityHeaders';
import toast from 'react-hot-toast';
vi.mock('../../api/securityHeaders');
vi.mock('react-hot-toast');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useSecurityHeaders', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useSecurityHeaderProfiles', () => {
it('should fetch profiles successfully', async () => {
const mockProfiles = [
{ id: 1, name: 'Profile 1', security_score: 85 },
{ id: 2, name: 'Profile 2', security_score: 90 },
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const { result } = renderHook(() => useSecurityHeaderProfiles(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProfiles);
expect(securityHeadersApi.listProfiles).toHaveBeenCalledTimes(1);
});
it('should handle error when fetching profiles', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useSecurityHeaderProfiles(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(Error);
});
});
describe('useSecurityHeaderProfile', () => {
it('should fetch a single profile', async () => {
const mockProfile = { id: 1, name: 'Profile 1', security_score: 85 };
vi.mocked(securityHeadersApi.getProfile).mockResolvedValue(mockProfile as any);
const { result } = renderHook(() => useSecurityHeaderProfile(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProfile);
expect(securityHeadersApi.getProfile).toHaveBeenCalledWith(1);
});
it('should not fetch when id is undefined', () => {
const { result } = renderHook(() => useSecurityHeaderProfile(undefined), {
wrapper: createWrapper(),
});
expect(result.current.data).toBeUndefined();
expect(securityHeadersApi.getProfile).not.toHaveBeenCalled();
});
});
describe('useCreateSecurityHeaderProfile', () => {
it('should create a profile successfully', async () => {
const newProfile = { name: 'New Profile', hsts_enabled: true };
const createdProfile = { id: 1, ...newProfile, security_score: 80 };
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue(createdProfile as any);
const { result } = renderHook(() => useCreateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(newProfile as any);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.createProfile).toHaveBeenCalledWith(newProfile);
expect(toast.success).toHaveBeenCalledWith('Security header profile created successfully');
});
it('should handle error when creating profile', async () => {
vi.mocked(securityHeadersApi.createProfile).mockRejectedValue(new Error('Validation error'));
const { result } = renderHook(() => useCreateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ name: 'Test' } as any);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to create profile: Validation error');
});
});
describe('useUpdateSecurityHeaderProfile', () => {
it('should update a profile successfully', async () => {
const updateData = { name: 'Updated Profile' };
const updatedProfile = { id: 1, ...updateData, security_score: 85 };
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(updatedProfile as any);
const { result } = renderHook(() => useUpdateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, data: updateData as any });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.updateProfile).toHaveBeenCalledWith(1, updateData);
expect(toast.success).toHaveBeenCalledWith('Security header profile updated successfully');
});
it('should handle error when updating profile', async () => {
vi.mocked(securityHeadersApi.updateProfile).mockRejectedValue(new Error('Not found'));
const { result } = renderHook(() => useUpdateSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, data: { name: 'Test' } as any });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to update profile: Not found');
});
});
describe('useDeleteSecurityHeaderProfile', () => {
it('should delete a profile successfully', async () => {
vi.mocked(securityHeadersApi.deleteProfile).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.deleteProfile).toHaveBeenCalledWith(1);
expect(toast.success).toHaveBeenCalledWith('Security header profile deleted successfully');
});
it('should handle error when deleting profile', async () => {
vi.mocked(securityHeadersApi.deleteProfile).mockRejectedValue(new Error('Cannot delete preset'));
const { result } = renderHook(() => useDeleteSecurityHeaderProfile(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Failed to delete profile: Cannot delete preset');
});
});
describe('useSecurityHeaderPresets', () => {
it('should fetch presets successfully', async () => {
const mockPresets = [
{ type: 'basic', name: 'Basic Security', score: 65 },
{ type: 'strict', name: 'Strict Security', score: 85 },
];
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue(mockPresets as any);
const { result } = renderHook(() => useSecurityHeaderPresets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockPresets);
});
});
describe('useApplySecurityHeaderPreset', () => {
it('should apply preset successfully', async () => {
const appliedProfile = { id: 1, name: 'Basic Security', security_score: 65 };
vi.mocked(securityHeadersApi.applyPreset).mockResolvedValue(appliedProfile as any);
const { result } = renderHook(() => useApplySecurityHeaderPreset(), {
wrapper: createWrapper(),
});
result.current.mutate({ preset_type: 'basic', name: 'Basic Security' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(securityHeadersApi.applyPreset).toHaveBeenCalledWith({ preset_type: 'basic', name: 'Basic Security' });
expect(toast.success).toHaveBeenCalledWith('Preset applied successfully');
});
});
describe('useCalculateSecurityScore', () => {
it('should calculate score successfully', async () => {
const mockScore = {
score: 85,
max_score: 100,
breakdown: { hsts: 25, csp: 20 },
suggestions: ['Enable CSP'],
};
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(mockScore);
const { result } = renderHook(() => useCalculateSecurityScore(), {
wrapper: createWrapper(),
});
result.current.mutate({ hsts_enabled: true } as any);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockScore);
});
});
describe('useValidateCSP', () => {
it('should validate CSP successfully', async () => {
const mockValidation = { valid: true, errors: [] };
vi.mocked(securityHeadersApi.validateCSP).mockResolvedValue(mockValidation);
const { result } = renderHook(() => useValidateCSP(), {
wrapper: createWrapper(),
});
result.current.mutate("default-src 'self'");
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockValidation);
});
});
describe('useBuildCSP', () => {
it('should build CSP string successfully', async () => {
const mockDirectives = [
{ directive: 'default-src', values: ["'self'"] },
];
const mockResult = { csp: "default-src 'self'" };
vi.mocked(securityHeadersApi.buildCSP).mockResolvedValue(mockResult);
const { result } = renderHook(() => useBuildCSP(), {
wrapper: createWrapper(),
});
result.current.mutate(mockDirectives);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResult);
});
});
});
+107
View File
@@ -0,0 +1,107 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { securityHeadersApi } from '../api/securityHeaders';
import type { CreateProfileRequest, ApplyPresetRequest } from '../api/securityHeaders';
import toast from 'react-hot-toast';
export function useSecurityHeaderProfiles() {
return useQuery({
queryKey: ['securityHeaderProfiles'],
queryFn: securityHeadersApi.listProfiles,
});
}
export function useSecurityHeaderProfile(id: number | string | undefined) {
return useQuery({
queryKey: ['securityHeaderProfile', id],
queryFn: () => securityHeadersApi.getProfile(id!),
enabled: !!id,
});
}
export function useCreateSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProfileRequest) => securityHeadersApi.createProfile(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Security header profile created successfully');
},
onError: (error: Error) => {
toast.error(`Failed to create profile: ${error.message}`);
},
});
}
export function useUpdateSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<CreateProfileRequest> }) =>
securityHeadersApi.updateProfile(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfile', variables.id] });
toast.success('Security header profile updated successfully');
},
onError: (error: Error) => {
toast.error(`Failed to update profile: ${error.message}`);
},
});
}
export function useDeleteSecurityHeaderProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => securityHeadersApi.deleteProfile(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Security header profile deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete profile: ${error.message}`);
},
});
}
export function useSecurityHeaderPresets() {
return useQuery({
queryKey: ['securityHeaderPresets'],
queryFn: securityHeadersApi.getPresets,
});
}
export function useApplySecurityHeaderPreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ApplyPresetRequest) => securityHeadersApi.applyPreset(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Preset applied successfully');
},
onError: (error: Error) => {
toast.error(`Failed to apply preset: ${error.message}`);
},
});
}
export function useCalculateSecurityScore() {
return useMutation({
mutationFn: (config: Partial<CreateProfileRequest>) => securityHeadersApi.calculateScore(config),
});
}
export function useValidateCSP() {
return useMutation({
mutationFn: (csp: string) => securityHeadersApi.validateCSP(csp),
});
}
export function useBuildCSP() {
return useMutation({
mutationFn: (directives: { directive: string; values: string[] }[]) =>
securityHeadersApi.buildCSP(directives),
});
}