Files
Charon/frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx
T
2026-01-26 19:22:05 +00:00

456 lines
17 KiB
TypeScript

import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import ProxyHosts from '../ProxyHosts';
import * as proxyHostsApi from '../../api/proxyHosts';
import * as certificatesApi from '../../api/certificates';
import type { Certificate } from '../../api/certificates';
import type { ProxyHost } from '../../api/proxyHosts';
import * as accessListsApi from '../../api/accessLists';
import type { AccessList } from '../../api/accessLists';
import * as settingsApi from '../../api/settings';
import * as securityHeadersApi from '../../api/securityHeaders';
import type { SecurityHeaderProfile } from '../../api/securityHeaders';
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
// Mock toast
vi.mock('react-hot-toast', () => ({
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: vi.fn(),
createProxyHost: vi.fn(),
updateProxyHost: vi.fn(),
deleteProxyHost: vi.fn(),
bulkUpdateACL: vi.fn(),
bulkUpdateSecurityHeaders: vi.fn(),
testProxyHostConnection: vi.fn(),
}));
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
vi.mock('../../api/securityHeaders', () => ({
securityHeadersApi: {
listProfiles: vi.fn(),
},
}));
const mockProxyHosts = [
createMockProxyHost({
uuid: 'host-1',
name: 'Test Host 1',
domain_names: 'test1.example.com',
forward_host: '192.168.1.10',
}),
createMockProxyHost({
uuid: 'host-2',
name: 'Test Host 2',
domain_names: 'test2.example.com',
forward_host: '192.168.1.20',
}),
];
const mockSecurityProfiles: SecurityHeaderProfile[] = [
{
id: 1,
uuid: 'profile-1',
name: 'Strict Security',
description: 'Maximum security headers',
security_score: 95,
is_preset: true,
preset_type: 'strict',
hsts_enabled: true,
hsts_max_age: 31536000,
hsts_include_subdomains: true,
hsts_preload: true,
x_frame_options: 'DENY',
x_content_type_options: true,
xss_protection: true,
referrer_policy: 'no-referrer',
permissions_policy: '',
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
cross_origin_opener_policy: 'same-origin',
cross_origin_resource_policy: 'same-origin',
cross_origin_embedder_policy: '',
cache_control_no_store: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: 2,
uuid: 'profile-2',
name: 'Moderate Security',
description: 'Balanced security headers',
security_score: 75,
is_preset: true,
preset_type: 'basic',
hsts_enabled: true,
hsts_max_age: 31536000,
hsts_include_subdomains: false,
hsts_preload: false,
x_frame_options: 'SAMEORIGIN',
x_content_type_options: true,
xss_protection: true,
referrer_policy: 'strict-origin-when-cross-origin',
permissions_policy: '',
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
cross_origin_opener_policy: 'same-origin',
cross_origin_resource_policy: 'same-origin',
cross_origin_embedder_policy: '',
cache_control_no_store: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: 3,
uuid: 'profile-3',
name: 'Custom Profile',
description: 'My custom headers',
security_score: 60,
is_preset: false,
preset_type: '',
hsts_enabled: false,
hsts_max_age: 0,
hsts_include_subdomains: false,
hsts_preload: false,
x_frame_options: 'SAMEORIGIN',
x_content_type_options: true,
xss_protection: true,
referrer_policy: 'same-origin',
permissions_policy: '',
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
cross_origin_opener_policy: 'same-origin',
cross_origin_resource_policy: 'same-origin',
cross_origin_embedder_policy: '',
cache_control_no_store: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
const createQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
const renderWithProviders = (ui: React.ReactNode) => {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
};
describe('ProxyHosts - Bulk Apply Security Headers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
vi.mocked(securityHeadersApi.securityHeadersApi.listProfiles).mockResolvedValue(
mockSecurityProfiles
);
});
it('shows security header profile option in bulk apply modal', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
// Open Bulk Apply modal
const bulkApplyButton = screen.getByText('Bulk Apply');
await userEvent.click(bulkApplyButton);
// Check for security header profile section
await waitFor(() => {
expect(screen.getByText('Security Header Profile')).toBeTruthy();
expect(
screen.getByText('Apply a security header profile to all selected hosts')
).toBeTruthy();
});
});
it('enables profile selection when checkbox is checked', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
// Find security header checkbox
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
// Dropdown should not be visible initially
expect(screen.queryByRole('combobox')).toBeNull();
// Click checkbox to enable
await userEvent.click(securityHeaderCheckbox);
// Dropdown should now be visible
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeTruthy();
});
});
it('lists all available profiles in dropdown grouped correctly', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
// Check dropdown options
await waitFor(() => {
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
expect(dropdown).toBeTruthy();
// Check for "None" option
const noneOption = within(dropdown).getByText(/None \(Remove Profile\)/i);
expect(noneOption).toBeTruthy();
// Check for preset profiles
expect(within(dropdown).getByText(/Strict Security/)).toBeTruthy();
expect(within(dropdown).getByText(/Moderate Security/)).toBeTruthy();
// Check for custom profiles
expect(within(dropdown).getByText(/Custom Profile/)).toBeTruthy();
});
});
it('applies security header profile to selected hosts using bulk endpoint', async () => {
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
// Select a profile
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
await userEvent.selectOptions(dropdown, '1'); // Select profile ID 1
// Click Apply
const dialog = screen.getByRole('dialog');
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
await userEvent.click(applyButton);
// Verify bulk endpoint was called with correct parameters
await waitFor(() => {
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], 1);
});
});
it('removes security header profile when "None" selected', async () => {
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
bulkUpdateMock.mockResolvedValue({ updated: 2, errors: [] });
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
// Select "None" (value 0)
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
await userEvent.selectOptions(dropdown, '0');
// Verify warning is shown
await waitFor(() => {
expect(
screen.getByText(
/This will remove the security header profile from all selected hosts/
)
).toBeTruthy();
});
// Click Apply
const dialog = screen.getByRole('dialog');
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
await userEvent.click(applyButton);
// Verify null was sent to API (remove profile)
await waitFor(() => {
expect(bulkUpdateMock).toHaveBeenCalledWith(['host-1', 'host-2'], null);
});
});
it('disables Apply button when no options selected', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
// Apply button should be disabled when nothing is selected
const dialog = screen.getByRole('dialog');
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
expect(applyButton).toHaveProperty('disabled', true);
});
it('handles partial failure with appropriate toast', async () => {
const bulkUpdateMock = vi.mocked(proxyHostsApi.bulkUpdateSecurityHeaders);
bulkUpdateMock.mockResolvedValue({
updated: 1,
errors: [{ uuid: 'host-2', error: 'Profile not found' }],
});
const toast = await import('react-hot-toast');
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option and select a profile
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
await userEvent.selectOptions(dropdown, '1');
// Click Apply
const dialog = screen.getByRole('dialog');
const applyButton = within(dialog).getByRole('button', { name: /^Apply$/i });
await userEvent.click(applyButton);
// Verify error toast was called
await waitFor(() => {
expect(toast.toast.error).toHaveBeenCalled();
});
});
it('resets state on modal close', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option and select a profile
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
await userEvent.selectOptions(dropdown, '1');
// Close modal
const dialog = screen.getByRole('dialog');
const cancelButton = within(dialog).getByRole('button', { name: /Cancel/i });
await userEvent.click(cancelButton);
// Re-open modal
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
await userEvent.click(screen.getByText('Bulk Apply'));
// Security header checkbox should be unchecked (state was reset)
await waitFor(() => {
const securityHeaderLabel2 = screen.getByText('Security Header Profile');
const securityHeaderRow2 = securityHeaderLabel2.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox2 = within(securityHeaderRow2).getByRole('checkbox');
expect(securityHeaderCheckbox2).toHaveAttribute('data-state', 'unchecked');
});
});
it('shows profile description when profile is selected', async () => {
renderWithProviders(<ProxyHosts />);
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
// Select hosts and open modal
const selectAll = screen.getByLabelText('Select all rows');
await userEvent.click(selectAll);
await userEvent.click(screen.getByText('Bulk Apply'));
// Enable security header option
const securityHeaderLabel = screen.getByText('Security Header Profile');
const securityHeaderRow = securityHeaderLabel.closest('.p-3') as HTMLElement;
const securityHeaderCheckbox = within(securityHeaderRow).getByRole('checkbox');
await userEvent.click(securityHeaderCheckbox);
// Select a profile
await waitFor(() => expect(screen.getByRole('combobox')).toBeTruthy());
const dropdown = screen.getByRole('combobox') as HTMLSelectElement;
await userEvent.selectOptions(dropdown, '1'); // Strict Security
// Verify description is shown
await waitFor(() => {
expect(screen.getByText('Maximum security headers')).toBeTruthy();
});
});
});