chore: clean git cache
This commit is contained in:
@@ -1,570 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { AccessListForm } from '../AccessListForm';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as systemApi from '../../api/system';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
vi.mock('../../api/system', () => ({
|
||||
getMyIP: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for any layout dependent components
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AccessListForm', () => {
|
||||
const mockSubmit = vi.fn();
|
||||
const mockCancel = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(systemApi.getMyIP).mockResolvedValue({ ip: '1.2.3.4', source: 'test' });
|
||||
});
|
||||
|
||||
it('renders basic form fields', () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Type/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits valid data', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Test List');
|
||||
await user.type(screen.getByLabelText(/Description/i), 'Description test');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Test List',
|
||||
description: 'Description test',
|
||||
type: 'whitelist',
|
||||
enabled: true
|
||||
}));
|
||||
});
|
||||
|
||||
it('loads initial data correctly', () => {
|
||||
const initialData = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing List',
|
||||
description: 'Existing Description',
|
||||
type: 'blacklist' as const,
|
||||
ip_rules: JSON.stringify([{ cidr: '10.0.0.1', description: 'Test IP' }]),
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
render(<AccessListForm initialData={initialData} onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Existing List')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Existing Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Update/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles IP rule addition and removal', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const ipInput = screen.getByPlaceholderText(/192.168.1.0\/24/i);
|
||||
const descInput = screen.getByPlaceholderText(/Description \(optional\)/i);
|
||||
|
||||
await user.type(ipInput, '1.2.3.4');
|
||||
await user.type(descInput, 'Test IP');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByText('1.2.3.4')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test IP')).toBeInTheDocument();
|
||||
|
||||
// Remove - look for button with X icon (lucide-x)
|
||||
// We use querySelector because the icon is inside the button
|
||||
const removeButton = screen.getAllByRole('button').find(b => b.querySelector('.lucide-x'));
|
||||
|
||||
if (removeButton) {
|
||||
await user.click(removeButton);
|
||||
expect(screen.queryByText('1.2.3.4')).not.toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error('Remove button not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('fetches and populates My IP', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const getIpButton = screen.getByRole('button', { name: /Get My IP/i });
|
||||
await user.click(getIpButton);
|
||||
|
||||
expect(systemApi.getMyIP).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/192.168.1.0\/24/i)).toHaveValue('1.2.3.4');
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles Geo type selection and country addition', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'geo_blacklist');
|
||||
|
||||
expect(screen.getByText(/Select Countries/i)).toBeInTheDocument();
|
||||
|
||||
// Use getByLabelText now that we fixed accessibility
|
||||
const countrySelect = screen.getByLabelText(/Select Countries/i);
|
||||
|
||||
// Select US
|
||||
await user.selectOptions(countrySelect, 'US');
|
||||
|
||||
expect(screen.getByText(/United States/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDelete when delete button is clicked', async () => {
|
||||
render(
|
||||
<AccessListForm
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
onDelete={mockDelete}
|
||||
initialData={{ id: 1, uuid: 'del-uuid', name: 'Del', description: '', type: 'whitelist', ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '', updated_at: '' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /Delete/i });
|
||||
await user.click(deleteBtn);
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles presets visibility', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
// Switch to blacklist to see preset button
|
||||
await user.selectOptions(screen.getByLabelText(/Type/i), 'blacklist');
|
||||
|
||||
const showPresetsBtn = screen.getByRole('button', { name: /Show Presets/i });
|
||||
await user.click(showPresetsBtn);
|
||||
|
||||
expect(screen.getByText(/Quick-start templates/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Hide Presets/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ===== BRANCH COVERAGE EXPANSION TESTS =====
|
||||
|
||||
// Form Submission Validation Tests
|
||||
it('prevents submission with empty name', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits form with all field types - whitelist IP mode', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Whitelist Test');
|
||||
await user.type(screen.getByLabelText(/Description/i), 'Test description');
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'whitelist');
|
||||
|
||||
const ipInput = screen.getByPlaceholderText(/192.168.1.0\/24/i);
|
||||
await user.type(ipInput, '10.0.0.0/8');
|
||||
|
||||
const descInput = screen.getByPlaceholderText(/Description \(optional\)/i);
|
||||
await user.type(descInput, 'Internal network');
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Whitelist Test',
|
||||
type: 'whitelist',
|
||||
enabled: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('submits form with geo whitelist type', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Geo Whitelist');
|
||||
|
||||
await user.selectOptions(screen.getByLabelText(/Type/i), 'geo_whitelist');
|
||||
|
||||
const countrySelect = screen.getByLabelText(/Select Countries/i);
|
||||
await user.selectOptions(countrySelect, 'US');
|
||||
await user.selectOptions(countrySelect, 'CA');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Geo Whitelist',
|
||||
type: 'geo_whitelist',
|
||||
country_codes: 'US,CA',
|
||||
ip_rules: '',
|
||||
}));
|
||||
});
|
||||
|
||||
it('toggles local network only and disables IP inputs', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Local Network');
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'whitelist');
|
||||
|
||||
// Toggle local network only
|
||||
const localNetworkSwitch = screen.getByRole('checkbox', { name: /Local Network Only/i });
|
||||
await user.click(localNetworkSwitch);
|
||||
|
||||
// IP inputs should be hidden
|
||||
expect(screen.queryByPlaceholderText(/192.168.1.0\/24/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Local Network',
|
||||
local_network_only: true,
|
||||
ip_rules: '',
|
||||
}));
|
||||
});
|
||||
|
||||
it('disables form when isLoading is true', () => {
|
||||
render(
|
||||
<AccessListForm
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /Saving.../i });
|
||||
expect(submitBtn).toBeDisabled();
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
|
||||
expect(cancelBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables form when isDeleting is true', () => {
|
||||
render(
|
||||
<AccessListForm
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
onDelete={mockDelete}
|
||||
isDeleting={true}
|
||||
initialData={{ id: 1, uuid: 'test-uuid', name: 'Test', description: '', type: 'whitelist', ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '', updated_at: '' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /Deleting.../i });
|
||||
expect(deleteBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles My IP fetch error gracefully', async () => {
|
||||
vi.mocked(systemApi.getMyIP).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const getIpButton = screen.getByRole('button', { name: /Get My IP/i });
|
||||
await user.click(getIpButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to fetch your IP address');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles IP validation with wildcard domains', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Wildcard Test');
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'whitelist');
|
||||
|
||||
const ipInput = screen.getByPlaceholderText(/192.168.1.0\/24/i);
|
||||
await user.type(ipInput, '*.example.com');
|
||||
|
||||
// This should trigger validation and show error for invalid IP format
|
||||
await user.tab();
|
||||
|
||||
// Try to submit - should not submit with invalid IP
|
||||
// Note: The component may or may not validate here depending on implementation
|
||||
});
|
||||
|
||||
it('edit mode shows update button instead of create', () => {
|
||||
const initialData = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing List',
|
||||
description: 'Description',
|
||||
type: 'blacklist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
render(
|
||||
<AccessListForm
|
||||
initialData={initialData}
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Update/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Create$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete button only in edit mode', () => {
|
||||
render(
|
||||
<AccessListForm
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Delete/i })).not.toBeInTheDocument();
|
||||
|
||||
const initialData = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
render(
|
||||
<AccessListForm
|
||||
initialData={initialData}
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
onDelete={mockDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables delete button when deleting', () => {
|
||||
const initialData = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
render(
|
||||
<AccessListForm
|
||||
initialData={initialData}
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
onDelete={mockDelete}
|
||||
isDeleting={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /Deleting.../i });
|
||||
expect(deleteBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies security preset for geo blacklist', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Preset Test');
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'geo_blacklist');
|
||||
|
||||
const showBtn = screen.getByRole('button', { name: /Show Presets/i });
|
||||
await user.click(showBtn);
|
||||
|
||||
expect(screen.getByText(/Quick-start templates/i)).toBeInTheDocument();
|
||||
|
||||
// Look for Apply buttons in presets
|
||||
const applyButtons = screen.getAllByRole('button', { name: /Apply/i });
|
||||
if (applyButtons.length > 0) {
|
||||
await user.click(applyButtons[0]);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies geo preset correctly', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Geo Preset Test');
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'geo_blacklist');
|
||||
|
||||
const showBtn = screen.getByRole('button', { name: /Show Presets/i });
|
||||
await user.click(showBtn);
|
||||
|
||||
const applyButtons = screen.getAllByRole('button', { name: /Apply/i });
|
||||
if (applyButtons.length > 0) {
|
||||
await user.click(applyButtons[0]);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles enabled switch', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Switch Test');
|
||||
|
||||
const enabledSwitch = screen.getByRole('checkbox', { name: /^Enabled$/i });
|
||||
await user.click(enabledSwitch);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enabled: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles multiple countries in geo type', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Multi-Country');
|
||||
|
||||
await user.selectOptions(screen.getByLabelText(/Type/i), 'geo_whitelist');
|
||||
|
||||
const countrySelect = screen.getByLabelText(/Select Countries/i);
|
||||
await user.selectOptions(countrySelect, 'US');
|
||||
await user.selectOptions(countrySelect, 'CA');
|
||||
await user.selectOptions(countrySelect, 'GB');
|
||||
|
||||
const countryTags = screen.getAllByText(/\([A-Z]{2}\)/);
|
||||
expect(countryTags.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
country_codes: expect.stringContaining('US'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes country from selection', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Country Removal');
|
||||
|
||||
await user.selectOptions(screen.getByLabelText(/Type/i), 'geo_whitelist');
|
||||
|
||||
const countrySelect = screen.getByLabelText(/Select Countries/i);
|
||||
await user.selectOptions(countrySelect, 'US');
|
||||
await user.selectOptions(countrySelect, 'CA');
|
||||
|
||||
// Remove US
|
||||
const closeButtons = screen.getAllByRole('button').filter(b =>
|
||||
b.querySelector('.lucide-x')
|
||||
);
|
||||
if (closeButtons.length > 0) {
|
||||
await user.click(closeButtons[0]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create/i }));
|
||||
|
||||
// Should have CA but maybe not US
|
||||
expect(mockSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads JSON IP rules from initial data', () => {
|
||||
const ipRulesJson = JSON.stringify([
|
||||
{ cidr: '192.168.0.0/16', description: 'Office' },
|
||||
{ cidr: '10.0.0.0/8', description: 'Data center' }
|
||||
]);
|
||||
|
||||
const initialData = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Loaded Rules',
|
||||
description: '',
|
||||
type: 'whitelist' as const,
|
||||
ip_rules: ipRulesJson,
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
};
|
||||
|
||||
render(
|
||||
<AccessListForm
|
||||
initialData={initialData}
|
||||
onSubmit={mockSubmit}
|
||||
onCancel={mockCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('192.168.0.0/16')).toBeInTheDocument();
|
||||
expect(screen.getByText('Office')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.0.0.0/8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows info about IP coverage', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Coverage Test');
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'whitelist');
|
||||
|
||||
const ipInput = screen.getByPlaceholderText(/192.168.1.0\/24/i);
|
||||
await user.type(ipInput, '10.0.0.0/8');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
// Should show coverage info
|
||||
expect(screen.getByText(/Current rules cover approximately/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders recommendations for blacklist type', async () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const typeSelect = screen.getByLabelText(/Type/i);
|
||||
await user.selectOptions(typeSelect, 'blacklist');
|
||||
|
||||
expect(screen.getByText(/Block lists are safer/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders best practices link', () => {
|
||||
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Best Practices/i });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AccessListSelector from '../AccessListSelector';
|
||||
import * as useAccessListsHook from '../../hooks/useAccessLists';
|
||||
import type { AccessList } from '../../api/accessLists';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useAccessLists');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AccessListSelector', () => {
|
||||
it('should render with no access lists', () => {
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: [],
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: /Access Control List/i });
|
||||
expect(trigger).toBeInTheDocument();
|
||||
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with access lists and show only enabled ones', async () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Test ACL 1',
|
||||
description: 'Description 1',
|
||||
type: 'whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'uuid-2',
|
||||
name: 'Test ACL 2',
|
||||
description: 'Description 2',
|
||||
type: 'blacklist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: /Access Control List/i });
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Test ACL 1 (whitelist)' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'Test ACL 2 (blacklist)' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected ACL details', () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Selected ACL',
|
||||
description: 'This is selected',
|
||||
type: 'geo_whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: 'US,CA',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={1} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Selected ACL')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is selected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import CertificateList from '../CertificateList'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
import { useCertificates } from '../../hooks/useCertificates'
|
||||
import { useProxyHosts } from '../../hooks/useProxyHosts'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
deleteCertificate: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
function renderWithClient(ui: React.ReactNode) {
|
||||
const qc = createTestQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertificates>> = {}) => {
|
||||
const certificates: Certificate[] = [
|
||||
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' },
|
||||
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
|
||||
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
{ id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
]
|
||||
|
||||
return {
|
||||
certificates,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const createProxyHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'h1',
|
||||
name: 'Host1',
|
||||
domain_names: 'host1.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 80,
|
||||
ssl_forced: false,
|
||||
http2_support: true,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2026-02-01T00:00:00Z',
|
||||
updated_at: '2026-02-01T00:00:00Z',
|
||||
certificate_id: 3,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createProxyHostsValue = (overrides: Partial<ReturnType<typeof useProxyHosts>> = {}): ReturnType<typeof useProxyHosts> => ({
|
||||
hosts: [
|
||||
createProxyHost(),
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: vi.fn(),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
bulkUpdateSecurityHeaders: vi.fn(),
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isBulkUpdating: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const getRowNames = () =>
|
||||
screen
|
||||
.getAllByRole('row')
|
||||
.slice(1)
|
||||
.map(row => row.querySelector('td')?.textContent?.trim() ?? '')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue())
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue())
|
||||
})
|
||||
|
||||
describe('CertificateList', () => {
|
||||
it('deletes custom certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { createBackup } = await import('../../api/backups')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
|
||||
expect(customRow).toBeTruthy()
|
||||
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
|
||||
expect(customBtn).toBeTruthy()
|
||||
await user.click(customBtn)
|
||||
|
||||
await waitFor(() => expect(createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Certificate deleted'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes staging certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
|
||||
expect(stagingButtons.length).toBeGreaterThan(0)
|
||||
await user.click(stagingButtons[0])
|
||||
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes valid custom certificate when not in use', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { createBackup } = await import('../../api/backups')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const unusedRow = rows.find(r => r.querySelector('td')?.textContent?.includes('UnusedValidCert')) as HTMLElement
|
||||
expect(unusedRow).toBeTruthy()
|
||||
const unusedButton = unusedRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
|
||||
expect(unusedButton).toBeTruthy()
|
||||
await user.click(unusedButton)
|
||||
|
||||
await waitFor(() => expect(createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(4))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders empty state when no certificates exist', async () => {
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ certificates: [] }))
|
||||
renderWithClient(<CertificateList />)
|
||||
expect(await screen.findByText('No certificates found.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state when certificate load fails', async () => {
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ error: new Error('boom') }))
|
||||
renderWithClient(<CertificateList />)
|
||||
expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sorts certificates by name and expiry when headers are clicked', async () => {
|
||||
const certificates: Certificate[] = [
|
||||
{ id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
{ id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
]
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ certificates }))
|
||||
renderWithClient(<CertificateList />)
|
||||
|
||||
expect(getRowNames()).toEqual(['Alpha', 'Zulu'])
|
||||
|
||||
await user.click(screen.getByText('Expires'))
|
||||
expect(getRowNames()).toEqual(['Alpha', 'Zulu'])
|
||||
|
||||
await user.click(screen.getByText('Expires'))
|
||||
expect(getRowNames()).toEqual(['Zulu', 'Alpha'])
|
||||
})
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import CertificateStatusCard from '../CertificateStatusCard'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
const mockCert: Certificate = {
|
||||
id: 1,
|
||||
name: 'Test Cert',
|
||||
domain: 'example.com',
|
||||
issuer: "Let's Encrypt",
|
||||
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
}
|
||||
|
||||
const mockHost: ProxyHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Test Host',
|
||||
domain_names: 'example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
enabled: true,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
}
|
||||
|
||||
// Helper to create a certificate with a specific domain
|
||||
function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate {
|
||||
return {
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
name: domain,
|
||||
domain: domain,
|
||||
issuer: "Let's Encrypt",
|
||||
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status,
|
||||
provider: 'letsencrypt',
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithRouter(ui: React.ReactNode) {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>)
|
||||
}
|
||||
|
||||
describe('CertificateStatusCard', () => {
|
||||
it('shows total certificate count', () => {
|
||||
const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText('SSL Certificates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows valid certificate count', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'valid' },
|
||||
{ ...mockCert, id: 2, status: 'valid' },
|
||||
{ ...mockCert, id: 3, status: 'expired' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('2 valid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows expiring count when certificates are expiring', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'expiring' },
|
||||
{ ...mockCert, id: 2, status: 'valid' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('1 expiring')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides expiring count when no certificates are expiring', () => {
|
||||
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.queryByText(/expiring/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows staging count for untrusted certificates', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'untrusted' },
|
||||
{ ...mockCert, id: 2, status: 'untrusted' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('2 staging')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides staging count when no untrusted certificates', () => {
|
||||
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.queryByText(/staging/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows spinning loader icon when pending', () => {
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'other.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
const { container } = renderWithRouter(
|
||||
<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />
|
||||
)
|
||||
|
||||
const spinner = container.querySelector('.animate-spin')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to certificates page', () => {
|
||||
renderWithRouter(<CertificateStatusCard certificates={[mockCert]} hosts={[]} />)
|
||||
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/certificates')
|
||||
})
|
||||
|
||||
it('handles empty certificates array', () => {
|
||||
renderWithRouter(<CertificateStatusCard certificates={[]} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('No certificates')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CertificateStatusCard - Domain Matching', () => {
|
||||
it('does not show pending when host domain matches certificate domain', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('example.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
// Should NOT show "awaiting certificate" since domain matches
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows pending when host domain has no matching certificate', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('other.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows plural for multiple pending hosts', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('has-cert.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, uuid: 'h1', domain_names: 'no-cert-1.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h2', domain_names: 'no-cert-2.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h3', domain_names: 'no-cert-3.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.getByText('3 hosts awaiting certificate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles case-insensitive domain matching', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('EXAMPLE.COM')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles case-insensitive matching with host uppercase', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('example.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'EXAMPLE.COM', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles multi-domain hosts with partial certificate coverage', () => {
|
||||
// Host has two domains, but only one has a certificate - should be "covered"
|
||||
const certs: Certificate[] = [mockCertWithDomain('example.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com, www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
// Host should be considered "covered" if any domain has a cert
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles comma-separated certificate domains', () => {
|
||||
const certs: Certificate[] = [{
|
||||
...mockCertWithDomain('example.com'),
|
||||
domain: 'example.com, www.example.com'
|
||||
}]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores disabled hosts even without certificate', () => {
|
||||
const certs: Certificate[] = []
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: false }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores hosts without SSL forced', () => {
|
||||
const certs: Certificate[] = []
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: false, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calculates progress percentage with domain matching', () => {
|
||||
const certs: Certificate[] = [
|
||||
mockCertWithDomain('a.example.com'),
|
||||
mockCertWithDomain('b.example.com'),
|
||||
]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h3', domain_names: 'c.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h4', domain_names: 'd.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
// 2 out of 4 hosts have matching certs = 50%
|
||||
expect(screen.getByText('50% provisioned')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all pending when no certificates exist', () => {
|
||||
const certs: Certificate[] = []
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument()
|
||||
expect(screen.getByText('0% provisioned')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows 100% provisioned when all SSL hosts have matching certificates', () => {
|
||||
const certs: Certificate[] = [
|
||||
mockCertWithDomain('a.example.com'),
|
||||
mockCertWithDomain('b.example.com'),
|
||||
]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
// Should NOT show awaiting indicator when all hosts are covered
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/provisioned/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles whitespace in domain names', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('example.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: ' example.com ', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles whitespace in certificate domains', () => {
|
||||
const certs: Certificate[] = [{
|
||||
...mockCertWithDomain('example.com'),
|
||||
domain: ' example.com '
|
||||
}]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('correctly counts mix of covered and uncovered hosts', () => {
|
||||
const certs: Certificate[] = [mockCertWithDomain('covered.com')]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, uuid: 'h1', domain_names: 'covered.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h2', domain_names: 'uncovered.com', ssl_forced: true, certificate_id: null, enabled: true },
|
||||
{ ...mockHost, uuid: 'h3', domain_names: 'disabled.com', ssl_forced: true, certificate_id: null, enabled: false },
|
||||
{ ...mockHost, uuid: 'h4', domain_names: 'no-ssl.com', ssl_forced: false, certificate_id: null, enabled: true },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={hosts} />)
|
||||
|
||||
// Only h1 and h2 are SSL hosts that are enabled
|
||||
// h1 is covered, h2 is not
|
||||
expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument()
|
||||
expect(screen.getByText('50% provisioned')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,869 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider, type UseMutationResult } from '@tanstack/react-query'
|
||||
import CredentialManager from '../CredentialManager'
|
||||
import {
|
||||
useCredentials,
|
||||
useCreateCredential,
|
||||
useUpdateCredential,
|
||||
useDeleteCredential,
|
||||
useTestCredential,
|
||||
} from '../../hooks/useCredentials'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import type { DNSProvider, DNSProviderTypeInfo } from '../../api/dnsProviders'
|
||||
import type { CredentialRequest, CredentialTestResult, DNSProviderCredential } from '../../api/credentials'
|
||||
|
||||
vi.mock('../../hooks/useCredentials')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockProvider: DNSProvider = {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Cloudflare Production',
|
||||
provider_type: 'cloudflare',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 5,
|
||||
success_count: 10,
|
||||
failure_count: 0,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockProviderTypeInfo: DNSProviderTypeInfo = {
|
||||
type: 'cloudflare',
|
||||
name: 'Cloudflare',
|
||||
fields: [
|
||||
{
|
||||
name: 'api_token',
|
||||
label: 'API Token',
|
||||
type: 'password',
|
||||
required: true,
|
||||
hint: 'Cloudflare API Token with DNS edit permissions',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email Address',
|
||||
type: 'text',
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
documentation_url: 'https://developers.cloudflare.com',
|
||||
}
|
||||
|
||||
const mockCredentials: DNSProviderCredential[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'cred-uuid-1',
|
||||
dns_provider_id: 1,
|
||||
label: 'Main Zone',
|
||||
zone_filter: 'example.com',
|
||||
enabled: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 5,
|
||||
key_version: 1,
|
||||
success_count: 15,
|
||||
failure_count: 0,
|
||||
last_used_at: '2025-01-03T10:00:00Z',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const createCredentialsQueryResult = (
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ReturnType<typeof useCredentials> => ({
|
||||
data: mockCredentials,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
error: null,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
...overrides,
|
||||
} as unknown as ReturnType<typeof useCredentials>)
|
||||
|
||||
const createMutationResult = <TData, TVariables>(
|
||||
mutateAsync: ReturnType<typeof vi.fn>,
|
||||
overrides: Partial<UseMutationResult<TData, Error, TVariables, unknown>> = {}
|
||||
): UseMutationResult<TData, Error, TVariables, unknown> => ({
|
||||
mutate: vi.fn() as UseMutationResult<TData, Error, TVariables, unknown>['mutate'],
|
||||
mutateAsync: mutateAsync as UseMutationResult<TData, Error, TVariables, unknown>['mutateAsync'],
|
||||
isPending: false,
|
||||
...overrides,
|
||||
} as UseMutationResult<TData, Error, TVariables, unknown>)
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('CredentialManager', () => {
|
||||
const mockOnOpenChange = vi.fn()
|
||||
const mockCreateMutate = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
const mockTestMutate = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useCredentials).mockReturnValue(createCredentialsQueryResult())
|
||||
|
||||
vi.mocked(useCreateCredential).mockReturnValue(
|
||||
createMutationResult<DNSProviderCredential, { providerId: number; data: CredentialRequest }>(
|
||||
mockCreateMutate
|
||||
)
|
||||
)
|
||||
|
||||
vi.mocked(useUpdateCredential).mockReturnValue(
|
||||
createMutationResult<
|
||||
DNSProviderCredential,
|
||||
{ providerId: number; credentialId: number; data: CredentialRequest }
|
||||
>(mockUpdateMutate)
|
||||
)
|
||||
|
||||
vi.mocked(useDeleteCredential).mockReturnValue(
|
||||
createMutationResult<void, { providerId: number; credentialId: number }>(
|
||||
mockDeleteMutate
|
||||
)
|
||||
)
|
||||
|
||||
vi.mocked(useTestCredential).mockReturnValue(
|
||||
createMutationResult<CredentialTestResult, { providerId: number; credentialId: number }>(
|
||||
mockTestMutate
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// 1. Rendering Checks
|
||||
it('renders credentials properly', async () => {
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Manage Credentials: Cloudflare Production')).toBeInTheDocument()
|
||||
expect(screen.getByText('Main Zone')).toBeInTheDocument()
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 2. Add Operation
|
||||
it('allows adding a new credential', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
// Click Add Credential
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
|
||||
// Verify Form opens
|
||||
expect(screen.getByRole('dialog', { name: 'Add Credential' })).toBeInTheDocument()
|
||||
|
||||
// Fill Form
|
||||
// Label requires *
|
||||
await user.type(screen.getByLabelText(/Label/i), 'New Staging')
|
||||
|
||||
// Zone Filter
|
||||
await user.type(screen.getByLabelText(/Zone Filter/i), '*.staging.com')
|
||||
|
||||
// Credentials fields from type info
|
||||
// API Token (required)
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'my-secret-token')
|
||||
|
||||
// Click Save
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
// Expect Create Mutation
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMutate).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
data: expect.objectContaining({
|
||||
label: 'New Staging',
|
||||
zone_filter: '*.staging.com',
|
||||
credentials: expect.objectContaining({
|
||||
api_token: 'my-secret-token'
|
||||
}),
|
||||
enabled: true
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 3. Edit Operation
|
||||
it('allows editing an existing credential', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
// Locate the edit button for the first credential.
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const editBtn = credRow?.querySelectorAll('button')[1] // 0=Test, 1=Edit, 2=Delete
|
||||
|
||||
expect(editBtn).toBeDefined()
|
||||
await user.click(editBtn!)
|
||||
|
||||
// Verify Form opens with pre-filled values
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Credential' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Main Zone')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('example.com')).toBeInTheDocument()
|
||||
|
||||
// Change label
|
||||
const labelInput = screen.getByLabelText(/Label/i)
|
||||
await user.clear(labelInput)
|
||||
await user.type(labelInput, 'Updated Label')
|
||||
|
||||
// Click Save
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
// Expect Update Mutation
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
credentialId: 1,
|
||||
data: expect.objectContaining({
|
||||
label: 'Updated Label',
|
||||
zone_filter: 'example.com'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 4. Delete Operation
|
||||
it('allows deleting a credential after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const deleteBtn = credRow?.querySelectorAll('button')[2] // 0=Test, 1=Edit, 2=Delete
|
||||
|
||||
expect(deleteBtn).toBeDefined()
|
||||
await user.click(deleteBtn!)
|
||||
|
||||
// Confirmation Dialog
|
||||
expect(screen.getByText('Delete Credential?')).toBeInTheDocument()
|
||||
|
||||
// Confirm
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
// Expect Delete Mutation
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
credentialId: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 5. Validation - Required Fields
|
||||
it('validates required fields on add', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
|
||||
// Click Save without filling anything
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
// Mutation should NOT be called.
|
||||
expect(mockCreateMutate).not.toHaveBeenCalled()
|
||||
|
||||
// Fill Label but not API Key (which is required by type info)
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Incomplete')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
// Still no mutation
|
||||
expect(mockCreateMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// 6. Validation - Zone Filter Format
|
||||
it('validates zone filter format', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Bad Zone')
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'token')
|
||||
|
||||
// Invalid zone
|
||||
await user.type(screen.getByLabelText(/Zone Filter/i), 'invalid zone')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockCreateMutate).not.toHaveBeenCalled()
|
||||
|
||||
// Fix zone
|
||||
const zoneInput = screen.getByLabelText(/Zone Filter/i)
|
||||
await user.clear(zoneInput)
|
||||
await user.type(zoneInput, 'valid.com')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMutate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ===== BRANCH COVERAGE EXPANSION TESTS =====
|
||||
|
||||
// 7. Empty Credential List Rendering
|
||||
it('renders empty state when no credentials exist', () => {
|
||||
vi.mocked(useCredentials).mockReturnValue(
|
||||
createCredentialsQueryResult({ data: [] })
|
||||
)
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/No credentials configured/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Add credentials to enable/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Add First Credential/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 8. Loading State
|
||||
it('renders loading state while fetching credentials', () => {
|
||||
vi.mocked(useCredentials).mockReturnValue(
|
||||
createCredentialsQueryResult({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
isSuccess: false,
|
||||
status: 'loading',
|
||||
fetchStatus: 'fetching',
|
||||
})
|
||||
)
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 9. Delete Error Handling
|
||||
it('shows error toast when delete fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockDeleteMutate.mockRejectedValue({
|
||||
response: { data: { error: 'Credential in use' } }
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const deleteBtn = credRow?.querySelectorAll('button')[2]
|
||||
|
||||
await user.click(deleteBtn!)
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 10. Test Credential - Success
|
||||
it('tests credential and shows success', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockTestMutate.mockResolvedValue({ success: true, message: 'Test passed' })
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const testBtn = credRow?.querySelectorAll('button')[0]
|
||||
|
||||
await user.click(testBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestMutate).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
credentialId: 1,
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 11. Test Credential - Failure
|
||||
it('tests credential and shows error on failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockTestMutate.mockResolvedValue({ success: false, error: 'Invalid token' })
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const testBtn = credRow?.querySelectorAll('button')[0]
|
||||
|
||||
await user.click(testBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 12. Test Credential - Exception
|
||||
it('handles test credential exception', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockTestMutate.mockRejectedValue({ message: 'Network error' })
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const testBtn = credRow?.querySelectorAll('button')[0]
|
||||
|
||||
await user.click(testBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 13. Multiple Credentials
|
||||
it('renders multiple credentials in table', () => {
|
||||
const multipleCreds = [
|
||||
...mockCredentials,
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'cred-uuid-2',
|
||||
dns_provider_id: 1,
|
||||
label: 'Staging Zone',
|
||||
zone_filter: '*.staging.com',
|
||||
enabled: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 5,
|
||||
key_version: 1,
|
||||
success_count: 5,
|
||||
failure_count: 2,
|
||||
last_used_at: undefined,
|
||||
last_error: undefined,
|
||||
created_at: '2025-01-02T00:00:00Z',
|
||||
updated_at: '2025-01-02T00:00:00Z',
|
||||
}
|
||||
]
|
||||
|
||||
vi.mocked(useCredentials).mockReturnValue(
|
||||
createCredentialsQueryResult({ data: multipleCreds })
|
||||
)
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Main Zone')).toBeInTheDocument()
|
||||
expect(screen.getByText('Staging Zone')).toBeInTheDocument()
|
||||
expect(screen.getByText('*.staging.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 14. Disabled Credential
|
||||
it('displays disabled status for disabled credentials', () => {
|
||||
const disabledCred = {
|
||||
...mockCredentials[0],
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
vi.mocked(useCredentials).mockReturnValue(
|
||||
createCredentialsQueryResult({ data: [disabledCred] })
|
||||
)
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 15. Credential with Last Error
|
||||
it('displays last error when credential has failure', () => {
|
||||
const errorCred = {
|
||||
...mockCredentials[0],
|
||||
failure_count: 3,
|
||||
last_error: 'API rate limit exceeded',
|
||||
}
|
||||
|
||||
vi.mocked(useCredentials).mockReturnValue(
|
||||
createCredentialsQueryResult({ data: [errorCred] })
|
||||
)
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('API rate limit exceeded')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 16. Create Credential Error
|
||||
it('shows error toast when create fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockCreateMutate.mockRejectedValue({
|
||||
response: { data: { error: 'Invalid provider' } }
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Test')
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'token')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 17. Update Credential Error
|
||||
it('shows error toast when update fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockUpdateMutate.mockRejectedValue({
|
||||
response: { data: { error: 'Zone filter conflict' } }
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const editBtn = credRow?.querySelectorAll('button')[1]
|
||||
|
||||
await user.click(editBtn!)
|
||||
await user.type(screen.getByLabelText(/Label/i), ' Updated')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 18. Cancel Delete
|
||||
it('cancels delete operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const deleteBtn = credRow?.querySelectorAll('button')[2]
|
||||
|
||||
await user.click(deleteBtn!)
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockDeleteMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// 19. Cancel Form Dialog
|
||||
it('closes form dialog without saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Test')
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockCreateMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// 20. Advanced Options - Propagation Timeout
|
||||
it('allows editing propagation timeout in advanced options', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Advanced Test')
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'token')
|
||||
|
||||
// Find and open details element
|
||||
const detailsElements = document.querySelectorAll('details')
|
||||
const detailsElement = Array.from(detailsElements).find(d =>
|
||||
d.textContent?.includes('Advanced Options')
|
||||
)
|
||||
|
||||
if (detailsElement) {
|
||||
const summary = detailsElement.querySelector('summary')
|
||||
if (summary) {
|
||||
await user.click(summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the propagation timeout input
|
||||
const timeoutInputs = document.querySelectorAll('input')
|
||||
const timeoutInput = Array.from(timeoutInputs).find(i =>
|
||||
i.id === 'propagation_timeout' || i.placeholder?.includes('120')
|
||||
) as HTMLInputElement
|
||||
|
||||
if (timeoutInput) {
|
||||
await user.clear(timeoutInput)
|
||||
await user.type(timeoutInput, '300')
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMutate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 21. Advanced Options - Polling Interval
|
||||
it('allows editing polling interval in advanced options', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
await user.type(screen.getByLabelText(/Label/i), 'Polling Test')
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'token')
|
||||
|
||||
// Find and open details element
|
||||
const detailsElements = document.querySelectorAll('details')
|
||||
const detailsElement = Array.from(detailsElements).find(d =>
|
||||
d.textContent?.includes('Advanced Options')
|
||||
)
|
||||
|
||||
if (detailsElement) {
|
||||
const summary = detailsElement.querySelector('summary')
|
||||
if (summary) {
|
||||
await user.click(summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the polling interval input
|
||||
const inputs = document.querySelectorAll('input')
|
||||
const pollingInput = Array.from(inputs).find(i =>
|
||||
i.id === 'polling_interval' || (i.type === 'number' && i.placeholder?.includes('5'))
|
||||
) as HTMLInputElement
|
||||
|
||||
if (pollingInput) {
|
||||
await user.clear(pollingInput)
|
||||
await user.type(pollingInput, '10')
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateMutate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// 22. Form Success Toast
|
||||
it('shows success toast after credential creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockCreateMutate.mockResolvedValue({ id: 2, label: 'New Cred' })
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Add Credential'))
|
||||
await user.type(screen.getByLabelText(/Label/i), 'New Cred')
|
||||
await user.type(screen.getByLabelText(/API Token/i), 'token')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('created successfully'))
|
||||
})
|
||||
})
|
||||
|
||||
// 23. Form Success Toast - Update
|
||||
it('shows success toast after credential update', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { toast } = await import('../../utils/toast')
|
||||
|
||||
mockUpdateMutate.mockResolvedValue({ id: 1, label: 'Updated' })
|
||||
|
||||
renderWithClient(
|
||||
<CredentialManager
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
provider={mockProvider}
|
||||
providerTypeInfo={mockProviderTypeInfo}
|
||||
/>
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
|
||||
const editBtn = credRow?.querySelectorAll('button')[1]
|
||||
|
||||
await user.click(editBtn!)
|
||||
await user.type(screen.getByLabelText(/Label/i), ' Updated')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('updated successfully'))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* CrowdSecBouncerKeyDisplay Component Tests
|
||||
* Tests the bouncer API key display functionality for CrowdSec integration
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { CrowdSecBouncerKeyDisplay } from '../CrowdSecBouncerKeyDisplay'
|
||||
|
||||
// Create mock axios instance
|
||||
vi.mock('axios', () => {
|
||||
const mockGet = vi.fn()
|
||||
return {
|
||||
default: {
|
||||
create: () => ({
|
||||
get: mockGet,
|
||||
defaults: { headers: { common: {} } },
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
}),
|
||||
},
|
||||
get: mockGet,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock i18n translation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'security.crowdsec.bouncerApiKey': 'Bouncer API Key',
|
||||
'security.crowdsec.keyCopied': 'Key copied to clipboard',
|
||||
'security.crowdsec.copyFailed': 'Failed to copy key',
|
||||
'security.crowdsec.noKeyConfigured': 'No bouncer API key configured',
|
||||
'security.crowdsec.registered': 'Registered',
|
||||
'security.crowdsec.notRegistered': 'Not Registered',
|
||||
'security.crowdsec.sourceEnvVar': 'Environment Variable',
|
||||
'security.crowdsec.sourceFile': 'File',
|
||||
'security.crowdsec.keyStoredAt': 'Key stored at',
|
||||
'common.copy': 'Copy',
|
||||
'common.success': 'Success',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
ready: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Re-import client after mocking axios
|
||||
import client from '../../api/client'
|
||||
|
||||
const mockBouncerInfo = {
|
||||
name: 'caddy-bouncer',
|
||||
key_preview: 'abc***xyz',
|
||||
key_source: 'file' as const,
|
||||
file_path: '/etc/crowdsec/bouncers/caddy.key',
|
||||
registered: true,
|
||||
}
|
||||
|
||||
const mockBouncerInfoEnvVar = {
|
||||
name: 'caddy-bouncer',
|
||||
key_preview: 'env***var',
|
||||
key_source: 'env_var' as const,
|
||||
file_path: '/etc/crowdsec/bouncers/caddy.key',
|
||||
registered: true,
|
||||
}
|
||||
|
||||
const mockBouncerInfoNotRegistered = {
|
||||
name: 'caddy-bouncer',
|
||||
key_preview: 'unreg***key',
|
||||
key_source: 'file' as const,
|
||||
file_path: '/etc/crowdsec/bouncers/caddy.key',
|
||||
registered: false,
|
||||
}
|
||||
|
||||
const mockBouncerInfoNoKey = {
|
||||
name: 'caddy-bouncer',
|
||||
key_preview: '',
|
||||
key_source: 'none' as const,
|
||||
file_path: '',
|
||||
registered: false,
|
||||
}
|
||||
|
||||
describe('CrowdSecBouncerKeyDisplay', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(<CrowdSecBouncerKeyDisplay />, { wrapper })
|
||||
}
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show skeleton while loading bouncer info', async () => {
|
||||
vi.mocked(client.get).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderComponent()
|
||||
|
||||
const skeletons = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registered Bouncer with File Key Source', () => {
|
||||
it('should display bouncer key preview', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfo })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('abc***xyz')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show registered badge for registered bouncer', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfo })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Registered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show file source badge', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfo })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('File')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display file path', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfo })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/etc/crowdsec/bouncers/caddy.key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show card title with key icon', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfo })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Bouncer API Key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registered Bouncer with Env Var Key Source', () => {
|
||||
it('should show env var source badge', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfoEnvVar })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Environment Variable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unregistered Bouncer', () => {
|
||||
it('should show not registered badge', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfoNotRegistered })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not Registered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('No Key Configured', () => {
|
||||
it('should show warning message when no key is configured', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBouncerInfoNoKey })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No bouncer API key configured')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy Key Functionality', () => {
|
||||
it.skip('should copy full key to clipboard when copy button is clicked', async () => {
|
||||
// Skipped: Complex async mock chain with clipboard API
|
||||
})
|
||||
|
||||
it.skip('should show success state after copying', async () => {
|
||||
// Skipped: Complex async mock chain with clipboard API
|
||||
})
|
||||
|
||||
it.skip('should show error toast when copy fails', async () => {
|
||||
// Skipped: Complex async mock chain
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it.skip('should return null when API fetch fails', async () => {
|
||||
// Skipped: Mock isolation issues with axios, covered in integration tests
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,184 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CrowdSecKeyWarning } from '../CrowdSecKeyWarning'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
ready: true,
|
||||
}),
|
||||
}))
|
||||
// Mock toast
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
Wrapper.displayName = 'QueryClientWrapper'
|
||||
return Wrapper
|
||||
}
|
||||
|
||||
describe('CrowdSecKeyWarning', () => {
|
||||
const defaultStatus = {
|
||||
key_source: 'env' as const,
|
||||
env_key_rejected: true,
|
||||
full_key: 'new-valid-key',
|
||||
current_key_preview: 'old...',
|
||||
message: 'Key rejected',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Clear localStorage
|
||||
localStorage.clear()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn() },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders when key is rejected (missing/invalid)', async () => {
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('security.crowdsec.keyWarning.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when key is valid (present)', async () => {
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue({
|
||||
key_source: 'env',
|
||||
env_key_rejected: false,
|
||||
current_key_preview: 'valid...',
|
||||
message: 'OK',
|
||||
})
|
||||
|
||||
const { container } = render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.getCrowdsecKeyStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('does not render when dismissed for the same key', async () => {
|
||||
localStorage.setItem('crowdsec-key-warning-dismissed', JSON.stringify({
|
||||
dismissed: true,
|
||||
key: defaultStatus.full_key,
|
||||
}))
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
const { container } = render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.getCrowdsecKeyStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('re-renders when dismissal key differs', async () => {
|
||||
localStorage.setItem('crowdsec-key-warning-dismissed', JSON.stringify({
|
||||
dismissed: true,
|
||||
key: 'old-key',
|
||||
}))
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('security.crowdsec.keyWarning.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('copies the key and toggles the copied state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const clipboardWrite = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: clipboardWrite },
|
||||
configurable: true,
|
||||
})
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
const copyButton = await screen.findByRole('button', {
|
||||
name: 'security.crowdsec.keyWarning.copyButton',
|
||||
})
|
||||
|
||||
await user.click(copyButton)
|
||||
|
||||
expect(clipboardWrite).toHaveBeenCalledWith(defaultStatus.full_key)
|
||||
expect(toast.success).toHaveBeenCalledWith('security.crowdsec.keyWarning.copied')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'security.crowdsec.keyWarning.copied' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a toast when copy fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const clipboardWrite = vi.fn().mockRejectedValue(new Error('copy failed'))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: clipboardWrite },
|
||||
configurable: true,
|
||||
})
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
const copyButton = await screen.findByRole('button', {
|
||||
name: 'security.crowdsec.keyWarning.copyButton',
|
||||
})
|
||||
await user.click(copyButton)
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('security.crowdsec.copyFailed')
|
||||
})
|
||||
|
||||
it('toggles key visibility', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
const codeBlock = await screen.findByText(/CHARON_SECURITY_CROWDSEC_API_KEY=/)
|
||||
expect(codeBlock).not.toHaveTextContent(defaultStatus.full_key)
|
||||
|
||||
const showButton = screen.getByTitle('Show key')
|
||||
await user.click(showButton)
|
||||
|
||||
expect(codeBlock).toHaveTextContent(defaultStatus.full_key)
|
||||
expect(screen.getByTitle('Hide key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists dismissal when closed', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue(defaultStatus)
|
||||
|
||||
const { container } = render(<CrowdSecKeyWarning />, { wrapper: createWrapper() })
|
||||
|
||||
const closeButton = await screen.findByRole('button', { name: 'common.close' })
|
||||
await user.click(closeButton)
|
||||
|
||||
expect(localStorage.getItem('crowdsec-key-warning-dismissed')).toContain(defaultStatus.full_key)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DNSDetectionResult } from '../DNSDetectionResult'
|
||||
import type { DetectionResult } from '../../api/dnsDetection'
|
||||
import type { DNSProvider } from '../../api/dnsProviders'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dns_detection.detecting': 'Detecting DNS provider...',
|
||||
'dns_detection.detected': `${params?.provider} detected`,
|
||||
'dns_detection.confidence_high': 'High confidence',
|
||||
'dns_detection.confidence_medium': 'Medium confidence',
|
||||
'dns_detection.confidence_low': 'Low confidence',
|
||||
'dns_detection.confidence_none': 'No match',
|
||||
'dns_detection.not_detected': 'Could not detect DNS provider',
|
||||
'dns_detection.use_suggested': `Use ${params?.provider}`,
|
||||
'dns_detection.select_manually': 'Select manually',
|
||||
'dns_detection.nameservers': 'Nameservers',
|
||||
'dns_detection.error': `Detection failed: ${params?.error}`,
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DNSDetectionResult', () => {
|
||||
const mockSuggestedProvider: DNSProvider = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Production Cloudflare',
|
||||
provider_type: 'cloudflare',
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 5,
|
||||
success_count: 10,
|
||||
failure_count: 0,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
it('should show loading state', () => {
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={{} as DetectionResult}
|
||||
isLoading={true}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Detecting DNS provider...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error message', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: [],
|
||||
confidence: 'none',
|
||||
error: 'Network error',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText(/Detection failed: Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not detected message with nameservers', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: ['ns1.unknown.com', 'ns2.unknown.com'],
|
||||
confidence: 'none',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Could not detect DNS provider')).toBeInTheDocument()
|
||||
expect(screen.getByText(/nameservers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('ns1.unknown.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns2.unknown.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show successful detection with high confidence', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('cloudflare detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('High confidence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Use Production Cloudflare')).toBeInTheDocument()
|
||||
expect(screen.getByText('Select manually')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onUseSuggested when "Use" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUseSuggested = vi.fn()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={result}
|
||||
onUseSuggested={onUseSuggested}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Use Production Cloudflare'))
|
||||
|
||||
expect(onUseSuggested).toHaveBeenCalledWith(mockSuggestedProvider)
|
||||
})
|
||||
|
||||
it('should call onSelectManually when "Select manually" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelectManually = vi.fn()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={result}
|
||||
onSelectManually={onSelectManually}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Select manually'))
|
||||
|
||||
expect(onSelectManually).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show medium confidence badge', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'route53',
|
||||
nameservers: ['ns-123.awsdns-12.com'],
|
||||
confidence: 'medium',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Medium confidence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show low confidence badge', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'digitalocean',
|
||||
nameservers: ['ns1.digitalocean.com'],
|
||||
confidence: 'low',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Low confidence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show expandable nameservers list', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com', 'ns3.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
// Nameservers are in a details element
|
||||
const summary = screen.getByText(/Nameservers \(3\)/)
|
||||
await user.click(summary)
|
||||
|
||||
expect(screen.getByText('ns1.cloudflare.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns2.cloudflare.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns3.cloudflare.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show action buttons when no suggested provider', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.queryByText(/Use/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Select manually')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import DNSProviderForm from '../DNSProviderForm';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock the hooks
|
||||
const mockCreateMutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
};
|
||||
const mockUpdateMutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
};
|
||||
const mockTestCredentialsMutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
};
|
||||
const mockEnableMultiCredentialsMutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
vi.mock('../../hooks/useDNSProviders', () => ({
|
||||
useDNSProviderTypes: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
type: 'cloudflare',
|
||||
name: 'Cloudflare',
|
||||
fields: [
|
||||
{ name: 'api_token', label: 'API Token', type: 'password', required: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'route53',
|
||||
name: 'Route53',
|
||||
fields: [
|
||||
{ name: 'access_key_id', label: 'Access Key ID', type: 'text', required: true },
|
||||
{ name: 'secret_access_key', label: 'Secret Access Key', type: 'password', required: true }
|
||||
]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
})),
|
||||
useDNSProviderMutations: vi.fn(() => ({
|
||||
createMutation: mockCreateMutation,
|
||||
updateMutation: mockUpdateMutation,
|
||||
testCredentialsMutation: mockTestCredentialsMutation,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useCredentials', () => ({
|
||||
useEnableMultiCredentials: vi.fn(() => mockEnableMultiCredentialsMutation),
|
||||
useCredentials: vi.fn(() => ({
|
||||
data: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock CredentialManager component to avoid complex nested testing
|
||||
vi.mock('../CredentialManager', () => ({
|
||||
default: () => <div data-testid="credential-manager">Credential Manager Mock</div>,
|
||||
}));
|
||||
|
||||
// Mock translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dnsProviders.addProvider': 'Add DNS Provider',
|
||||
'dnsProviders.editProvider': 'Edit DNS Provider',
|
||||
'dnsProviders.providerName': 'Provider Name',
|
||||
'dnsProviders.providerType': 'Provider Type',
|
||||
'dnsProviders.propagationTimeout': 'Propagation Timeout (seconds)',
|
||||
'dnsProviders.pollingInterval': 'Polling Interval (seconds)',
|
||||
'dnsProviders.setAsDefault': 'Set as default provider',
|
||||
'dnsProviders.advancedSettings': 'Advanced Settings',
|
||||
'dnsProviders.testConnection': 'Test Connection',
|
||||
'dnsProviders.testSuccess': 'Connection test successful',
|
||||
'dnsProviders.testFailed': 'Connection test failed',
|
||||
'common.create': 'Create',
|
||||
'common.update': 'Update',
|
||||
'common.cancel': 'Cancel',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DNSProviderForm', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly in add mode', () => {
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Add DNS Provider')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Provider Name')).toBeInTheDocument();
|
||||
// Use role to find the trigger specifically
|
||||
expect(screen.getByRole('combobox', { name: 'Provider Type' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates fields when editing', async () => {
|
||||
const provider = {
|
||||
id: 1,
|
||||
uuid: 'prov-uuid',
|
||||
name: 'My Cloudflare',
|
||||
provider_type: 'cloudflare' as const,
|
||||
is_default: true,
|
||||
enabled: true,
|
||||
propagation_timeout: 180,
|
||||
polling_interval: 10,
|
||||
has_credentials: true,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
created_at: '2023-01-01',
|
||||
updated_at: '2023-01-01',
|
||||
};
|
||||
|
||||
render(<DNSProviderForm {...defaultProps} provider={provider} />);
|
||||
|
||||
expect(screen.getByText('Edit DNS Provider')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('My Cloudflare')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('API Token')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles form submission for creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Provider Name'), 'New Provider');
|
||||
|
||||
const typeSelectTrigger = screen.getByRole('combobox', { name: 'Provider Type' });
|
||||
await user.click(typeSelectTrigger);
|
||||
|
||||
// Select option by role to distinguish from trigger text
|
||||
await user.click(screen.getByRole('option', { name: 'Cloudflare' }));
|
||||
|
||||
const tokenInput = await screen.findByLabelText('API Token');
|
||||
await user.type(tokenInput, 'my-token');
|
||||
|
||||
mockCreateMutation.mutateAsync.mockResolvedValue({});
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(mockCreateMutation.mutateAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'New Provider',
|
||||
provider_type: 'cloudflare',
|
||||
credentials: { api_token: 'my-token' },
|
||||
}));
|
||||
expect(defaultProps.onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles validation failure (missing required fields)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Provider Name'), 'New Provider');
|
||||
|
||||
// Type is not selected, submit button should be disabled
|
||||
const submitBtn = screen.getByRole('button', { name: 'Create' });
|
||||
expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('tests connection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Provider Name'), 'Test Prov');
|
||||
await user.click(screen.getByRole('combobox', { name: 'Provider Type' }));
|
||||
await user.click(screen.getByRole('option', { name: 'Cloudflare' }));
|
||||
await user.type(screen.getByLabelText('API Token'), 'token');
|
||||
|
||||
mockTestCredentialsMutation.mutateAsync.mockResolvedValue({ success: true, message: 'Connection valid' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
expect(mockTestCredentialsMutation.mutateAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider_type: 'cloudflare',
|
||||
credentials: { api_token: 'token' }
|
||||
}));
|
||||
|
||||
expect(await screen.findByText('Connection test successful')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles test connection failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Provider Name'), 'Test Prov');
|
||||
await user.click(screen.getByRole('combobox', { name: 'Provider Type' }));
|
||||
await user.click(screen.getByRole('option', { name: 'Cloudflare' }));
|
||||
await user.type(screen.getByLabelText('API Token'), 'token');
|
||||
|
||||
// Simulate error response structure
|
||||
const errorResponse = {
|
||||
response: { data: { error: 'Invalid token' } }
|
||||
};
|
||||
mockTestCredentialsMutation.mutateAsync.mockRejectedValue(errorResponse);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
expect(await screen.findByText('Connection test failed')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Invalid token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles advanced settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DNSProviderForm {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByLabelText('Propagation Timeout (seconds)')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Advanced Settings' }));
|
||||
|
||||
expect(screen.getByLabelText('Propagation Timeout (seconds)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Polling Interval (seconds)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Set as default provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,501 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import DNSProviderSelector from '../DNSProviderSelector'
|
||||
import { useDNSProviders } from '../../hooks/useDNSProviders'
|
||||
import type { DNSProvider } from '../../api/dnsProviders'
|
||||
|
||||
vi.mock('../../hooks/useDNSProviders')
|
||||
|
||||
// Capture the onValueChange callback from Select component
|
||||
let capturedOnValueChange: ((value: string) => void) | undefined
|
||||
let capturedSelectDisabled: boolean | undefined
|
||||
let capturedSelectValue: string | undefined
|
||||
|
||||
// Mock the Select component to capture onValueChange and enable testing
|
||||
vi.mock('../ui', async () => {
|
||||
const actual = await vi.importActual('../ui')
|
||||
return {
|
||||
...actual,
|
||||
Select: ({ value, onValueChange, disabled, children }: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
capturedOnValueChange = onValueChange
|
||||
capturedSelectDisabled = disabled
|
||||
capturedSelectValue = value
|
||||
return (
|
||||
<div data-testid="select-mock" data-value={value} data-disabled={disabled}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SelectTrigger: ({ error, children }: { error?: boolean; children: React.ReactNode }) => (
|
||||
<button
|
||||
role="combobox"
|
||||
data-error={error}
|
||||
disabled={capturedSelectDisabled}
|
||||
aria-disabled={capturedSelectDisabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: ({ placeholder }: { placeholder?: string }) => {
|
||||
// Display actual selected value based on capturedSelectValue
|
||||
return <span data-placeholder={placeholder}>{capturedSelectValue || placeholder}</span>
|
||||
},
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div role="listbox">{children}</div>
|
||||
),
|
||||
SelectItem: ({ value, disabled, children }: { value: string; disabled?: boolean; children: React.ReactNode }) => (
|
||||
<div role="option" data-value={value} data-disabled={disabled}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const mockProviders: DNSProvider[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Cloudflare Prod',
|
||||
provider_type: 'cloudflare',
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 2,
|
||||
success_count: 10,
|
||||
failure_count: 0,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'uuid-2',
|
||||
name: 'Route53 Staging',
|
||||
provider_type: 'route53',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 60,
|
||||
polling_interval: 2,
|
||||
success_count: 5,
|
||||
failure_count: 1,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'uuid-3',
|
||||
name: 'Disabled Provider',
|
||||
provider_type: 'digitalocean',
|
||||
enabled: false,
|
||||
is_default: false,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 90,
|
||||
polling_interval: 2,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: 'uuid-4',
|
||||
name: 'No Credentials',
|
||||
provider_type: 'googleclouddns',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
has_credentials: false,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 2,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
return render(<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('DNSProviderSelector', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnValueChange = undefined
|
||||
capturedSelectDisabled = undefined
|
||||
capturedSelectValue = undefined
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: mockProviders,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders with label when provided', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={undefined} onChange={mockOnChange} label="DNS Provider" />
|
||||
)
|
||||
|
||||
expect(screen.getByText('DNS Provider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without label when not provided', () => {
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.queryByRole('label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows required asterisk when required=true', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
label="DNS Provider"
|
||||
required
|
||||
/>
|
||||
)
|
||||
|
||||
const label = screen.getByText('DNS Provider')
|
||||
expect(label.parentElement?.textContent).toContain('*')
|
||||
})
|
||||
|
||||
it('shows helper text when provided', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
helperText="Select a DNS provider for wildcard certificates"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('Select a DNS provider for wildcard certificates')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message when provided and replaces helper text', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
helperText="This should not appear"
|
||||
error="DNS provider is required"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('DNS provider is required')).toBeInTheDocument()
|
||||
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Filtering', () => {
|
||||
it('only shows enabled providers', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={undefined} onChange={mockOnChange} />
|
||||
)
|
||||
|
||||
// Component filters providers internally, verify filtering logic
|
||||
// by checking that only enabled providers with credentials are available
|
||||
const providers = mockProviders.filter((p) => p.enabled && p.has_credentials)
|
||||
expect(providers).toHaveLength(2)
|
||||
expect(providers[0].name).toBe('Cloudflare Prod')
|
||||
expect(providers[1].name).toBe('Route53 Staging')
|
||||
})
|
||||
|
||||
it('only shows providers with credentials', () => {
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// Verify filtering logic: providers must have both enabled=true and has_credentials=true
|
||||
const availableProviders = mockProviders.filter((p) => p.enabled && p.has_credentials)
|
||||
expect(availableProviders.every((p) => p.has_credentials)).toBe(true)
|
||||
})
|
||||
|
||||
it('filters out disabled providers', () => {
|
||||
const disabledProvider: DNSProvider = {
|
||||
...mockProviders[0],
|
||||
id: 5,
|
||||
enabled: false,
|
||||
name: 'Another Disabled',
|
||||
}
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [...mockProviders, disabledProvider],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// Verify the disabled provider is filtered out
|
||||
const allProviders = [...mockProviders, disabledProvider]
|
||||
const availableProviders = allProviders.filter((p) => p.enabled && p.has_credentials)
|
||||
expect(availableProviders.find((p) => p.name === 'Another Disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters out providers without credentials', () => {
|
||||
const noCredProvider: DNSProvider = {
|
||||
...mockProviders[0],
|
||||
id: 6,
|
||||
has_credentials: false,
|
||||
name: 'Missing Creds',
|
||||
}
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [...mockProviders, noCredProvider],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// Verify the provider without credentials is filtered out
|
||||
const allProviders = [...mockProviders, noCredProvider]
|
||||
const availableProviders = allProviders.filter((p) => p.enabled && p.has_credentials)
|
||||
expect(availableProviders.find((p) => p.name === 'Missing Creds')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('shows loading state while fetching', () => {
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// When loading, data is undefined and isLoading is true
|
||||
expect(screen.getByRole('combobox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables select during loading', () => {
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty States', () => {
|
||||
it('handles empty provider list', () => {
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// Verify selector renders even with empty list
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles all providers filtered out scenario', () => {
|
||||
const allDisabled = mockProviders.map((p) => ({ ...p, enabled: false }))
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: allDisabled,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// Verify selector renders with no available providers
|
||||
const availableProviders = allDisabled.filter((p) => p.enabled && p.has_credentials)
|
||||
expect(availableProviders).toHaveLength(0)
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection Behavior', () => {
|
||||
it('displays selected provider by ID', () => {
|
||||
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
|
||||
|
||||
// Verify the Select received the correct value
|
||||
expect(capturedSelectValue).toBe('1')
|
||||
})
|
||||
|
||||
it('shows none placeholder when value is undefined and not required', () => {
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// When value is undefined, the component uses 'none' as the Select value
|
||||
expect(capturedSelectValue).toBe('none')
|
||||
})
|
||||
|
||||
it('handles required prop correctly', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={undefined} onChange={mockOnChange} required />
|
||||
)
|
||||
|
||||
// When required, component should not include "none" in value
|
||||
const combobox = screen.getByRole('combobox')
|
||||
expect(combobox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stores provider ID in component state', () => {
|
||||
const { rerender } = renderWithClient(
|
||||
<DNSProviderSelector value={1} onChange={mockOnChange} />
|
||||
)
|
||||
|
||||
expect(capturedSelectValue).toBe('1')
|
||||
|
||||
// Change to different provider
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<DNSProviderSelector value={2} onChange={mockOnChange} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
expect(capturedSelectValue).toBe('2')
|
||||
})
|
||||
|
||||
it('handles undefined selection', () => {
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
// When undefined, the value should be 'none'
|
||||
expect(capturedSelectValue).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Display', () => {
|
||||
it('renders provider names correctly', () => {
|
||||
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
|
||||
|
||||
// Verify selected provider value is passed to Select
|
||||
expect(capturedSelectValue).toBe('1')
|
||||
// Provider names are rendered in SelectItems
|
||||
expect(screen.getByText('Cloudflare Prod')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('identifies default provider', () => {
|
||||
const defaultProvider = mockProviders.find((p) => p.is_default)
|
||||
expect(defaultProvider?.is_default).toBe(true)
|
||||
expect(defaultProvider?.name).toBe('Cloudflare Prod')
|
||||
})
|
||||
|
||||
it('includes provider type information', () => {
|
||||
// Verify mock data includes provider types
|
||||
expect(mockProviders[0].provider_type).toBe('cloudflare')
|
||||
expect(mockProviders[1].provider_type).toBe('route53')
|
||||
})
|
||||
|
||||
it('uses translation keys for provider types', () => {
|
||||
renderWithClient(<DNSProviderSelector value={1} onChange={mockOnChange} />)
|
||||
|
||||
// The component uses t(`dnsProviders.types.${provider.provider_type}`)
|
||||
// Our mock translation returns the key if not found
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('disables select when disabled=true', () => {
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} disabled />)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables select during loading', () => {
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
|
||||
renderWithClient(<DNSProviderSelector value={undefined} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('error has role="alert"', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
error="Required field"
|
||||
/>
|
||||
)
|
||||
|
||||
const errorElement = screen.getByText('Required field')
|
||||
expect(errorElement).toHaveAttribute('role', 'alert')
|
||||
})
|
||||
|
||||
it('label properly associates with select', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
label="Choose Provider"
|
||||
/>
|
||||
)
|
||||
|
||||
const label = screen.getByText('Choose Provider')
|
||||
const select = screen.getByRole('combobox')
|
||||
|
||||
// They should be associated (exact implementation may vary)
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(select).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Change Handling', () => {
|
||||
it('calls onChange with undefined when "none" is selected', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={1} onChange={mockOnChange} />
|
||||
)
|
||||
|
||||
// Invoke the captured onValueChange with 'none'
|
||||
expect(capturedOnValueChange).toBeDefined()
|
||||
capturedOnValueChange!('none')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('calls onChange with provider ID when a provider is selected', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={undefined} onChange={mockOnChange} />
|
||||
)
|
||||
|
||||
// Invoke the captured onValueChange with provider id '1'
|
||||
expect(capturedOnValueChange).toBeDefined()
|
||||
capturedOnValueChange!('1')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('calls onChange with different provider ID when switching providers', () => {
|
||||
renderWithClient(
|
||||
<DNSProviderSelector value={1} onChange={mockOnChange} />
|
||||
)
|
||||
|
||||
// Invoke the captured onValueChange with provider id '2'
|
||||
expect(capturedOnValueChange).toBeDefined()
|
||||
capturedOnValueChange!('2')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,249 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import ImportReviewTable from '../ImportReviewTable'
|
||||
|
||||
describe('ImportReviewTable - Status Display', () => {
|
||||
const mockOnCommit = vi.fn()
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
it('displays New badge for hosts without conflicts', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'app.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={[]}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('New')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays Conflict badge for hosts in conflicts array', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'conflict.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={['conflict.example.com']}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Conflict')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows expand button only for hosts with conflicts', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'conflict.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
},
|
||||
{
|
||||
domain_names: 'new.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
},
|
||||
]
|
||||
|
||||
const conflictDetails = {
|
||||
'conflict.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={['conflict.example.com']}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Expand button shows as triangle character
|
||||
const expandButtons = screen.getAllByRole('button', { name: /▶/ })
|
||||
expect(expandButtons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('expands to show conflict details when clicked', async () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'conflict.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
},
|
||||
]
|
||||
|
||||
const conflictDetails = {
|
||||
'conflict.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={['conflict.example.com']}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByRole('button', { name: /▶/ })
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses conflict details when clicked again', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'conflict.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
},
|
||||
]
|
||||
|
||||
const conflictDetails = {
|
||||
'conflict.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={['conflict.example.com']}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByRole('button', { name: /▶/ })
|
||||
|
||||
// Expand
|
||||
fireEvent.click(expandButton)
|
||||
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
|
||||
|
||||
// Collapse (now button shows ▼)
|
||||
const collapseButton = screen.getByRole('button', { name: /▼/ })
|
||||
fireEvent.click(collapseButton)
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows conflict resolution dropdown for conflicting hosts', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'conflict.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 80,
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={['conflict.example.com']}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const select = screen.getByRole('combobox')
|
||||
expect(select).toBeInTheDocument()
|
||||
expect(screen.getByText('Keep Existing (Skip Import)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Replace with Imported')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Will be imported" text for non-conflicting hosts', () => {
|
||||
const hosts = [
|
||||
{
|
||||
domain_names: 'new.example.com',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={hosts}
|
||||
conflicts={[]}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Will be imported')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportReviewTable from '../ImportReviewTable'
|
||||
import { mockImportPreview } from '../../test/mockData'
|
||||
|
||||
describe('ImportReviewTable', () => {
|
||||
const mockOnCommit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('displays hosts to import', () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Review Imported Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays conflicts with resolution dropdowns', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays errors', () => {
|
||||
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={errors}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Issues found during parsing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing required field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCommit with resolutions and names', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement
|
||||
await userEvent.selectOptions(dropdown, 'overwrite')
|
||||
|
||||
const commitButton = screen.getByText('Commit Import')
|
||||
await userEvent.click(commitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommit).toHaveBeenCalledWith(
|
||||
{ 'test.example.com': 'overwrite' },
|
||||
{ 'test.example.com': 'test.example.com' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Back'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows conflict indicator on conflicting hosts', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses conflict details', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
|
||||
// Find and click expand button (it's the ▶ button)
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Now should show details
|
||||
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.2:9090')).toBeInTheDocument()
|
||||
|
||||
// Click collapse button
|
||||
const collapseButton = screen.getByText('▼')
|
||||
await userEvent.click(collapseButton)
|
||||
|
||||
// Details should be hidden again
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows recommendation based on configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Expand to see recommendation
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Should show recommendation about config changes (SSL differs)
|
||||
expect(screen.getByText(/different SSL or WebSocket settings/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'https',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Check for differences being displayed
|
||||
expect(screen.getByText('https://192.168.1.2:9090')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { LanguageSelector } from '../LanguageSelector'
|
||||
import { LanguageProvider } from '../../context/LanguageContext'
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
language: 'en',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('LanguageSelector', () => {
|
||||
const renderWithProvider = () => {
|
||||
return render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders language selector with all options', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const select = screen.getByRole('combobox')
|
||||
expect(select).toBeInTheDocument()
|
||||
|
||||
// Check that all language options are available
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(5)
|
||||
expect(options[0]).toHaveTextContent('English')
|
||||
expect(options[1]).toHaveTextContent('Español')
|
||||
expect(options[2]).toHaveTextContent('Français')
|
||||
expect(options[3]).toHaveTextContent('Deutsch')
|
||||
expect(options[4]).toHaveTextContent('中文')
|
||||
})
|
||||
|
||||
it('displays globe icon', () => {
|
||||
const { container } = renderWithProvider()
|
||||
const svgElement = container.querySelector('svg')
|
||||
expect(svgElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes language when option is selected', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
expect(select.value).toBe('en')
|
||||
|
||||
fireEvent.change(select, { target: { value: 'es' } })
|
||||
expect(select.value).toBe('es')
|
||||
|
||||
fireEvent.change(select, { target: { value: 'fr' } })
|
||||
expect(select.value).toBe('fr')
|
||||
})
|
||||
})
|
||||
@@ -1,344 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from '../Layout'
|
||||
import { ThemeProvider } from '../../context/ThemeContext'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
// Mock AuthContext
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
logout: mockLogout,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({
|
||||
version: '0.1.0',
|
||||
git_commit: 'abcdef1',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn().mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock System API to prevent unhandled network requests
|
||||
vi.mock('../../api/system', () => ({
|
||||
getNotifications: vi.fn().mockResolvedValue([]),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
checkUpdates: vi.fn().mockResolvedValue({ available: false }),
|
||||
}))
|
||||
|
||||
const renderWithProviders = (children: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('sidebarCollapsed', 'false')
|
||||
// Default: all features enabled
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the application logo', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const logos = screen.getAllByAltText('Charon')
|
||||
expect(logos.length).toBeGreaterThan(0)
|
||||
expect(logos[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all navigation items', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Domains')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Certificates')).toBeInTheDocument()
|
||||
expect(await screen.findByText('DNS')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Settings')).toBeInTheDocument()
|
||||
|
||||
// Expand DNS to see nested items
|
||||
await user.click(await screen.findByRole('button', { name: /dns/i }))
|
||||
expect(await screen.findByText('DNS Providers')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Plugins')).toBeInTheDocument()
|
||||
|
||||
// Expand Security to see nested items
|
||||
await user.click(await screen.findByRole('button', { name: /security/i }))
|
||||
expect(await screen.findByText('Access Lists')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Rate Limiting')).toBeInTheDocument()
|
||||
|
||||
// Expand Tasks and Import to see nested items
|
||||
await user.click(await screen.findByRole('button', { name: /tasks/i }))
|
||||
expect(await screen.findByText('Import')).toBeInTheDocument()
|
||||
await user.click(await screen.findByRole('button', { name: /import/i }))
|
||||
expect(await screen.findByText('Caddyfile')).toBeInTheDocument()
|
||||
const crowdSecLinks = await screen.findAllByRole('link', { name: 'CrowdSec' })
|
||||
expect(crowdSecLinks.some(link => link.getAttribute('href') === '/tasks/import/crowdsec')).toBe(true)
|
||||
expect(await screen.findByText('Import NPM')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Import JSON')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children content', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays version information', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Version 0.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Logout'))
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles sidebar on mobile', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// The mobile sidebar toggle is found by test-id
|
||||
const toggleButton = screen.getByTestId('mobile-menu-toggle')
|
||||
|
||||
// Click to open the sidebar
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// The overlay should be present when mobile sidebar is open
|
||||
// The overlay has class 'fixed inset-0 bg-gray-900/50 z-20 lg:hidden'
|
||||
// Click the toggle again to close
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// Toggle button should still be in the document
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists collapse state to localStorage', async () => {
|
||||
localStorage.clear()
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const collapseBtn = await screen.findByTitle('Collapse sidebar')
|
||||
await userEvent.click(collapseBtn)
|
||||
expect(JSON.parse(localStorage.getItem('sidebarCollapsed') || 'false')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores collapsed state from localStorage on load', async () => {
|
||||
localStorage.setItem('sidebarCollapsed', 'true')
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByTitle('Expand sidebar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Feature Flags - Conditional Sidebar Items', () => {
|
||||
it('displays Security nav item when Cerberus is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Security nav item when Cerberus is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays Uptime nav item when Uptime is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Uptime nav item when Uptime is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Security and Uptime when both features are enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides both Security and Uptime when both features are disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// When flags are undefined, items should be visible by default (conservative approach)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows other nav items regardless of feature flags', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,661 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LiveLogViewer } from '../LiveLogViewer';
|
||||
import * as logsApi from '../../api/logs';
|
||||
|
||||
// Mock the connectLiveLogs and connectSecurityLogs functions
|
||||
vi.mock('../../api/logs', async () => {
|
||||
const actual = await vi.importActual('../../api/logs');
|
||||
return {
|
||||
...actual,
|
||||
connectLiveLogs: vi.fn(),
|
||||
connectSecurityLogs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LiveLogViewer', () => {
|
||||
let mockCloseConnection: ReturnType<typeof vi.fn>;
|
||||
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
|
||||
let mockOnSecurityMessage: ((log: logsApi.SecurityLogEntry) => void) | null;
|
||||
let mockOnClose: (() => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCloseConnection = vi.fn();
|
||||
mockOnMessage = null;
|
||||
mockOnSecurityMessage = null;
|
||||
mockOnClose = null;
|
||||
|
||||
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
|
||||
mockOnMessage = onMessage;
|
||||
mockOnClose = onClose ?? null;
|
||||
// Simulate connection success
|
||||
if (onOpen) {
|
||||
setTimeout(() => onOpen(), 0);
|
||||
}
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
|
||||
vi.mocked(logsApi.connectSecurityLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
|
||||
mockOnSecurityMessage = onMessage;
|
||||
mockOnClose = onClose ?? null;
|
||||
// Simulate connection success
|
||||
if (onOpen) {
|
||||
setTimeout(() => onOpen(), 0);
|
||||
}
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component with initial state', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Default mode is now 'security'
|
||||
expect(screen.getByText('Security Access Logs')).toBeTruthy();
|
||||
// Initially disconnected until WebSocket opens
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Wait for onOpen callback to be called
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('displays incoming log messages', async () => {
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Simulate receiving a log
|
||||
const logEntry: logsApi.LiveLogEntry = {
|
||||
level: 'info',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Test log message',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logEntry);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test log message')).toBeTruthy();
|
||||
expect(screen.getByText('INFO')).toBeTruthy();
|
||||
expect(screen.getByText('[test]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by text', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'First message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Second message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.getByText('Second message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply text filter
|
||||
const filterInput = screen.getByPlaceholderText('Filter by text...');
|
||||
await user.type(filterInput, 'First');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.queryByText('Second message')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by level', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Info message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Error message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info message')).toBeTruthy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply level filter
|
||||
const levelSelect = screen.getAllByRole('combobox')[0];
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Info message')).toBeFalsy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('pauses and resumes log streaming', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Add initial log
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Before pause' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Before pause')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click pause button
|
||||
const pauseButton = screen.getByTitle('Pause');
|
||||
await user.click(pauseButton);
|
||||
|
||||
// Verify paused state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('⏸ Paused')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Try to add log while paused (should not appear)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'During pause' });
|
||||
}
|
||||
|
||||
// Log should not appear
|
||||
expect(screen.queryByText('During pause')).toBeFalsy();
|
||||
|
||||
// Resume
|
||||
const resumeButton = screen.getByTitle('Resume');
|
||||
await user.click(resumeButton);
|
||||
|
||||
// Add log after resume
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'After resume' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('After resume')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Add logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Log 1')).toBeTruthy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByTitle('Clear logs');
|
||||
await user.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.queryByText('Log 2')).toBeFalsy();
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('limits the number of stored logs', async () => {
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer maxLogs={2} mode="application" />);
|
||||
|
||||
// Add 3 logs (exceeding maxLogs)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'Log 3' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// First log should be removed, only last 2 should remain
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
expect(screen.getByText('Log 3')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays log data when available', async () => {
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
const logWithData: logsApi.LiveLogEntry = {
|
||||
level: 'error',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Error occurred',
|
||||
data: { error_code: 500, details: 'Internal server error' },
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logWithData);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error occurred')).toBeTruthy();
|
||||
// Check that data is rendered as JSON
|
||||
expect(screen.getByText(/"error_code"/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes WebSocket connection on unmount', () => {
|
||||
const { unmount } = render(<LiveLogViewer />);
|
||||
|
||||
// Default mode is security
|
||||
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockCloseConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<LiveLogViewer className="custom-class" />);
|
||||
|
||||
const element = container.querySelector('.custom-class');
|
||||
expect(element).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows correct connection status', async () => {
|
||||
let mockOnOpen: (() => void) | undefined;
|
||||
let mockOnError: ((error: Event) => void) | undefined;
|
||||
|
||||
// Use security logs mock since default mode is security
|
||||
vi.mocked(logsApi.connectSecurityLogs).mockImplementation((_filters, _onMessage, onOpen, onError) => {
|
||||
mockOnOpen = onOpen;
|
||||
mockOnError = onError;
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Initially disconnected until onOpen is called
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Simulate connection opened
|
||||
if (mockOnOpen) {
|
||||
mockOnOpen();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate connection error
|
||||
if (mockOnError) {
|
||||
mockOnError(new Event('error'));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
// Should show error message
|
||||
expect(screen.getByText('Failed to connect to log stream. Check your authentication or try refreshing.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-match message when filters exclude all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Explicitly use application mode for this test
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Visible' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Hidden' });
|
||||
}
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Visible')).toBeTruthy());
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Filter by text...'), 'nomatch');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No logs match the current filters.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks connection as disconnected when WebSocket closes', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
act(() => {
|
||||
mockOnClose?.();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Security Mode Tests
|
||||
// ============================================================
|
||||
|
||||
describe('Security Mode', () => {
|
||||
it('renders in security mode when mode="security"', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
expect(screen.getByText('Security Access Logs')).toBeTruthy();
|
||||
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays security log entries with source badges', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
const securityLog: logsApi.SecurityLogEntry = {
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'info',
|
||||
logger: 'http.log.access',
|
||||
client_ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/api/test',
|
||||
status: 200,
|
||||
duration: 0.05,
|
||||
size: 1024,
|
||||
user_agent: 'TestAgent/1.0',
|
||||
host: 'example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
};
|
||||
|
||||
if (mockOnSecurityMessage) {
|
||||
mockOnSecurityMessage(securityLog);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('NORMAL')).toBeTruthy();
|
||||
expect(screen.getByText('192.168.1.100')).toBeTruthy();
|
||||
expect(screen.getByText(/GET \/api\/test → 200/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays blocked requests with special styling', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
const blockedLog: logsApi.SecurityLogEntry = {
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'warn',
|
||||
logger: 'http.handlers.waf',
|
||||
client_ip: '10.0.0.1',
|
||||
method: 'POST',
|
||||
uri: '/admin',
|
||||
status: 403,
|
||||
duration: 0.001,
|
||||
size: 0,
|
||||
user_agent: 'Attack/1.0',
|
||||
host: 'example.com',
|
||||
source: 'waf',
|
||||
blocked: true,
|
||||
block_reason: 'SQL injection detected',
|
||||
};
|
||||
|
||||
// Send message inside act to properly handle state updates
|
||||
await act(async () => {
|
||||
if (mockOnSecurityMessage) {
|
||||
mockOnSecurityMessage(blockedLog);
|
||||
}
|
||||
});
|
||||
|
||||
// Use findBy queries (built-in waiting) instead of single waitFor with multiple assertions
|
||||
// This avoids race conditions where one failing assertion causes the entire block to retry
|
||||
await screen.findByText('10.0.0.1');
|
||||
await screen.findByText(/🚫 BLOCKED: SQL injection detected/);
|
||||
await screen.findByText(/\[SQL injection detected\]/);
|
||||
|
||||
// For getAllByText, keep in waitFor but separate from other assertions
|
||||
await waitFor(() => {
|
||||
// Use getAllByText since 'WAF' appears both in dropdown option and source badge
|
||||
const wafElements = screen.getAllByText('WAF');
|
||||
expect(wafElements.length).toBeGreaterThanOrEqual(2); // Option + badge
|
||||
});
|
||||
}, 15000); // 15 second timeout as safeguard
|
||||
|
||||
it('shows source filter dropdown in security mode', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Should have source filter options
|
||||
expect(screen.getByText('All Sources')).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: 'WAF' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: 'CrowdSec' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: 'Rate Limit' })).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: 'ACL' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters by source in security mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
// Add logs from different sources
|
||||
if (mockOnSecurityMessage) {
|
||||
mockOnSecurityMessage({
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'info',
|
||||
logger: 'http.log.access',
|
||||
client_ip: '192.168.1.1',
|
||||
method: 'GET',
|
||||
uri: '/normal-request',
|
||||
status: 200,
|
||||
duration: 0.01,
|
||||
size: 100,
|
||||
user_agent: 'Test/1.0',
|
||||
host: 'example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
});
|
||||
mockOnSecurityMessage({
|
||||
timestamp: '2025-12-12T10:30:01Z',
|
||||
level: 'warn',
|
||||
logger: 'http.handlers.waf',
|
||||
client_ip: '10.0.0.1',
|
||||
method: 'POST',
|
||||
uri: '/waf-blocked',
|
||||
status: 403,
|
||||
duration: 0.001,
|
||||
size: 0,
|
||||
user_agent: 'Attack/1.0',
|
||||
host: 'example.com',
|
||||
source: 'waf',
|
||||
blocked: true,
|
||||
block_reason: 'WAF block',
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for logs to appear - normal shows URI, blocked shows block message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/GET \/normal-request/)).toBeTruthy();
|
||||
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Filter by WAF using the source dropdown (second combobox after level)
|
||||
const sourceSelects = screen.getAllByRole('combobox');
|
||||
const sourceFilterSelect = sourceSelects[1]; // Second combobox is source filter
|
||||
|
||||
await user.selectOptions(sourceFilterSelect, 'waf');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/GET \/normal-request/)).toBeFalsy();
|
||||
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows blocked only checkbox in security mode', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
expect(screen.getByText('Blocked only')).toBeTruthy();
|
||||
expect(screen.getByRole('checkbox')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles blocked only filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
await user.click(checkbox);
|
||||
|
||||
// Verify checkbox is checked
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('displays duration for security logs', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
const securityLog: logsApi.SecurityLogEntry = {
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'info',
|
||||
logger: 'http.log.access',
|
||||
client_ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/api/test',
|
||||
status: 200,
|
||||
duration: 0.123,
|
||||
size: 1024,
|
||||
user_agent: 'TestAgent/1.0',
|
||||
host: 'example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
};
|
||||
|
||||
if (mockOnSecurityMessage) {
|
||||
mockOnSecurityMessage(securityLog);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('123.0ms')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays status code with appropriate color for security logs', async () => {
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
if (mockOnSecurityMessage) {
|
||||
mockOnSecurityMessage({
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'info',
|
||||
logger: 'http.log.access',
|
||||
client_ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/ok',
|
||||
status: 200,
|
||||
duration: 0.01,
|
||||
size: 100,
|
||||
user_agent: 'Test/1.0',
|
||||
host: 'example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('[200]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Mode Toggle Tests
|
||||
// ============================================================
|
||||
|
||||
describe('Mode Toggle', () => {
|
||||
it('switches from application to security mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
expect(screen.getByText('Live Security Logs')).toBeTruthy();
|
||||
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
|
||||
|
||||
// Click security mode button
|
||||
const securityButton = screen.getByTitle('Security access logs');
|
||||
await user.click(securityButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Access Logs')).toBeTruthy();
|
||||
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches from security to application mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="security" />);
|
||||
|
||||
expect(screen.getByText('Security Access Logs')).toBeTruthy();
|
||||
|
||||
// Click application mode button
|
||||
const appButton = screen.getByTitle('Application logs');
|
||||
await user.click(appButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Live Security Logs')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears logs when switching modes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Add a log in application mode
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-12T10:30:00Z', message: 'App log' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('App log')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch to security mode
|
||||
const securityButton = screen.getByTitle('Security access logs');
|
||||
await user.click(securityButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('App log')).toBeFalsy();
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets filters when switching modes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer mode="application" />);
|
||||
|
||||
// Set a filter
|
||||
const filterInput = screen.getByPlaceholderText('Filter by text...');
|
||||
await user.type(filterInput, 'test');
|
||||
|
||||
// Switch to security mode
|
||||
const securityButton = screen.getByTitle('Security access logs');
|
||||
await user.click(securityButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Filter should be cleared
|
||||
expect(screen.getByPlaceholderText('Filter by text...')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CharonLoader, CharonCoinLoader, CerberusLoader, ConfigReloadOverlay } from '../LoadingStates'
|
||||
|
||||
describe('CharonLoader', () => {
|
||||
it('renders boat animation with accessibility label', () => {
|
||||
render(<CharonLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders coin animation with accessibility label', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders guardian animation with accessibility label', () => {
|
||||
render(<CerberusLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay', () => {
|
||||
it('renders with Charon theme (default)', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Coin theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Paying the ferryman..."
|
||||
submessage="Your obol grants passage"
|
||||
type="coin"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Cerberus theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Cerberus awakens..."
|
||||
submessage="Guardian of the gates stands watch"
|
||||
type="cerberus"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cerberus awakens...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Guardian of the gates stands watch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom messages', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Custom message"
|
||||
submessage="Custom submessage"
|
||||
type="charon"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom submessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct theme colors', () => {
|
||||
const { container, rerender } = render(<ConfigReloadOverlay type="charon" />)
|
||||
let overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="coin" />)
|
||||
overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="cerberus" />)
|
||||
overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as full-screen overlay with high z-index', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.fixed.inset-0.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CharonLoader,
|
||||
CharonCoinLoader,
|
||||
CerberusLoader,
|
||||
ConfigReloadOverlay,
|
||||
} from '../LoadingStates'
|
||||
|
||||
describe('LoadingStates - Security Audit', () => {
|
||||
describe('CharonLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles all size variants', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label', () => {
|
||||
render(<CharonLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for authentication', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders gradient definition', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const gradient = container.querySelector('#goldGradient')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonCoinLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for security', () => {
|
||||
render(<CerberusLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders three heads (three circles for heads)', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const circles = container.querySelectorAll('circle')
|
||||
// At least 3 head circles should exist (plus paws and eyes)
|
||||
expect(circles.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CerberusLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay - XSS Protection', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in message prop', () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>'
|
||||
render(<ConfigReloadOverlay message={xssPayload} />)
|
||||
|
||||
// React should escape this automatically
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in submessage prop', () => {
|
||||
const xssPayload = '<img src=x onerror="alert(1)">'
|
||||
render(<ConfigReloadOverlay submessage={xssPayload} />)
|
||||
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('img[onerror]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles extremely long messages', () => {
|
||||
const longMessage = 'A'.repeat(10000)
|
||||
const { container } = render(<ConfigReloadOverlay message={longMessage} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles special characters', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message={specialChars}
|
||||
submessage={specialChars}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText(specialChars)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ATTACK: handles unicode and emoji', () => {
|
||||
const unicode = '🔥💀🐕🦺 λ µ π Σ 中文 العربية עברית'
|
||||
render(<ConfigReloadOverlay message={unicode} />)
|
||||
|
||||
expect(screen.getByText(unicode)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - charon (blue)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="charon" />)
|
||||
const overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - coin (gold)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="coin" />)
|
||||
const overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - cerberus (red)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="cerberus" />)
|
||||
const overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct z-index (z-50)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies backdrop blur', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const backdrop = container.querySelector('.backdrop-blur-sm')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: type prop injection attempt', () => {
|
||||
// @ts-expect-error - Testing invalid type
|
||||
const { container } = render(<ConfigReloadOverlay type="<script>alert(1)</script>" />)
|
||||
|
||||
// Should default to charon theme
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Integration Tests', () => {
|
||||
it('CharonLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="charon" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('CharonCoinLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="coin" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('CerberusLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="cerberus" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Animation Requirements', () => {
|
||||
it('CharonLoader uses animate-bob-boat class', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
const animated = container.querySelector('.animate-bob-boat')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CharonCoinLoader uses animate-spin-y class', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const animated = container.querySelector('.animate-spin-y')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CerberusLoader uses animate-rotate-head class', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const animated = container.querySelector('.animate-rotate-head')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined size prop gracefully', () => {
|
||||
const { container } = render(<CharonLoader size={undefined} />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20') // defaults to md
|
||||
})
|
||||
|
||||
it('handles null message', () => {
|
||||
// @ts-expect-error - Testing null
|
||||
render(<ConfigReloadOverlay message={null} />)
|
||||
// Null message renders as empty paragraph - component gracefully handles null
|
||||
const textContainer = screen.getByText(/Charon is crossing the Styx/i).closest('div')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty string message', () => {
|
||||
render(<ConfigReloadOverlay message="" submessage="" />)
|
||||
// Should render but be empty
|
||||
expect(screen.queryByText('Ferrying configuration...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles undefined type prop', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type={undefined} />)
|
||||
// Should default to charon
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility Requirements', () => {
|
||||
it('overlay is keyboard accessible', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.firstChild
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('all loaders have status role', () => {
|
||||
render(
|
||||
<>
|
||||
<CharonLoader />
|
||||
<CharonCoinLoader />
|
||||
<CerberusLoader />
|
||||
</>
|
||||
)
|
||||
const statuses = screen.getAllByRole('status')
|
||||
expect(statuses).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('all loaders have aria-label', () => {
|
||||
const { container: c1 } = render(<CharonLoader />)
|
||||
const { container: c2 } = render(<CharonCoinLoader />)
|
||||
const { container: c3 } = render(<CerberusLoader />)
|
||||
|
||||
expect(c1.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c2.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c3.firstChild).toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
it('renders CharonLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100) // Should render in <100ms
|
||||
})
|
||||
|
||||
it('renders CharonCoinLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonCoinLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders CerberusLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CerberusLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders ConfigReloadOverlay quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<ConfigReloadOverlay />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,712 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, act } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ManualDNSChallenge from '../dns-providers/ManualDNSChallenge'
|
||||
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
|
||||
import type { ManualChallenge } from '../../api/manualChallenge'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../hooks/useManualChallenge')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock clipboard API using vi.stubGlobal
|
||||
const mockWriteText = vi.fn()
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dnsProvider.manual.title': 'Manual DNS Challenge',
|
||||
'dnsProvider.manual.instructions': `To obtain a certificate for ${options?.domain || 'example.com'}, create the following TXT record at your DNS provider:`,
|
||||
'dnsProvider.manual.createRecord': 'Create this TXT record at your DNS provider',
|
||||
'dnsProvider.manual.recordName': 'Record Name',
|
||||
'dnsProvider.manual.recordValue': 'Record Value',
|
||||
'dnsProvider.manual.ttl': 'TTL',
|
||||
'dnsProvider.manual.seconds': 'seconds',
|
||||
'dnsProvider.manual.minutes': 'minutes',
|
||||
'dnsProvider.manual.timeRemaining': 'Time remaining',
|
||||
'dnsProvider.manual.progressPercent': `${options?.percent || 0}% time remaining`,
|
||||
'dnsProvider.manual.challengeProgress': 'Challenge timeout progress',
|
||||
'dnsProvider.manual.copy': 'Copy',
|
||||
'dnsProvider.manual.copied': 'Copied!',
|
||||
'dnsProvider.manual.copyFailed': 'Failed to copy to clipboard',
|
||||
'dnsProvider.manual.copyRecordName': 'Copy record name to clipboard',
|
||||
'dnsProvider.manual.copyRecordValue': 'Copy record value to clipboard',
|
||||
'dnsProvider.manual.checkDnsNow': 'Check DNS Now',
|
||||
'dnsProvider.manual.checkDnsDescription': 'Immediately check if the DNS record has propagated',
|
||||
'dnsProvider.manual.verifyButton': "I've Created the Record - Verify",
|
||||
'dnsProvider.manual.verifyDescription': 'Verify that the DNS record exists',
|
||||
'dnsProvider.manual.cancelChallenge': 'Cancel Challenge',
|
||||
'dnsProvider.manual.lastCheck': 'Last checked',
|
||||
'dnsProvider.manual.lastCheckSecondsAgo': `${options?.seconds || 0} seconds ago`,
|
||||
'dnsProvider.manual.lastCheckMinutesAgo': `${options?.minutes || 0} minutes ago`,
|
||||
'dnsProvider.manual.notPropagated': 'DNS record not yet propagated',
|
||||
'dnsProvider.manual.dnsNotFound': 'DNS record not found',
|
||||
'dnsProvider.manual.verifySuccess': 'DNS challenge verified successfully!',
|
||||
'dnsProvider.manual.verifyFailed': 'DNS verification failed',
|
||||
'dnsProvider.manual.challengeExpired': 'Challenge expired',
|
||||
'dnsProvider.manual.challengeCancelled': 'Challenge cancelled',
|
||||
'dnsProvider.manual.cancelFailed': 'Failed to cancel challenge',
|
||||
'dnsProvider.manual.statusChanged': `Challenge status changed to ${options?.status || ''}`,
|
||||
'dnsProvider.manual.status.created': 'Created',
|
||||
'dnsProvider.manual.status.pending': 'Pending',
|
||||
'dnsProvider.manual.status.verifying': 'Verifying...',
|
||||
'dnsProvider.manual.status.verified': 'Verified',
|
||||
'dnsProvider.manual.status.expired': 'Expired',
|
||||
'dnsProvider.manual.status.failed': 'Failed',
|
||||
'dnsProvider.manual.statusMessage.pending': 'Waiting for DNS propagation...',
|
||||
'dnsProvider.manual.statusMessage.verified': 'DNS challenge verified successfully!',
|
||||
'dnsProvider.manual.statusMessage.expired': 'Challenge has expired.',
|
||||
'dnsProvider.manual.statusMessage.failed': 'DNS verification failed.',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChallenge: ManualChallenge = {
|
||||
id: 'test-challenge-uuid',
|
||||
status: 'pending',
|
||||
fqdn: '_acme-challenge.example.com',
|
||||
value: 'gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7',
|
||||
ttl: 300,
|
||||
created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
|
||||
expires_at: new Date(Date.now() + 8 * 60 * 1000).toISOString(), // 8 minutes from now
|
||||
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(), // 10 seconds ago
|
||||
dns_propagated: false,
|
||||
}
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderComponent = (
|
||||
challenge: ManualChallenge = mockChallenge,
|
||||
onComplete = vi.fn(),
|
||||
onCancel = vi.fn()
|
||||
) => {
|
||||
const queryClient = createQueryClient()
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ManualDNSChallenge
|
||||
providerId={1}
|
||||
challenge={challenge}
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
onComplete,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ManualDNSChallenge', () => {
|
||||
let mockVerifyMutation: ReturnType<typeof vi.fn>
|
||||
let mockDeleteMutation: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
mockVerifyMutation = vi.fn()
|
||||
mockDeleteMutation = vi.fn()
|
||||
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'pending',
|
||||
dns_propagated: false,
|
||||
time_remaining_seconds: 480,
|
||||
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
vi.mocked(useManualChallengeMutations).mockReturnValue({
|
||||
verifyMutation: {
|
||||
mutateAsync: mockVerifyMutation,
|
||||
isPending: false,
|
||||
},
|
||||
deleteMutation: {
|
||||
mutateAsync: mockDeleteMutation,
|
||||
isPending: false,
|
||||
},
|
||||
createMutation: {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
},
|
||||
} as unknown as ReturnType<typeof useManualChallengeMutations>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the challenge title', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the FQDN record name', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('_acme-challenge.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the challenge value', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays TTL information', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText(/300/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/5 minutes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders copy buttons with aria labels', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy record name/i })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy record value/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders verify and check DNS buttons', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Check DNS Now')).toBeInTheDocument()
|
||||
expect(screen.getByText("I've Created the Record - Verify")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders cancel button when not in terminal state', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Cancel Challenge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress and Countdown', () => {
|
||||
it('displays time remaining', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText(/Time remaining/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays progress bar', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: /challenge.*progress/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates countdown every second', async () => {
|
||||
renderComponent()
|
||||
|
||||
// Get initial time display
|
||||
const timeElement = screen.getByText(/Time remaining/i)
|
||||
expect(timeElement).toBeInTheDocument()
|
||||
|
||||
// Advance timer by 1 second
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
// Time should have updated (countdown decreased)
|
||||
expect(timeElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy Functionality', () => {
|
||||
// Note: These tests verify the clipboard copy functionality.
|
||||
// Due to jsdom limitations with navigator.clipboard mocking, we test
|
||||
// the UI state changes instead of verifying the actual clipboard API calls.
|
||||
// The component correctly shows "Copied!" state after clicking, which
|
||||
// indicates the async copy handler completed successfully.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
it('shows copied state after clicking copy record name button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
|
||||
await user.click(copyNameButton)
|
||||
|
||||
// The button should show the "Copied!" state after successful copy
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows copied state after clicking copy record value button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
|
||||
await user.click(copyValueButton)
|
||||
|
||||
// The button should show the "Copied!" state after successful copy
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('copy buttons are accessible and clickable', () => {
|
||||
renderComponent()
|
||||
|
||||
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
|
||||
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
|
||||
|
||||
expect(copyNameButton).toBeEnabled()
|
||||
expect(copyValueButton).toBeEnabled()
|
||||
expect(copyNameButton).toHaveAttribute('aria-label')
|
||||
expect(copyValueButton).toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Verification', () => {
|
||||
it('calls verify mutation when verify button is clicked', async () => {
|
||||
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const verifyButton = screen.getByText("I've Created the Record - Verify")
|
||||
await user.click(verifyButton)
|
||||
|
||||
expect(mockVerifyMutation).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
challengeId: 'test-challenge-uuid',
|
||||
})
|
||||
})
|
||||
|
||||
it('calls verify mutation when Check DNS Now is clicked', async () => {
|
||||
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const checkButton = screen.getByText('Check DNS Now')
|
||||
await user.click(checkButton)
|
||||
|
||||
expect(mockVerifyMutation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success toast on successful verification', async () => {
|
||||
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const verifyButton = screen.getByText("I've Created the Record - Verify")
|
||||
await user.click(verifyButton)
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('DNS challenge verified successfully!')
|
||||
})
|
||||
|
||||
it('shows warning toast when DNS not found', async () => {
|
||||
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const verifyButton = screen.getByText("I've Created the Record - Verify")
|
||||
await user.click(verifyButton)
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('DNS record not found')
|
||||
})
|
||||
|
||||
it('shows error toast on verification failure', async () => {
|
||||
mockVerifyMutation.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Server error' } },
|
||||
})
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const verifyButton = screen.getByText("I've Created the Record - Verify")
|
||||
await user.click(verifyButton)
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Server error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancellation', () => {
|
||||
it('calls delete mutation and onCancel when cancelled', async () => {
|
||||
mockDeleteMutation.mockResolvedValueOnce(undefined)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const { onCancel } = renderComponent()
|
||||
|
||||
const cancelButton = screen.getByText('Cancel Challenge')
|
||||
await user.click(cancelButton)
|
||||
|
||||
expect(mockDeleteMutation).toHaveBeenCalledWith({
|
||||
providerId: 1,
|
||||
challengeId: 'test-challenge-uuid',
|
||||
})
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(toast.info).toHaveBeenCalledWith('Challenge cancelled')
|
||||
})
|
||||
|
||||
it('shows error toast when cancellation fails', async () => {
|
||||
mockDeleteMutation.mockRejectedValueOnce({
|
||||
response: { data: { message: 'Cannot cancel' } },
|
||||
})
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
renderComponent()
|
||||
|
||||
const cancelButton = screen.getByText('Cancel Challenge')
|
||||
await user.click(cancelButton)
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Cannot cancel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Terminal States', () => {
|
||||
it('hides cancel button when challenge is verified', () => {
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'verified',
|
||||
dns_propagated: true,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const verifiedChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'verified',
|
||||
dns_propagated: true,
|
||||
}
|
||||
renderComponent(verifiedChallenge)
|
||||
|
||||
expect(screen.queryByText('Cancel Challenge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides progress bar when challenge is expired', () => {
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'expired',
|
||||
dns_propagated: false,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const expiredChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'expired',
|
||||
}
|
||||
renderComponent(expiredChallenge)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('progressbar', { name: /challenge.*progress/i })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables verify buttons when challenge is failed', () => {
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'failed',
|
||||
dns_propagated: false,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
error_message: 'ACME validation failed',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const failedChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'failed',
|
||||
}
|
||||
renderComponent(failedChallenge)
|
||||
|
||||
expect(screen.getByText('Check DNS Now').closest('button')).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText("I've Created the Record - Verify").closest('button')
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onComplete with true when status changes to verified', async () => {
|
||||
const { onComplete, rerender } = renderComponent()
|
||||
|
||||
// Update poll data to verified
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'verified',
|
||||
dns_propagated: true,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
// Re-render to trigger effect
|
||||
const verifiedChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'verified',
|
||||
dns_propagated: true,
|
||||
}
|
||||
|
||||
const queryClient = createQueryClient()
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ManualDNSChallenge
|
||||
providerId={1}
|
||||
challenge={verifiedChallenge}
|
||||
onComplete={onComplete}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onComplete).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onComplete with false when status changes to expired', async () => {
|
||||
const { onComplete, rerender } = renderComponent()
|
||||
|
||||
// Update poll data to expired
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'expired',
|
||||
dns_propagated: false,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const expiredChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'expired',
|
||||
}
|
||||
|
||||
const queryClient = createQueryClient()
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ManualDNSChallenge
|
||||
providerId={1}
|
||||
challenge={expiredChallenge}
|
||||
onComplete={onComplete}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onComplete).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper ARIA labels for copy buttons', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy record name to clipboard/i })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy record value to clipboard/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has screen reader announcer for status changes', () => {
|
||||
renderComponent()
|
||||
|
||||
const announcer = document.querySelector('[role="status"][aria-live="polite"]')
|
||||
expect(announcer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has proper labels for form fields', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Record Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Record Value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('progress bar has accessible label', () => {
|
||||
renderComponent()
|
||||
|
||||
const progressBar = screen.getByRole('progressbar')
|
||||
expect(progressBar).toHaveAttribute('aria-label')
|
||||
})
|
||||
|
||||
it('buttons have aria-describedby for additional context', () => {
|
||||
renderComponent()
|
||||
|
||||
const checkDnsButton = screen.getByText('Check DNS Now').closest('button')
|
||||
expect(checkDnsButton).toHaveAttribute('aria-describedby')
|
||||
})
|
||||
|
||||
it('uses semantic heading structure', () => {
|
||||
renderComponent()
|
||||
|
||||
// Card title should exist
|
||||
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
|
||||
|
||||
// Section heading for DNS record
|
||||
expect(screen.getByText('Create this TXT record at your DNS provider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Polling Behavior', () => {
|
||||
it('enables polling when challenge is pending', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', true, 10000)
|
||||
})
|
||||
|
||||
it('disables polling when challenge is in terminal state', () => {
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'verified',
|
||||
dns_propagated: true,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const verifiedChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'verified',
|
||||
}
|
||||
renderComponent(verifiedChallenge)
|
||||
|
||||
// The component should pass enabled=false for terminal states
|
||||
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', false, 10000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('shows loading state on verify button while verifying', () => {
|
||||
vi.mocked(useManualChallengeMutations).mockReturnValue({
|
||||
verifyMutation: {
|
||||
mutateAsync: mockVerifyMutation,
|
||||
isPending: true,
|
||||
},
|
||||
deleteMutation: {
|
||||
mutateAsync: mockDeleteMutation,
|
||||
isPending: false,
|
||||
},
|
||||
createMutation: {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
},
|
||||
} as unknown as ReturnType<typeof useManualChallengeMutations>)
|
||||
|
||||
renderComponent()
|
||||
|
||||
const verifyButton = screen.getByText("I've Created the Record - Verify").closest('button')
|
||||
expect(verifyButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows loading state on cancel button while cancelling', () => {
|
||||
vi.mocked(useManualChallengeMutations).mockReturnValue({
|
||||
verifyMutation: {
|
||||
mutateAsync: mockVerifyMutation,
|
||||
isPending: false,
|
||||
},
|
||||
deleteMutation: {
|
||||
mutateAsync: mockDeleteMutation,
|
||||
isPending: true,
|
||||
},
|
||||
createMutation: {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
},
|
||||
} as unknown as ReturnType<typeof useManualChallengeMutations>)
|
||||
|
||||
renderComponent()
|
||||
|
||||
const cancelButton = screen.getByText('Cancel Challenge').closest('button')
|
||||
expect(cancelButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Messages', () => {
|
||||
it('displays error message from poll response', () => {
|
||||
vi.mocked(useChallengePoll).mockReturnValue({
|
||||
data: {
|
||||
status: 'failed',
|
||||
dns_propagated: false,
|
||||
time_remaining_seconds: 0,
|
||||
last_check_at: new Date().toISOString(),
|
||||
error_message: 'ACME server rejected the challenge',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as unknown as ReturnType<typeof useChallengePoll>)
|
||||
|
||||
const failedChallenge: ManualChallenge = {
|
||||
...mockChallenge,
|
||||
status: 'failed',
|
||||
error_message: 'ACME server rejected the challenge',
|
||||
}
|
||||
renderComponent(failedChallenge)
|
||||
|
||||
expect(screen.getByText('ACME server rejected the challenge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Last Check Display', () => {
|
||||
it('shows last check time when available', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText(/Last checked/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows propagation status when not propagated', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText(/not yet propagated/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import NotificationCenter from '../NotificationCenter'
|
||||
import * as api from '../../api/system'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/system', () => ({
|
||||
getNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockNotifications: api.Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
title: 'Info Notification',
|
||||
message: 'This is an info message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'success',
|
||||
title: 'Success Notification',
|
||||
message: 'This is a success message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Warning Notification',
|
||||
message: 'This is a warning message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'error',
|
||||
title: 'Error Notification',
|
||||
message: 'This is an error message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('NotificationCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '0.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders bell icon and unread count', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens notification panel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await user.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Success Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warning Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error Notification')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays empty state when no notifications', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getNotifications).mockResolvedValue([])
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await user.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No new notifications')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks single notification as read', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markNotificationRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i })
|
||||
await user.click(closeButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark all read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('Mark all read'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markAllNotificationsRead).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes panel when clicking outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('notification-backdrop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PasswordStrengthMeter } from '../PasswordStrengthMeter'
|
||||
|
||||
describe('PasswordStrengthMeter', () => {
|
||||
it('renders nothing when password is empty', () => {
|
||||
const { container } = render(<PasswordStrengthMeter password="" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders strength label when password is provided', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// Depending on the implementation, it might show "Weak", "Fair", etc.
|
||||
// "password123" is likely weak or fair.
|
||||
// Let's just check if any text is rendered.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders progress bars', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// It usually renders 4 bars
|
||||
// In the implementation I read, it renders one bar with width.
|
||||
// <div className="h-1.5 w-full ..."><div className="h-full ..." style={{ width: ... }} /></div>
|
||||
// So we can check for the progress bar container or the inner bar.
|
||||
// Let's check for the label text which we already did.
|
||||
// Let's check if the feedback is shown if present.
|
||||
// For "password123", it might have feedback.
|
||||
// But let's just stick to checking the label for now as "renders progress bars" was a bit vague in my previous attempt.
|
||||
// I'll replace this test with something more specific or just remove it if covered by others.
|
||||
// Actually, let's check that the bar exists.
|
||||
// It doesn't have a role, so we can't use getByRole('progressbar').
|
||||
// We can check if the container has the class 'bg-gray-200' or 'dark:bg-gray-700'.
|
||||
// But testing implementation details (classes) is brittle.
|
||||
// Let's just check that the component renders without crashing and shows the label.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates label based on password strength', () => {
|
||||
const { rerender } = render(<PasswordStrengthMeter password="123" />)
|
||||
expect(screen.getByText('Weak')).toBeInTheDocument()
|
||||
|
||||
rerender(<PasswordStrengthMeter password="CorrectHorseBatteryStaple1!" />)
|
||||
expect(screen.getByText('Strong')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PermissionsPolicyBuilder } from '../PermissionsPolicyBuilder';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('PermissionsPolicyBuilder', () => {
|
||||
const defaultProps = {
|
||||
value: '',
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders correctly with empty value', () => {
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Permissions Policy Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('No permissions policies configured. Add features above to restrict browser capabilities.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly with initial value', () => {
|
||||
const initialValue = JSON.stringify([
|
||||
{ feature: 'camera', allowlist: [] },
|
||||
{ feature: 'microphone', allowlist: ['self'] },
|
||||
]);
|
||||
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} value={initialValue} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Remove camera' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove microphone' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Self only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a new feature (disabled)', async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} onChange={onChange} />);
|
||||
|
||||
// Select feature 'geolocation'
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: /select feature/i }), 'geolocation');
|
||||
|
||||
// Select allowlist 'None' (default, but explicit check)
|
||||
// Value is ''
|
||||
|
||||
// Click Add
|
||||
await user.click(screen.getByRole('button', { name: 'Add Feature' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"feature":"geolocation"'));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"allowlist":[]'));
|
||||
});
|
||||
|
||||
it('adds a feature with custom origin', async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} onChange={onChange} />);
|
||||
|
||||
// To enter custom origin, value should be '' (None). It is default.
|
||||
// Enter origin. The input is visible.
|
||||
const customInput = screen.getByPlaceholderText('or enter origin (e.g., https://example.com)');
|
||||
await user.type(customInput, 'https://trusted.com');
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: /select feature/i }), 'usb');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add Feature' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"feature":"usb"'));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"allowlist":["https://trusted.com"]'));
|
||||
});
|
||||
|
||||
it('removes a feature', async () => {
|
||||
const onChange = vi.fn();
|
||||
const initialValue = JSON.stringify([
|
||||
{ feature: 'camera', allowlist: [] }
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} value={initialValue} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Remove camera' })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove camera' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('[]');
|
||||
});
|
||||
|
||||
it('handles quick add', async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByText('Disable Common Features'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/camera/));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/microphone/));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/geolocation/));
|
||||
});
|
||||
|
||||
it('updates existing feature if added again', async () => {
|
||||
const onChange = vi.fn();
|
||||
const initialValue = JSON.stringify([
|
||||
{ feature: 'camera', allowlist: [] }
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} value={initialValue} onChange={onChange} />);
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: /select feature/i }), 'camera');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: /select allowlist origin/i }), 'self');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add Feature' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"feature":"camera"'));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.stringContaining('"allowlist":["self"]'));
|
||||
});
|
||||
|
||||
it('toggles preview', async () => {
|
||||
const initialValue = JSON.stringify([
|
||||
{ feature: 'camera', allowlist: [] }
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
render(<PermissionsPolicyBuilder {...defaultProps} value={initialValue} />);
|
||||
|
||||
const toggleBtn = screen.getByText('Show Preview');
|
||||
await user.click(toggleBtn);
|
||||
|
||||
expect(screen.getByText('Generated Permissions-Policy Header:')).toBeInTheDocument();
|
||||
expect(screen.getByText(/camera=\(\)/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Hide Preview'));
|
||||
expect(screen.queryByText('Generated Permissions-Policy Header:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: mockRemoteServers,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({
|
||||
containers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({
|
||||
domains: [{ uuid: 'domain-1', name: 'example.com' }],
|
||||
createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'example.com' }),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useSecurity', () => ({
|
||||
useAuthPolicies: vi.fn(() => ({
|
||||
policies: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useSecurityHeaders', () => ({
|
||||
useSecurityHeaderProfiles: vi.fn(() => ({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDNSProviders', () => ({
|
||||
useDNSProviders: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'dns-uuid-1',
|
||||
name: 'Cloudflare',
|
||||
provider_type: 'cloudflare',
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 2,
|
||||
success_count: 5,
|
||||
failure_count: 0,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDNSDetection', () => ({
|
||||
useDetectDNSProvider: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: [],
|
||||
confidence: 'none',
|
||||
}),
|
||||
isPending: false,
|
||||
data: undefined,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/dnsDetection', () => ({
|
||||
detectDNSProvider: vi.fn().mockResolvedValue({
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: [],
|
||||
confidence: 'none',
|
||||
}),
|
||||
getDetectionPatterns: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('ProxyHostForm - DNS Provider Integration', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
|
||||
})
|
||||
})
|
||||
|
||||
describe('Wildcard Domain Detection', () => {
|
||||
it('detects *.example.com as wildcard', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not detect sub.example.com as wildcard', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, 'sub.example.com')
|
||||
|
||||
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('detects multiple wildcards in comma-separated list', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, 'app.test.com, *.wildcard.com, api.test.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('detects wildcard at start of comma-separated list', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com, app.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS Provider Requirement for Wildcards', () => {
|
||||
it('shows DNS provider selector when wildcard domain entered', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
// Verify the selector combobox is rendered (even without opening it)
|
||||
const selectors = screen.getAllByRole('combobox')
|
||||
expect(selectors.length).toBeGreaterThan(3) // More than the base form selectors
|
||||
})
|
||||
})
|
||||
|
||||
it('shows info alert explaining DNS-01 requirement', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/Wildcard certificates.*require DNS-01 challenge/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows validation error on submit if wildcard without provider', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText('example.com, www.example.com'),
|
||||
'*.example.com'
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Submit without selecting DNS provider
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
// Should not call onSubmit
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show DNS provider selector without wildcard', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, 'app.example.com')
|
||||
|
||||
// DNS Provider section should not appear
|
||||
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DNS Provider Selection', () => {
|
||||
it('DNS provider selector is present for wildcard domains', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Enter wildcard domain to show DNS selector
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// DNS provider selector should be rendered (it's a combobox without explicit name)
|
||||
const comboboxes = screen.getAllByRole('combobox')
|
||||
// There should be extra combobox(es) now for DNS provider
|
||||
expect(comboboxes.length).toBeGreaterThan(5) // Base form has ~5 comboboxes
|
||||
})
|
||||
|
||||
it('clears DNS provider when switching to non-wildcard', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Enter wildcard
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Change to non-wildcard domain
|
||||
await userEvent.clear(domainInput)
|
||||
await userEvent.type(domainInput, 'app.example.com')
|
||||
|
||||
// DNS provider selector should disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Wildcard Certificate Required')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves form state during wildcard domain edits', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Fill name field
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
|
||||
|
||||
// Enter wildcard
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
await userEvent.type(domainInput, '*.example.com')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Edit other fields
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
|
||||
|
||||
// Name should still be present
|
||||
expect(screen.getByPlaceholderText('My Service')).toHaveValue('Test Service')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission with DNS Provider', () => {
|
||||
it('includes dns_provider_id null for non-wildcard domains', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Fill required fields without wildcard
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Regular Service')
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText('example.com, www.example.com'),
|
||||
'app.example.com'
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Submit form
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dns_provider_id: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('prevents submission when wildcard present without DNS provider', async () => {
|
||||
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
|
||||
|
||||
// Fill required fields with wildcard
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Test Service')
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText('example.com, www.example.com'),
|
||||
'*.example.com'
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Submit without selecting DNS provider
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
// Should not call onSubmit due to validation
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('loads existing host with DNS provider correctly', async () => {
|
||||
const existingHost: ProxyHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing Wildcard',
|
||||
domain_names: '*.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
enabled: true,
|
||||
dns_provider_id: 1,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// DNS provider section should be visible due to wildcard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The form should have wildcard domain loaded
|
||||
expect(screen.getByPlaceholderText('example.com, www.example.com')).toHaveValue(
|
||||
'*.example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('submits with dns_provider_id when editing existing wildcard host', async () => {
|
||||
const existingHost: ProxyHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing Wildcard',
|
||||
domain_names: '*.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
enabled: true,
|
||||
dns_provider_id: 1,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wildcard Certificate Required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Submit without changes
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dns_provider_id: 1,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
syncMonitors: vi.fn(() => Promise.resolve({})),
|
||||
}))
|
||||
|
||||
// Minimal hook mocks used by the component
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
// stub global fetch for health endpoint
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
|
||||
|
||||
describe('ProxyHostForm Add Uptime flow', () => {
|
||||
it('submits host and requests uptime sync when Add Uptime is checked', async () => {
|
||||
const onSubmit = vi.fn(() => Promise.resolve())
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Check Add Uptime
|
||||
const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
await userEvent.click(addUptimeCheckbox)
|
||||
|
||||
// Adjust uptime options — locate the container for the uptime inputs
|
||||
const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement
|
||||
if (!uptimeContainer) throw new Error('Uptime container not found')
|
||||
|
||||
const { within } = await import('@testing-library/react')
|
||||
const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton')
|
||||
// first spinbutton is interval, second is max retries
|
||||
fireEvent.change(spinbuttons[0], { target: { value: '30' } })
|
||||
fireEvent.change(spinbuttons[1], { target: { value: '2' } })
|
||||
|
||||
// Submit
|
||||
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement
|
||||
if (!submitBtn) throw new Error('Submit button not found')
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
// wait for onSubmit to have been called
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
|
||||
// Ensure uptime API was called with provided options
|
||||
const uptime = await import('../../api/uptime')
|
||||
await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 }))
|
||||
|
||||
// Ensure onSubmit payload does not include temporary uptime keys
|
||||
const onSubmitMock = onSubmit as unknown as import('vitest').Mock
|
||||
const submittedPayload = onSubmitMock.mock.calls[0][0]
|
||||
expect(submittedPayload).not.toHaveProperty('addUptime')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import RemoteServerForm from '../RemoteServerForm'
|
||||
import * as remoteServersApi from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
testRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080' })),
|
||||
testCustomRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080', reachable: true })),
|
||||
}))
|
||||
|
||||
describe('RemoteServerForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders create form', () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('renders edit form with pre-filled data', () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
username: 'admin',
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows test connection button in create and edit mode', () => {
|
||||
const { rerender } = render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: false,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
rerender(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('submits form with correct data', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('My Production Server')
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.100')
|
||||
const portInput = screen.getByDisplayValue('22')
|
||||
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'New Server')
|
||||
await userEvent.clear(hostInput)
|
||||
await userEvent.type(hostInput, '10.0.0.5')
|
||||
await userEvent.clear(portInput)
|
||||
await userEvent.type(portInput, '9090')
|
||||
|
||||
await userEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Server',
|
||||
host: '10.0.0.5',
|
||||
port: 9090,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles provider selection', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const providerSelect = screen.getByDisplayValue('Generic')
|
||||
await userEvent.selectOptions(providerSelect, 'docker')
|
||||
|
||||
expect(providerSelect).toHaveValue('docker')
|
||||
})
|
||||
|
||||
it('handles submission error', async () => {
|
||||
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.clear(screen.getByPlaceholderText('My Production Server'))
|
||||
await userEvent.type(screen.getByPlaceholderText('My Production Server'), 'Test Server')
|
||||
await userEvent.clear(screen.getByPlaceholderText('192.168.1.100'))
|
||||
await userEvent.type(screen.getByPlaceholderText('192.168.1.100'), '10.0.0.1')
|
||||
|
||||
await userEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submission failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection success', async () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const testButton = screen.getByText('Test Connection')
|
||||
await userEvent.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for success state (green background)
|
||||
expect(testButton).toHaveClass('bg-green-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection failure', async () => {
|
||||
// Override mock for this test
|
||||
vi.mocked(remoteServersApi.testCustomRemoteServerConnection).mockRejectedValueOnce(new Error('Connection failed'))
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Test Connection'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,435 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SecurityNotificationSettingsModal } from '../SecurityNotificationSettingsModal';
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient';
|
||||
import * as notificationsApi from '../../api/notifications';
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/notifications', async () => {
|
||||
const actual = await vi.importActual('../../api/notifications');
|
||||
return {
|
||||
...actual,
|
||||
getSecurityNotificationSettings: vi.fn(),
|
||||
updateSecurityNotificationSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock toast
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SecurityNotificationSettingsModal', () => {
|
||||
const mockSettings: notificationsApi.SecurityNotificationSettings = {
|
||||
enabled: true,
|
||||
min_log_level: 'warn',
|
||||
notify_waf_blocks: true,
|
||||
notify_acl_denials: true,
|
||||
notify_rate_limit_hits: false,
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
email_recipients: 'admin@example.com',
|
||||
};
|
||||
|
||||
let queryClient: ReturnType<typeof createTestQueryClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
});
|
||||
|
||||
const renderModal = (isOpen = true, onClose = vi.fn()) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SecurityNotificationSettingsModal isOpen={isOpen} onClose={onClose} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
renderModal(false);
|
||||
expect(screen.queryByText('Security Notification Settings')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and displays existing settings', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
expect(levelSelect.value).toBe('warn');
|
||||
expect(webhookInput.value).toBe('https://example.com/webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal when clicking outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
const { container } = renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click on the backdrop
|
||||
const backdrop = container.querySelector('.fixed.inset-0');
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits updated settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change minimum log level
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i);
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
// Change webhook URL
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i);
|
||||
await user.clear(webhookInput);
|
||||
await user.type(webhookInput, 'https://new-webhook.com');
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
min_log_level: 'error',
|
||||
webhook_url: 'https://new-webhook.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Modal should close on success
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles notification enable/disable', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
|
||||
// Disable notifications
|
||||
await user.click(enableSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables controls when notifications are disabled', async () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue({
|
||||
...mockSettings,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
// Wait for settings to be loaded and form to render
|
||||
await waitFor(() => {
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
expect(levelSelect.disabled).toBe(true);
|
||||
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
expect(webhookInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles event type filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WAF Blocks')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and toggle WAF blocks switch
|
||||
const wafSwitch = screen.getByLabelText('WAF Blocks') as HTMLInputElement;
|
||||
expect(wafSwitch.checked).toBe(true);
|
||||
|
||||
await user.click(wafSwitch);
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notify_waf_blocks: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should NOT close on error
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockReturnValue(
|
||||
new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText('Loading settings...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles email recipients input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/admin@example.com/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/admin@example.com/i);
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, 'user1@test.com, user2@test.com');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email_recipients: 'user1@test.com, user2@test.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents modal content clicks from closing modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click inside the modal content
|
||||
const modalContent = screen.getByText('Security Notification Settings');
|
||||
await user.click(modalContent);
|
||||
|
||||
// Modal should not close
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import SystemStatus from '../SystemStatus'
|
||||
import * as systemApi from '../../api/system'
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemStatus', () => {
|
||||
it('calls checkUpdates on mount', async () => {
|
||||
vi.mocked(systemApi.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '1.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
|
||||
renderWithClient(<SystemStatus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(systemApi.checkUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,260 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { WebSocketStatusCard } from '../WebSocketStatusCard';
|
||||
import * as websocketApi from '../../api/websocket';
|
||||
|
||||
// Mock the API functions
|
||||
vi.mock('../../api/websocket');
|
||||
|
||||
// Mock date-fns to avoid timezone issues in tests
|
||||
vi.mock('date-fns', () => ({
|
||||
formatDistanceToNow: vi.fn(() => '5 minutes ago'),
|
||||
}));
|
||||
|
||||
describe('WebSocketStatusCard', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebSocketStatusCard {...props} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('should render loading state', () => {
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockReturnValue(
|
||||
new Promise(() => {}) // Never resolves
|
||||
);
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockReturnValue(
|
||||
new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Loading state shows skeleton elements
|
||||
expect(screen.getAllByRole('generic').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render with no active connections', async () => {
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: [],
|
||||
count: 0,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 0,
|
||||
logs_connections: 0,
|
||||
cerberus_connections: 0,
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('0 Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('No active WebSocket connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with active connections', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
type: 'logs' as const,
|
||||
connected_at: '2024-01-15T10:00:00Z',
|
||||
last_activity_at: '2024-01-15T10:05:00Z',
|
||||
remote_addr: '192.168.1.1:12345',
|
||||
filters: 'level=error',
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
type: 'cerberus' as const,
|
||||
connected_at: '2024-01-15T10:02:00Z',
|
||||
last_activity_at: '2024-01-15T10:06:00Z',
|
||||
remote_addr: '192.168.1.2:54321',
|
||||
filters: 'source=waf',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: mockConnections,
|
||||
count: 2,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 2,
|
||||
logs_connections: 1,
|
||||
cerberus_connections: 1,
|
||||
oldest_connection: '2024-01-15T10:00:00Z',
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('2 Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('General Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Security Logs')).toBeInTheDocument();
|
||||
// Use getAllByText since we have two "1" values
|
||||
const ones = screen.getAllByText('1');
|
||||
expect(ones).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should show details when expanded', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-123',
|
||||
type: 'logs' as const,
|
||||
connected_at: '2024-01-15T10:00:00Z',
|
||||
last_activity_at: '2024-01-15T10:05:00Z',
|
||||
remote_addr: '192.168.1.1:12345',
|
||||
filters: 'level=error',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: mockConnections,
|
||||
count: 1,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 1,
|
||||
logs_connections: 1,
|
||||
cerberus_connections: 0,
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
renderComponent({ showDetails: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check for connection details
|
||||
expect(screen.getByText('Active Connections')).toBeInTheDocument();
|
||||
expect(screen.getByText(/conn-123/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('192.168.1.1:12345')).toBeInTheDocument();
|
||||
expect(screen.getByText('level=error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle details on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
type: 'logs' as const,
|
||||
connected_at: '2024-01-15T10:00:00Z',
|
||||
last_activity_at: '2024-01-15T10:05:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: mockConnections,
|
||||
count: 1,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 1,
|
||||
logs_connections: 1,
|
||||
cerberus_connections: 0,
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially hidden
|
||||
expect(screen.queryByText('Active Connections')).not.toBeInTheDocument();
|
||||
|
||||
// Click to show
|
||||
await user.click(screen.getByText('Show Details'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Active Connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to hide
|
||||
await user.click(screen.getByText('Hide Details'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Active Connections')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unable to load WebSocket status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display oldest connection when available', async () => {
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: [],
|
||||
count: 1,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 1,
|
||||
logs_connections: 1,
|
||||
cerberus_connections: 0,
|
||||
oldest_connection: '2024-01-15T09:55:00Z',
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Oldest Connection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('5 minutes ago')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({
|
||||
connections: [],
|
||||
count: 0,
|
||||
});
|
||||
vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({
|
||||
total_active: 0,
|
||||
logs_connections: 0,
|
||||
cerberus_connections: 0,
|
||||
last_updated: '2024-01-15T10:10:00Z',
|
||||
});
|
||||
|
||||
const { container } = renderComponent({ className: 'custom-class' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Connections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const card = container.querySelector('.custom-class');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user