fix(tests): Enhance CrowdSecConfig with new input fields and improve accessibility

- Added IDs to input fields in CrowdSecConfig for better accessibility.
- Updated labels to use <label> elements for checkboxes and inputs.
- Improved error handling and user feedback in the CrowdSecConfig tests.
- Enhanced test coverage for console enrollment and banned IP functionalities.

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

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

test: Add comprehensive tests for proxyHostsHelpers and validation utilities

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

chore: Update vitest configuration for dynamic coverage thresholds

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

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

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

fix: Ensure tests pass with consistent data in passwd file

- Updated tests/etc/passwd to ensure consistent content.
This commit is contained in:
GitHub Actions
2026-02-06 17:38:08 +00:00
parent 57c3a70007
commit 10582872f9
34 changed files with 4197 additions and 724 deletions
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest'
type ResponseHandler = (value: unknown) => unknown
type ErrorHandler = (error: ResponseError) => Promise<never>
type ResponseError = {
response?: {
status?: number
data?: Record<string, unknown>
}
config?: {
url?: string
}
message?: string
}
// Use vi.hoisted() to declare variables accessible in hoisted mocks
const capturedHandlers = vi.hoisted(() => ({
onFulfilled: undefined as ResponseHandler | undefined,
onRejected: undefined as ErrorHandler | undefined,
}))
vi.mock('axios', () => {
const mockClient = {
defaults: {
headers: {
common: {} as Record<string, string>,
},
},
interceptors: {
response: {
use: vi.fn((onFulfilled?: ResponseHandler, onRejected?: ErrorHandler) => {
capturedHandlers.onFulfilled = onFulfilled
capturedHandlers.onRejected = onRejected
return vi.fn()
}),
},
},
}
return {
default: {
create: vi.fn(() => mockClient),
},
}
})
// Must import AFTER mock definition
import { setAuthErrorHandler, setAuthToken } from '../client'
import axios from 'axios'
// Get mock client instance for header assertions
const getMockClient = () => {
const mockAxios = vi.mocked(axios)
return mockAxios.create()
}
describe('api client', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('sets and clears the Authorization header', () => {
const mockClientInstance = getMockClient()
setAuthToken('test-token')
expect(mockClientInstance.defaults.headers.common.Authorization).toBe('Bearer test-token')
setAuthToken(null)
expect(mockClientInstance.defaults.headers.common.Authorization).toBeUndefined()
})
it('extracts error message from response payload', async () => {
const error: ResponseError = {
response: { data: { error: 'Bad request' } },
config: { url: '/test' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(error.message).toBe('Bad request')
})
it('invokes auth error handler on 401 outside auth endpoints', async () => {
const onAuthError = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setAuthErrorHandler(onAuthError)
const error: ResponseError = {
response: { status: 401, data: { message: 'Unauthorized' } },
config: { url: '/proxy-hosts' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(onAuthError).toHaveBeenCalledTimes(1)
expect(error.message).toBe('Unauthorized')
warnSpy.mockRestore()
})
it('skips auth error handler for auth endpoints', async () => {
const onAuthError = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setAuthErrorHandler(onAuthError)
const error: ResponseError = {
response: { status: 401, data: { message: 'Unauthorized' } },
config: { url: '/auth/login' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
// Call handler with auth endpoint error to verify it skips the auth error handler
if (handler) {
await handler(error)
}
warnSpy.mockRestore()
})
})
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadCaddyfile, uploadCaddyfilesMulti, getImportPreview, commitImport, cancelImport, getImportStatus } from '../import';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('import API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uploadCaddyfile posts content', async () => {
const content = 'example.com';
const mockResponse = { preview: { hosts: [] } };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfile(content);
expect(client.post).toHaveBeenCalledWith('/import/upload', { content });
expect(result).toEqual(mockResponse);
});
it('uploadCaddyfilesMulti posts files', async () => {
const files = [{ filename: 'Caddyfile', content: 'foo.com' }];
const mockResponse = { preview: { hosts: [] } };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfilesMulti(files);
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files });
expect(result).toEqual(mockResponse);
});
it('getImportPreview gets preview', async () => {
const mockResponse = { preview: { hosts: [] } };
(client.get as any).mockResolvedValue({ data: mockResponse });
const result = await getImportPreview();
expect(client.get).toHaveBeenCalledWith('/import/preview');
expect(result).toEqual(mockResponse);
});
it('commitImport posts commitments', async () => {
const sessionUUID = 'uuid-123';
const resolutions = { 'foo.com': 'keep' };
const names = { 'foo.com': 'My Site' };
const mockResponse = { created: 1, updated: 0, skipped: 0, errors: [] };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await commitImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/commit', {
session_uuid: sessionUUID,
resolutions,
names
});
expect(result).toEqual(mockResponse);
});
it('cancelImport posts cancel', async () => {
(client.post as any).mockResolvedValue({});
await cancelImport();
expect(client.post).toHaveBeenCalledWith('/import/cancel');
});
it('getImportStatus gets status', async () => {
const mockResponse = { has_pending: true };
(client.get as any).mockResolvedValue({ data: mockResponse });
const result = await getImportStatus();
expect(client.get).toHaveBeenCalledWith('/import/status');
expect(result).toEqual(mockResponse);
});
it('getImportStatus handles error', async () => {
(client.get as any).mockRejectedValue(new Error('Failed'));
const result = await getImportStatus();
expect(result).toEqual({ has_pending: false });
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import {
getPlugins,
getPlugin,
enablePlugin,
disablePlugin,
reloadPlugins,
type PluginInfo,
} from '../plugins';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('Plugins API', () => {
const mockPlugins: PluginInfo[] = [
{
id: 1,
uuid: 'plugin-1',
name: 'Test Plugin 1',
type: 'auth',
enabled: true,
status: 'loaded',
is_built_in: false,
created_at: '2023-01-01',
updated_at: '2023-01-01',
},
{
id: 2,
uuid: 'plugin-2',
name: 'Test Plugin 2',
type: 'notification',
enabled: false,
status: 'pending',
is_built_in: true,
created_at: '2023-01-01',
updated_at: '2023-01-01',
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('getPlugins', () => {
it('fetches all plugins successfully', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPlugins });
const result = await getPlugins();
expect(client.get).toHaveBeenCalledWith('/admin/plugins');
expect(result).toEqual(mockPlugins);
});
it('propagates error when request fails', async () => {
const error = new Error('API Error');
vi.mocked(client.get).mockRejectedValueOnce(error);
await expect(getPlugins()).rejects.toThrow(error);
});
});
describe('getPlugin', () => {
it('fetches a single plugin successfully', async () => {
const plugin = mockPlugins[0];
vi.mocked(client.get).mockResolvedValueOnce({ data: plugin });
const result = await getPlugin(1);
expect(client.get).toHaveBeenCalledWith('/admin/plugins/1');
expect(result).toEqual(plugin);
});
it('propagates error when plugin not found', async () => {
const error = new Error('Not Found');
vi.mocked(client.get).mockRejectedValueOnce(error);
await expect(getPlugin(999)).rejects.toThrow(error);
});
});
describe('enablePlugin', () => {
it('enables a plugin successfully', async () => {
const response = { message: 'Plugin enabled' };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await enablePlugin(1);
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/enable');
expect(result).toEqual(response);
});
});
describe('disablePlugin', () => {
it('disables a plugin successfully', async () => {
const response = { message: 'Plugin disabled' };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await disablePlugin(1);
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/disable');
expect(result).toEqual(response);
});
});
describe('reloadPlugins', () => {
it('reloads plugins successfully', async () => {
const response = { message: 'Plugins reloaded', count: 5 };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await reloadPlugins();
expect(client.post).toHaveBeenCalledWith('/admin/plugins/reload');
expect(result).toEqual(response);
});
});
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { securityHeadersApi } from '../securityHeaders';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('securityHeadersApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('listProfiles returns profiles', async () => {
const mockProfiles = [{ id: 1, name: 'Profile 1' }];
(client.get as any).mockResolvedValue({ data: { profiles: mockProfiles } });
const result = await securityHeadersApi.listProfiles();
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles');
expect(result).toEqual(mockProfiles);
});
it('getProfile returns a profile', async () => {
const mockProfile = { id: 1, name: 'Profile 1' };
(client.get as any).mockResolvedValue({ data: { profile: mockProfile } });
const result = await securityHeadersApi.getProfile(1);
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/1');
expect(result).toEqual(mockProfile);
});
it('createProfile creates a profile', async () => {
const newProfile = { name: 'New Profile' };
const mockResponse = { id: 1, ...newProfile };
(client.post as any).mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.createProfile(newProfile);
expect(client.post).toHaveBeenCalledWith('/security/headers/profiles', newProfile);
expect(result).toEqual(mockResponse);
});
it('updateProfile updates a profile', async () => {
const updates = { name: 'Updated Profile' };
const mockResponse = { id: 1, ...updates };
(client.put as any).mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.updateProfile(1, updates);
expect(client.put).toHaveBeenCalledWith('/security/headers/profiles/1', updates);
expect(result).toEqual(mockResponse);
});
it('deleteProfile deletes a profile', async () => {
(client.delete as any).mockResolvedValue({});
await securityHeadersApi.deleteProfile(1);
expect(client.delete).toHaveBeenCalledWith('/security/headers/profiles/1');
});
it('getPresets returns presets', async () => {
const mockPresets = [{ name: 'Basic' }];
(client.get as any).mockResolvedValue({ data: { presets: mockPresets } });
const result = await securityHeadersApi.getPresets();
expect(client.get).toHaveBeenCalledWith('/security/headers/presets');
expect(result).toEqual(mockPresets);
});
it('applyPreset applies a preset', async () => {
const request = { preset_type: 'basic', name: 'My Preset' };
const mockResponse = { id: 1, ...request };
(client.post as any).mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.applyPreset(request);
expect(client.post).toHaveBeenCalledWith('/security/headers/presets/apply', request);
expect(result).toEqual(mockResponse);
});
it('calculateScore calculates score', async () => {
const config = { hsts_enabled: true };
const mockResponse = { score: 90 };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.calculateScore(config);
expect(client.post).toHaveBeenCalledWith('/security/headers/score', config);
expect(result).toEqual(mockResponse);
});
it('validateCSP validates CSP', async () => {
const csp = "default-src 'self'";
const mockResponse = { valid: true, errors: [] };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.validateCSP(csp);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/validate', { csp });
expect(result).toEqual(mockResponse);
});
it('buildCSP builds CSP', async () => {
const directives = [{ directive: 'default-src', values: ["'self'"] }];
const mockResponse = { csp: "default-src 'self'" };
(client.post as any).mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.buildCSP(directives);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/build', { directives });
expect(result).toEqual(mockResponse);
});
});
+2 -1
View File
@@ -485,8 +485,9 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
{isGeoType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
<label htmlFor="country-select" className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
<select
id="country-select"
onChange={(e) => {
if (e.target.value) {
handleAddCountry(e.target.value);
@@ -13,11 +13,12 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<label htmlFor="access-list-select" className="block text-sm font-medium text-gray-300 mb-2">
Access Control List
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
id="access-list-select"
value={value || 0}
onChange={(e) => onChange(parseInt(e.target.value) || null)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -418,6 +418,7 @@ export default function DNSProviderForm({
{showAdvanced && (
<div className="mt-4 space-y-3">
<Input
id="propagation-timeout"
label={t('dnsProviders.propagationTimeout')}
type="number"
value={propagationTimeout}
@@ -427,6 +428,7 @@ export default function DNSProviderForm({
max={600}
/>
<Input
id="polling-interval"
label={t('dnsProviders.pollingInterval')}
type="number"
value={pollingInterval}
@@ -178,6 +178,7 @@ export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyB
value={newFeature}
onChange={(e) => setNewFeature(e.target.value)}
className="w-48"
aria-label="Select Feature"
>
{FEATURES.map((feature) => (
<option key={feature} value={feature}>
@@ -190,6 +191,7 @@ export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyB
value={newAllowlist}
onChange={(e) => setNewAllowlist(e.target.value)}
className="w-40"
aria-label="Select Allowlist Origin"
>
{ALLOWLIST_PRESETS.map((preset) => (
<option key={preset.value} value={preset.value}>
@@ -208,7 +210,7 @@ export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyB
/>
)}
<Button onClick={handleAddFeature}>
<Button onClick={handleAddFeature} aria-label="Add Feature">
<Plus className="w-4 h-4" />
</Button>
</div>
@@ -247,6 +249,7 @@ export function PermissionsPolicyBuilder({ value, onChange }: PermissionsPolicyB
variant="ghost"
size="sm"
onClick={() => handleRemoveFeature(policy.feature)}
aria-label={`Remove ${policy.feature}`}
>
<X className="w-4 h-4" />
</Button>
+1 -1
View File
@@ -756,7 +756,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
{/* DNS Provider Selector for Wildcard Domains */}
{hasWildcardDomain && (
<div className="space-y-3">
<div className="space-y-3" data-testid="dns-provider-section">
<Alert variant="info">
<Info className="h-4 w-4" />
<div>
@@ -0,0 +1,578 @@
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.getByLabelText(/Local Network Only/i)
.querySelector('input[type="checkbox"]');
if (localNetworkSwitch) {
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: /Create/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: /Delete/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: /Delete/i });
expect(deleteBtn).toBeDisabled();
});
it('applies security preset for 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, '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.getByLabelText(/^Enabled$/)
.querySelector('input[type="checkbox"]');
if (enabledSwitch) {
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(/Recommended: 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');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
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,77 +1,227 @@
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 DNSProviderForm from '../DNSProviderForm'
import { defaultProviderSchemas } from '../../data/dnsProviderSchemas'
import type { DNSProvider } from '../../api/dnsProviders'
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,
};
// Mock hooks used by DNSProviderForm
vi.mock('../../hooks/useDNSProviders', () => ({
useDNSProviderTypes: vi.fn(() => ({ data: [defaultProviderSchemas.script], isLoading: false })),
useDNSProviderMutations: vi.fn(() => ({ createMutation: { isPending: false }, updateMutation: { isPending: false }, testCredentialsMutation: { isPending: false } })),
}))
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', () => ({
useCredentials: vi.fn(() => ({ data: [] })),
useEnableMultiCredentials: vi.fn(() => ({ mutate: vi.fn(), isPending: false }))
}))
useEnableMultiCredentials: vi.fn(() => mockEnableMultiCredentialsMutation),
useCredentials: vi.fn(() => ({
data: [],
})),
}));
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
// 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(),
};
describe('DNSProviderForm — Script provider (accessibility)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
it('renders `Script Path` input when Script provider is selected (add flow)', async () => {
renderWithClient(<DNSProviderForm open={true} onOpenChange={() => {}} provider={null} onSuccess={() => {}} />)
it('renders correctly in add mode', () => {
render(<DNSProviderForm {...defaultProps} />);
// Open provider selector and choose the script provider
const select = screen.getByLabelText(/provider type/i)
await userEvent.click(select)
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();
});
const scriptOption = await screen.findByRole('option', { name: /script|custom script/i })
await userEvent.click(scriptOption)
// The input should be present, labelled "Script Path", have the expected placeholder and be required (add flow)
const scriptInput = await screen.findByRole('textbox', { name: /script path/i })
expect(scriptInput).toBeInTheDocument()
expect(scriptInput).toHaveAttribute('placeholder', expect.stringMatching(/dns-challenge\.sh/i))
expect(scriptInput).toBeRequired()
// Keyboard focus works
scriptInput.focus()
await waitFor(() => expect(scriptInput).toHaveFocus())
})
it('renders Script Path when editing an existing script provider (not required)', async () => {
const existingProvider: DNSProvider = {
it('populates fields when editing', async () => {
const provider = {
id: 1,
uuid: 'p-1',
name: 'local-script',
provider_type: 'script',
uuid: 'prov-uuid',
name: 'My Cloudflare',
provider_type: 'cloudflare' as const,
is_default: true,
enabled: true,
is_default: false,
propagation_timeout: 180,
polling_interval: 10,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 5,
success_count: 0,
failure_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
created_at: '2023-01-01',
updated_at: '2023-01-01',
};
renderWithClient(
<DNSProviderForm open={true} onOpenChange={() => {}} provider={existingProvider} onSuccess={() => {}} />
)
render(<DNSProviderForm {...defaultProps} provider={provider} />);
// Since provider prop is provided, providerType should be pre-populated and the field rendered
const scriptInput = await screen.findByRole('textbox', { name: /script path/i })
expect(scriptInput).toBeInTheDocument()
// Not required when editing
expect(scriptInput).not.toBeRequired()
})
})
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();
});
});
@@ -0,0 +1,131 @@
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,5 +1,5 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -60,6 +60,50 @@ vi.mock('../../hooks/useCertificates', () => ({
})),
}))
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(() => ({
data: [
{ id: 10, name: 'Trusted IPs', type: 'allow_list', enabled: true, description: 'Only trusted' },
{ id: 11, name: 'Geo Block', type: 'geo_block', enabled: true }
],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({
data: [
{ id: 100, name: 'Strict Profile', description: 'Very strict', security_score: 90, is_preset: true, preset_type: 'strict' }
],
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useDNSDetection', () => ({
useDetectDNSProvider: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
data: null,
reset: vi.fn(),
})),
}))
vi.mock('../../hooks/useDNSProviders', () => ({
useDNSProviders: vi.fn(() => ({
data: [
{ id: 1, name: 'Cloudflare', provider_type: 'cloudflare', enabled: true, has_credentials: true }
],
isLoading: false,
error: null,
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('../../hooks/useSecurity', () => ({
useAuthPolicies: vi.fn(() => ({
policies: [
@@ -657,4 +701,530 @@ describe('ProxyHostForm', () => {
})
})
})
describe('Security Options', () => {
it('toggles security options', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Toggle SSL Forced (default is true)
const sslCheckbox = screen.getByLabelText('Force SSL')
expect(sslCheckbox).toBeChecked()
await userEvent.click(sslCheckbox)
expect(sslCheckbox).not.toBeChecked()
// Toggle HSTS (default is true)
const hstsCheckbox = screen.getByLabelText('HSTS Enabled')
expect(hstsCheckbox).toBeChecked()
await userEvent.click(hstsCheckbox)
expect(hstsCheckbox).not.toBeChecked()
// Toggle HTTP/2 (default is true)
const http2Checkbox = screen.getByLabelText('HTTP/2 Support')
expect(http2Checkbox).toBeChecked()
await userEvent.click(http2Checkbox)
expect(http2Checkbox).not.toBeChecked()
// Toggle Block Exploits (default is true)
const blockExploitsCheckbox = screen.getByLabelText('Block Exploits')
expect(blockExploitsCheckbox).toBeChecked()
await userEvent.click(blockExploitsCheckbox)
expect(blockExploitsCheckbox).not.toBeChecked()
})
})
describe('Access Control', () => {
it('selects an access list', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Select 'Trusted IPs'
// Need to find the select by label/aria-label? AccessListSelector has label "Access Control List"
const aclSelect = screen.getByLabelText(/Access Control List/i)
await userEvent.selectOptions(aclSelect, '10')
// Verify it was selected
expect(aclSelect).toHaveValue('10')
// Verify description appears
expect(screen.getByText('Trusted IPs')).toBeInTheDocument()
expect(screen.getByText('Only trusted')).toBeInTheDocument()
})
})
describe('Wildcard Domains', () => {
it('shows DNS provider selector for wildcard domains', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Enter a wildcard domain
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
// DNS Provider Selector should appear
await waitFor(() => {
expect(screen.getByTestId('dns-provider-section')).toBeInTheDocument()
})
// Select a provider using the mocked data: Cloudflare (ID 1)
const section = screen.getByTestId('dns-provider-section')
// Since Shadcn Select uses Radix, the trigger is a button with role combobox
const providerSelectTrigger = within(section).getByRole('combobox')
await userEvent.click(providerSelectTrigger)
const cloudflareOption = screen.getByText('Cloudflare')
await userEvent.click(cloudflareOption)
// Now try to save
await userEvent.type(screen.getByLabelText(/^Name/), 'Wildcard Test')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.10')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
domain_names: '*.example.com',
dns_provider_id: 1
}))
})
})
it('validates DNS provider requirement for wildcard domains', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Enter a wildcard domain
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), '*.missing.com')
// Fill other required fields
await userEvent.type(screen.getByLabelText(/^Name/), 'Missing Provider')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.10')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Click save without selecting provider
await userEvent.click(screen.getByText('Save'))
// Expect toast error (mocked only effectively if we check for it, but here we check it prevents submit)
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
// ===== BRANCH COVERAGE EXPANSION TESTS =====
describe('Form Submission and Validation', () => {
it('prevents submission without required fields', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Click save without filling any fields
await userEvent.click(screen.getByText('Save'))
// Submit should not be called
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('submits form with all basic fields', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'My Service')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'myservice.com')
await userEvent.selectOptions(screen.getByLabelText(/^Scheme/), 'https')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port/))
await userEvent.type(screen.getByLabelText(/^Port/), '8080')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'My Service',
domain_names: 'myservice.com',
forward_scheme: 'https',
forward_host: '192.168.1.100',
forward_port: 8080,
}))
})
})
it('submits form with certificate selection', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Cert Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'cert.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Select certificate
const certSelect = screen.getByLabelText(/Certificate/i)
await userEvent.selectOptions(certSelect, '1')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
certificate_id: 1,
}))
})
})
it('submits form with security header profile selection', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Security Headers Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'secure.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Select security header profile
const profileSelect = screen.getByLabelText(/Security Headers/i)
await userEvent.selectOptions(profileSelect, '100')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
security_header_profile_id: 100,
}))
})
})
})
describe('Edit Mode vs Create Mode', () => {
it('shows edit mode with pre-filled data', async () => {
const existingHost: ProxyHost = {
uuid: 'host-uuid-1',
name: 'Existing Service',
domain_names: 'existing.com',
forward_scheme: 'https' as const,
forward_host: '192.168.1.50',
forward_port: 443,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enable_standard_headers: true,
application: 'none' as const,
advanced_config: '',
enabled: true,
locations: [],
certificate_id: null,
access_list_id: null,
security_header_profile_id: null,
dns_provider_id: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
await renderWithClientAct(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Fields should be pre-filled
expect(screen.getByDisplayValue('Existing Service')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('192.168.1.50')).toBeInTheDocument()
// Update and save
const nameInput = screen.getByDisplayValue('Existing Service') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Updated Service')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Service',
}))
})
})
it('renders title as Edit for existing host', async () => {
const existingHost: ProxyHost = {
uuid: 'host-uuid-1',
name: 'Existing',
domain_names: 'test.com',
forward_scheme: 'http' as const,
forward_host: '127.0.0.1',
forward_port: 80,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enable_standard_headers: true,
application: 'none' as const,
advanced_config: '',
enabled: true,
locations: [],
certificate_id: null,
access_list_id: null,
security_header_profile_id: null,
dns_provider_id: null,
created_at: '',
updated_at: '',
}
await renderWithClientAct(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Edit Proxy Host')).toBeInTheDocument()
})
it('renders title as Add for new proxy host', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
})
describe('Scheme Selection', () => {
it('shows scheme options http, https, ws, wss', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const schemeSelect = screen.getByLabelText('Scheme')
expect(schemeSelect).toBeInTheDocument()
const options = schemeSelect.querySelectorAll('option')
const values = Array.from(options).map(o => o.value)
expect(values).toContain('http')
expect(values).toContain('https')
expect(values).toContain('ws')
expect(values).toContain('wss')
})
it('accepts websockets scheme', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'WS Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ws.example.com')
await userEvent.selectOptions(screen.getByLabelText('Scheme') as HTMLSelectElement, 'ws')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '8000')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_scheme: 'ws',
}))
})
})
it('accepts secure websockets scheme', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'WSS Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'wss.example.com')
await userEvent.selectOptions(screen.getByLabelText('Scheme') as HTMLSelectElement, 'wss')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '8000')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_scheme: 'wss',
}))
})
})
})
describe('Cancel Operations', () => {
it('calls onCancel when cancel button is clicked', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const cancelBtn = screen.getByRole('button', { name: /Cancel/i })
await userEvent.click(cancelBtn)
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Advanced Config', () => {
it('shows advanced config field for application presets', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
// Find advanced config field (it's in a collapsible section)
// Check that advanced config JSON for plex has been populated
const advancedConfigField = screen.getByPlaceholderText(/Caddy JSON config/i) as HTMLTextAreaElement
// Verify it contains JSON (Plex has some default config)
if (advancedConfigField.value) {
expect(advancedConfigField.value).toContain('handler')
}
})
it('allows manual advanced config input', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Custom Config')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'custom.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
const advancedConfigField = screen.getByPlaceholderText('Additional Caddy directives...')
await userEvent.type(advancedConfigField, 'header /api/* X-Custom-Header "test"')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
advanced_config: expect.stringContaining('header'),
}))
})
})
})
describe('Port Input Handling', () => {
it('validates port number range', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Port Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'port.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
// Clear and set invalid port
const portInput = screen.getByLabelText(/^Port$/) as HTMLInputElement
await userEvent.clear(portInput)
await userEvent.type(portInput, '99999')
// The form should still allow submission (validation happens server-side usually)
// But port should be converted to number
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})
})
})
describe('Host and Port Combination', () => {
it('accepts docker container IP', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Docker Container')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'docker.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '172.17.0.2')
await userEvent.type(screen.getByLabelText(/^Port/), '8080')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_host: '172.17.0.2',
}))
})
})
it('accepts localhost IP', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Localhost')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'localhost.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), 'localhost')
await userEvent.type(screen.getByLabelText(/^Port/), '3000')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_host: 'localhost',
}))
})
})
})
describe('Enabled/Disabled State', () => {
it('toggles enabled state', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Enabled Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'enabled.example.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Toggle enabled (defaults to true) - look for "Enable Proxy Host" text
const enabledCheckbox = screen.getByLabelText(/Enable Proxy Host/)
await userEvent.click(enabledCheckbox)
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
enabled: false,
}))
})
})
})
describe('Standard Headers Option', () => {
it('toggles standard headers option', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const standardHeadersCheckbox = screen.getByLabelText(/Enable Standard Proxy Headers/)
expect(standardHeadersCheckbox).toBeChecked()
await userEvent.click(standardHeadersCheckbox)
expect(standardHeadersCheckbox).not.toBeChecked()
await userEvent.type(screen.getByLabelText(/^Name/), 'No Headers')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'no-headers.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
enable_standard_headers: false,
}))
})
})
})
})
@@ -6,6 +6,65 @@ import { securityHeadersApi, type SecurityHeaderProfile } from '../../api/securi
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: {
@@ -44,6 +103,9 @@ describe('SecurityHeaderProfileForm', () => {
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', () => {
@@ -57,7 +119,10 @@ describe('SecurityHeaderProfileForm', () => {
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as SecurityHeaderProfile} />,
<SecurityHeaderProfileForm
{...defaultProps}
initialData={initialData as SecurityHeaderProfile}
/>,
{ wrapper: createWrapper() }
);
@@ -100,12 +165,32 @@ describe('SecurityHeaderProfileForm', () => {
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() });
// Switch component uses checkbox with sr-only class
const hstsSection = screen.getByText('HTTP Strict Transport Security (HSTS)').closest('div');
const hstsToggle = hstsSection?.querySelector('input[type="checkbox"]') as HTMLInputElement;
// 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);
@@ -114,24 +199,49 @@ describe('SecurityHeaderProfileForm', () => {
expect(hstsToggle.checked).toBe(false);
});
it('should show HSTS options when enabled', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
it('should show HSTS options when enabled and handle updates', async () => {
const initialData: Partial<SecurityHeaderProfile> = {
hsts_enabled: true,
hsts_max_age: 1000,
};
expect(screen.getByText(/Max Age \(seconds\)/)).toBeInTheDocument();
expect(screen.getByText('Include Subdomains')).toBeInTheDocument();
expect(screen.getByText('Preload')).toBeInTheDocument();
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() });
// Find the preload switch by finding the parent container with the "Preload" label
const preloadText = screen.getByText('Preload');
const preloadContainer = preloadText.closest('div')?.parentElement; // Go up to the flex container
const preloadContainer = preloadText.closest('div')?.parentElement;
const preloadSwitch = preloadContainer?.querySelector('input[type="checkbox"]');
expect(preloadSwitch).toBeTruthy();
if (preloadSwitch) {
fireEvent.click(preloadSwitch);
}
@@ -141,24 +251,64 @@ describe('SecurityHeaderProfileForm', () => {
});
});
it('should toggle CSP enabled', async () => {
it('should toggle CSP enabled and show CSP builder', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// CSP is disabled by default, so builder should not be visible
expect(screen.queryByText('Content Security Policy Builder')).not.toBeInTheDocument();
// Find and click the CSP toggle switch (checkbox with sr-only class)
const cspSection = screen.getByText('Content Security Policy (CSP)').closest('div');
const cspSection = screen
.getByText('Content Security Policy (CSP)')
.closest('div');
const cspCheckbox = cspSection?.querySelector('input[type="checkbox"]');
if (cspCheckbox) {
fireEvent.click(cspCheckbox);
fireEvent.click(cspCheckbox); // Enable CSP (default is false)
}
// Builder should now be visible
await waitFor(() => {
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
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', () => {
@@ -171,110 +321,115 @@ describe('SecurityHeaderProfileForm', () => {
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={presetData as SecurityHeaderProfile} />,
<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();
});
it('should show delete button for non-presets', () => {
const profileData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as SecurityHeaderProfile}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('button', { name: /Delete Profile/ })).toBeInTheDocument();
});
it('should not show delete button 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}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(
screen.getByText(/This is a system preset and cannot be modified/)
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Delete Profile/ })).not.toBeInTheDocument();
});
it('should change referrer policy', () => {
it('should handle cross origin policies', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const referrerSelect = screen.getAllByRole('combobox')[1]; // Referrer policy is second select
fireEvent.change(referrerSelect, { target: { value: 'no-referrer' } });
// Use traversing to find selects since labels are not associated
// Order: X-Frame, Referrer, Opener, Resource, Embedder
const selects = screen.getAllByRole('combobox');
expect(referrerSelect).toHaveValue('no-referrer');
// 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 change x-frame-options', () => {
it('should handle additional options', () => {
// xss_protection defaults to true
// cache_control_no_store defaults to false
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const xfoSelect = screen.getAllByRole('combobox')[0]; // X-Frame-Options is first select
fireEvent.change(xfoSelect, { target: { value: 'SAMEORIGIN' } });
const xssSection = screen.getByText('X-XSS-Protection').closest('div')?.parentElement;
const xssSwitch = xssSection?.querySelector('input[type="checkbox"]');
expect(xssSwitch).toBeChecked(); // Default true
expect(xfoSelect).toHaveValue('SAMEORIGIN');
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 show loading state', () => {
render(<SecurityHeaderProfileForm {...defaultProps} isLoading={true} />, { wrapper: createWrapper() });
it('should update permissions policy', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Saving...')).toBeInTheDocument();
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 deleting state', () => {
const profileData: Partial<SecurityHeaderProfile> = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as SecurityHeaderProfile}
onDelete={mockOnDelete}
isDeleting={true}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Deleting...')).toBeInTheDocument();
});
it('should calculate security score on form changes', async () => {
it('should show security score', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'Test' } });
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: 1000 });
}, { timeout: 1500 });
});
});
+18 -7
View File
@@ -655,6 +655,7 @@ export default function CrowdSecConfig() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
id="console-enrollment-token"
label={t('crowdsecConfig.consoleEnrollment.enrollToken')}
type="password"
value={enrollmentToken}
@@ -666,6 +667,7 @@ export default function CrowdSecConfig() {
data-testid="console-enrollment-token"
/>
<Input
id="console-agent-name"
label={t('crowdsecConfig.consoleEnrollment.agentName')}
value={consoleAgentName}
onChange={(e) => setConsoleAgentName(e.target.value)}
@@ -674,6 +676,7 @@ export default function CrowdSecConfig() {
data-testid="console-agent-name"
/>
<Input
id="console-tenant"
label={t('crowdsecConfig.consoleEnrollment.tenant')}
value={consoleTenant}
onChange={(e) => setConsoleTenant(e.target.value)}
@@ -686,6 +689,7 @@ export default function CrowdSecConfig() {
<div className="flex items-center gap-2">
<input
id="console-ack"
type="checkbox"
className="h-4 w-4 accent-blue-500"
checked={consoleAck}
@@ -693,7 +697,7 @@ export default function CrowdSecConfig() {
disabled={isConsolePending}
data-testid="console-ack-checkbox"
/>
<span className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.ackText')}</span>
<label htmlFor="console-ack" className="text-sm text-gray-400">{t('crowdsecConfig.consoleEnrollment.ackText')}</label>
</div>
{consoleErrors.ack && <p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.ack}</p>}
@@ -801,10 +805,11 @@ export default function CrowdSecConfig() {
{/* Re-enrollment form */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-token">
{t('crowdsecConfig.reenroll.newEnrollmentKey')}
</label>
<Input
id="reenroll-token"
type="text"
value={enrollmentToken}
onChange={(e) => setEnrollmentToken(e.target.value)}
@@ -813,10 +818,11 @@ export default function CrowdSecConfig() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-agent-name">
{t('crowdsecConfig.consoleEnrollment.agentName')}
</label>
<Input
id="reenroll-agent-name"
type="text"
value={consoleAgentName}
onChange={(e) => setConsoleAgentName(e.target.value)}
@@ -824,10 +830,11 @@ export default function CrowdSecConfig() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-1" htmlFor="reenroll-tenant">
{t('crowdsecConfig.reenroll.tenantOptional')}
</label>
<Input
id="reenroll-tenant"
type="text"
value={consoleTenant}
onChange={(e) => setConsoleTenant(e.target.value)}
@@ -972,7 +979,7 @@ export default function CrowdSecConfig() {
</div>
))
) : (
<div className="p-4 text-center text-gray-500 text-sm">{t('crowdsecConfig.presets.noResults', { query: searchQuery })}</div>
<div className="p-4 text-center text-gray-500 text-sm">{t('crowdsecConfig.presets.noPresets', { query: searchQuery })}</div>
)}
</div>
@@ -1110,6 +1117,7 @@ export default function CrowdSecConfig() {
<h3 className="text-md font-semibold">{t('crowdsecConfig.bannedIps.title')}</h3>
</div>
<Button
data-testid="ban-ip-trigger"
onClick={() => setShowBanModal(true)}
disabled={status.crowdsec.mode === 'disabled'}
size="sm"
@@ -1186,14 +1194,16 @@ export default function CrowdSecConfig() {
</h3>
<div className="space-y-4">
<Input
id="ban-ip"
label={t('crowdsecConfig.banModal.ipLabel')}
placeholder="192.168.1.100"
value={banForm.ip}
onChange={(e) => setBanForm({ ...banForm, ip: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">{t('crowdsecConfig.banModal.durationLabel')}</label>
<label className="block text-sm font-medium text-gray-300 mb-1.5" htmlFor="ban-duration">{t('crowdsecConfig.banModal.durationLabel')}</label>
<select
id="ban-duration"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white"
value={banForm.duration}
onChange={(e) => setBanForm({ ...banForm, duration: e.target.value })}
@@ -1207,8 +1217,9 @@ export default function CrowdSecConfig() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">{t('crowdsecConfig.banModal.reasonLabel')}</label>
<label className="block text-sm font-medium text-gray-300 mb-1.5" htmlFor="ban-reason">{t('crowdsecConfig.banModal.reasonLabel')}</label>
<textarea
id="ban-reason"
placeholder={t('crowdsecConfig.banModal.reasonPlaceholder')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
+1
View File
@@ -272,6 +272,7 @@ export default function SecurityHeaders() {
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(profile)}
aria-label={t('common.delete')}
>
<Trash2 className="w-3 h-3" />
</Button>
@@ -7,19 +7,20 @@ import CrowdSecConfig from '../CrowdSecConfig'
import * as securityApi from '../../api/security'
import * as crowdsecApi from '../../api/crowdsec'
import * as backupsApi from '../../api/backups'
import * as settingsApi from '../../api/settings'
import * as presetsApi from '../../api/presets'
import * as featureFlagsApi from '../../api/featureFlags'
import { toast } from '../../utils/toast'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/presets')
vi.mock('../../api/featureFlags')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
@@ -32,7 +33,7 @@ describe('CrowdSecConfig', () => {
},
})
const renderWithProviders = () => {
const renderComponent = () => {
const queryClient = createClient()
return render(
<QueryClientProvider client={queryClient}>
@@ -45,6 +46,8 @@ describe('CrowdSecConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default mocks
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
cerberus: { enabled: true },
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
@@ -52,13 +55,29 @@ describe('CrowdSecConfig', () => {
rate_limit: { enabled: true },
acl: { enabled: true },
})
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.crowdsec.console_enrollment': true
})
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['config.yaml', 'profiles.yaml'] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'yaml content' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({
decisions: [
{ id: '1', ip: '1.2.3.4', reason: 'ssh-bf', duration: '23h', created_at: '2023-01-01', source: 'local' }
]
})
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({
running: true,
pid: 123,
lapi_ready: true,
})
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
@@ -67,40 +86,163 @@ describe('CrowdSecConfig', () => {
preview: 'configs: {}',
cache_key: 'cache-123',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
// Window Prompt Mock
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
window.URL.createObjectURL = vi.fn(() => 'blob:url')
window.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
})
it('shows info banner directing to Security Dashboard', async () => {
renderWithProviders()
await waitFor(() => screen.getByText(/CrowdSec is controlled via the toggle on the/i))
expect(screen.getByRole('link', { name: /Security/i })).toHaveAttribute('href', '/security')
})
it('exports configuration packages with prompted filename', async () => {
renderWithProviders()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
await userEvent.click(exportButton)
// 1. Rendering basic elements
it('renders page configuration elements', async () => {
renderComponent()
await waitFor(() => {
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
// Updated text to match translation file
expect(screen.getByText('Edit Configuration Files')).toBeInTheDocument()
expect(screen.getByText('Banned IPs')).toBeInTheDocument()
})
})
it('shows Configuration Packages heading', async () => {
renderWithProviders()
// 2. File Editor
it('allows reading and saving config files', async () => {
const user = userEvent.setup()
renderComponent()
await waitFor(() => screen.getByText('Configuration Packages'))
await waitFor(() => screen.getByTestId('crowdsec-file-select'))
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
// Select file
const select = screen.getByTestId('crowdsec-file-select')
await user.selectOptions(select, 'config.yaml')
await waitFor(() => {
expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('config.yaml')
expect(screen.getByDisplayValue('yaml content')).toBeInTheDocument()
})
// Edit content
const textarea = screen.getByDisplayValue('yaml content')
await user.clear(textarea)
await user.type(textarea, 'new content')
// Save
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('config.yaml', 'new content')
expect(backupsApi.createBackup).toHaveBeenCalled() // Should backup first
})
})
// 3. Banned IPs Table
it('renders banned IPs table', async () => {
renderComponent()
await waitFor(() => {
expect(screen.getByText('1.2.3.4')).toBeInTheDocument()
expect(screen.getByText('ssh-bf')).toBeInTheDocument()
})
})
// 4. Ban IP Action
it('allows banning an IP', async () => {
const user = userEvent.setup()
renderComponent()
await waitFor(() => screen.getByText('Ban IP'))
// Click Ban IP trigger (using ID we added)
await user.click(screen.getByTestId('ban-ip-trigger'))
// Modal opens
await waitFor(() => screen.getByText('Ban IP Address'))
// Fill form
await user.type(screen.getByLabelText(/IP Address/i), '5.6.7.8')
await user.type(screen.getByPlaceholderText(/Reason for ban/i), 'manual ban')
// Submit - Target the last button with name "Ban IP" (modal button)
const buttons = screen.getAllByRole('button', { name: 'Ban IP' })
const submitBtn = buttons[buttons.length - 1]
await user.click(submitBtn)
await waitFor(() => {
expect(crowdsecApi.banIP).toHaveBeenCalledWith('5.6.7.8', '24h', 'manual ban')
expect(toast.success).toHaveBeenCalled()
})
})
// 5. Unban IP Action
it('allows unbanning an IP', async () => {
const user = userEvent.setup()
renderComponent()
await waitFor(() => screen.getByText('1.2.3.4'))
const unbanBtns = screen.getAllByRole('button', { name: 'Unban' })
expect(unbanBtns.length).toBeGreaterThan(0)
// Click the unban button in the table (first one)
await user.click(unbanBtns[0])
// Confirm modal
await waitFor(() => screen.getByText('Confirm Unban'))
// Click confirm in modal. Use getAllByRole to get the modal one (last one)
const modalButtons = screen.getAllByRole('button', { name: 'Unban' })
const confirmBtn = modalButtons[modalButtons.length - 1]
await user.click(confirmBtn)
await waitFor(() => {
expect(crowdsecApi.unbanIP).toHaveBeenCalledWith('1.2.3.4')
expect(toast.success).toHaveBeenCalled()
})
})
// 6. Console Enrollment fields (if enabled)
it('handles console enrollment form', async () => {
// const user = userEvent.setup()
renderComponent()
await waitFor(() => screen.getByText('Console Enrollment'))
// Check inputs exist
expect(screen.getByTestId('console-enrollment-token')).toBeInTheDocument()
expect(screen.getByTestId('console-agent-name')).toBeInTheDocument()
})
// 7. Presets logic
it('handles preset searching', async () => {
const user = userEvent.setup()
// Mock presets with data
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
presets: [
{
slug: 'ssh-bf',
title: 'SSH Bruteforce',
summary: 'Block SSH attacks',
source: 'crowdsec',
tags: ['linux'],
requires_hub: false,
available: true,
cached: false,
},
]
})
renderComponent()
await waitFor(() => {
expect(presetsApi.listCrowdsecPresets).toHaveBeenCalled()
})
const searchInput = screen.getByPlaceholderText('Search presets...')
expect(searchInput).toBeInTheDocument()
await user.type(searchInput, 'SSH')
expect(searchInput).toHaveValue('SSH')
})
})
@@ -674,4 +674,154 @@ describe('SecurityHeaders', () => {
const createButtons = screen.getAllByRole('button', { name: /Create Profile/i });
expect(createButtons.length).toBeGreaterThan(0);
});
it('should close create dialog on success', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({ id: 1, name: 'New Profile', security_score: 50, created_at: '', updated_at: '' } as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
const openBtn = screen.getAllByRole('button', { name: /Create Profile/i })[0];
fireEvent.click(openBtn);
await waitFor(() => screen.getByText(/Create Security Header Profile/i));
// Fill required fields to enable submit
const nameInput = screen.getByPlaceholderText(/e.g., Production Security Headers/i);
fireEvent.change(nameInput, { target: { value: 'New Profile' } });
// Find submit button in dialog (it might have 'Create Profile' text or just 'Create')
// Looking at SecurityHeaderProfileForm, it likely has a submit button.
// We can assume it's the one with type="submit" or appropriate text.
// Let's search for "Create Profile" button inside the dialog or just "Create".
const submitBtn = screen.getByRole('button', { name: /Save Profile/i });
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should close edit dialog on success', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50, max_score: 100, breakdown: {}, suggestions: [] } as any);
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0] as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Edit Me'));
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
const submitButton = screen.getByRole('button', { name: /Save Profile/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should handle delete failure', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockResolvedValue({ filename: 'test-backup.tar.gz' });
vi.mocked(securityHeadersApi.deleteProfile).mockRejectedValue(new Error('Delete failed'));
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Delete Me'));
const buttons = screen.getAllByRole('button');
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
fireEvent.click(deleteButton!);
await waitFor(() => screen.getByText(/Confirm Deletion/i));
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(createBackup).toHaveBeenCalled();
expect(securityHeadersApi.deleteProfile).toHaveBeenCalled();
});
});
it('should handle backup failure during delete', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockRejectedValue(new Error('Backup failed'));
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Delete Me'));
const buttons = screen.getAllByRole('button');
const deleteButton = buttons.find(btn => btn.querySelector('.lucide-trash-2, .lucide-trash'));
fireEvent.click(deleteButton!);
await waitFor(() => screen.getByText(/Confirm Deletion/i));
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(createBackup).toHaveBeenCalled();
expect(securityHeadersApi.deleteProfile).not.toHaveBeenCalled();
});
});
it('should handle unknown preset types', async () => {
const mockProfiles = [{ id: 1, name: 'Weird Preset', is_preset: true, preset_type: 'unknown_type', security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Weird Preset'));
// Just ensuring render doesn't crash
});
it('should handle cancel in edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Edit Me'));
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelButton);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should handle delete from edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me from Edit', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
render(<SecurityHeaders />, { wrapper: createWrapper() });
await waitFor(() => screen.getByText('Delete Me from Edit'));
fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
await waitFor(() => screen.getByText(/Edit Security Header Profile/i));
const deleteButton = screen.getByRole('button', { name: /Delete Profile/i });
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton);
await waitFor(() => screen.getByText(/Confirm Deletion/i));
});
});
@@ -0,0 +1,143 @@
import {
formatSettingLabel,
settingHelpText,
settingKeyToField,
applyBulkSettingsToHosts,
} from '../proxyHostsHelpers'
import type { ProxyHost } from '../../api/proxyHosts'
import { vi } from 'vitest'
describe('proxyHostsHelpers', () => {
describe('formatSettingLabel', () => {
it('returns correct labels for known keys', () => {
expect(formatSettingLabel('ssl_forced')).toBe('Force SSL')
expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support')
expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled')
expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains')
expect(formatSettingLabel('block_exploits')).toBe('Block Exploits')
expect(formatSettingLabel('websocket_support')).toBe('Websockets Support')
expect(formatSettingLabel('enable_standard_headers')).toBe('Standard Proxy Headers')
})
it('returns key for unknown keys', () => {
expect(formatSettingLabel('unknown_key')).toBe('unknown_key')
})
})
describe('settingHelpText', () => {
it('returns correct help text for known keys', () => {
expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic')
expect(settingHelpText('http2_support')).toContain('Enable HTTP/2')
expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation')
})
it('returns empty string for unknown keys', () => {
expect(settingHelpText('unknown_key')).toBe('')
})
})
describe('settingKeyToField', () => {
it('returns correct field for known keys', () => {
expect(settingKeyToField('ssl_forced')).toBe('ssl_forced')
expect(settingKeyToField('websocket_support')).toBe('websocket_support')
})
it('returns key for unknown keys', () => {
expect(settingKeyToField('unknown_key')).toBe('unknown_key')
})
})
describe('applyBulkSettingsToHosts', () => {
const mockHosts: ProxyHost[] = [
{ uuid: 'h1', is_enabled: true } as unknown as ProxyHost,
{ uuid: 'h2', is_enabled: false } as unknown as ProxyHost
]
const mockUpdateHost = vi.fn()
const mockSetProgress = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('applies settings to specified hosts', async () => {
mockUpdateHost.mockResolvedValue({} as ProxyHost)
const result = await applyBulkSettingsToHosts({
hosts: mockHosts,
hostUUIDs: ['h1'],
keysToApply: ['ssl_forced'],
bulkApplySettings: {
ssl_forced: { apply: true, value: true }
},
updateHost: mockUpdateHost,
setApplyProgress: mockSetProgress
})
expect(result).toEqual({ errors: 0, completed: 1 })
expect(mockUpdateHost).toHaveBeenCalledWith('h1', expect.objectContaining({
uuid: 'h1',
ssl_forced: true
}))
expect(mockUpdateHost).toHaveBeenCalledTimes(1)
expect(mockSetProgress).toHaveBeenCalled()
})
it('handles errors during update', async () => {
mockUpdateHost.mockRejectedValue(new Error('Update failed'))
const result = await applyBulkSettingsToHosts({
hosts: mockHosts,
hostUUIDs: ['h1'],
keysToApply: ['ssl_forced'],
bulkApplySettings: {
ssl_forced: { apply: true, value: true }
},
updateHost: mockUpdateHost
})
expect(result).toEqual({ errors: 1, completed: 1 })
})
it('handles missing hosts', async () => {
const result = await applyBulkSettingsToHosts({
hosts: mockHosts,
// h3 doesn't exist
hostUUIDs: ['h3'],
keysToApply: ['ssl_forced'],
bulkApplySettings: {
ssl_forced: { apply: true, value: true }
},
updateHost: mockUpdateHost
})
expect(result).toEqual({ errors: 1, completed: 1 })
expect(mockUpdateHost).not.toHaveBeenCalled()
})
it('handles multiple hosts and settings', async () => {
mockUpdateHost.mockResolvedValue({} as ProxyHost)
const result = await applyBulkSettingsToHosts({
hosts: mockHosts,
hostUUIDs: ['h1', 'h2'],
keysToApply: ['ssl_forced', 'http2_support'],
bulkApplySettings: {
ssl_forced: { apply: true, value: true },
http2_support: { apply: true, value: false }
},
updateHost: mockUpdateHost,
setApplyProgress: mockSetProgress
})
expect(result).toEqual({ errors: 0, completed: 2 })
expect(mockUpdateHost).toHaveBeenCalledWith('h1', expect.objectContaining({
uuid: 'h1',
ssl_forced: true,
http2_support: false
}))
expect(mockUpdateHost).toHaveBeenCalledWith('h2', expect.objectContaining({
uuid: 'h2',
ssl_forced: true,
http2_support: false
}))
expect(mockUpdateHost).toHaveBeenCalledTimes(2)
})
})
})
@@ -0,0 +1,89 @@
import {
isValidEmail,
isIPv4,
isPrivateOrDockerIP,
isLikelyDockerContainerIP,
} from '../validation'
describe('validation utils', () => {
describe('isValidEmail', () => {
it('returns true for valid emails', () => {
expect(isValidEmail('test@example.com')).toBe(true)
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
expect(isValidEmail('user+regex@domain.com')).toBe(true)
})
it('returns false for invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false)
expect(isValidEmail('invalid@')).toBe(false)
expect(isValidEmail('@domain.com')).toBe(false)
expect(isValidEmail('user@domain')).toBe(false)
expect(isValidEmail('')).toBe(false)
})
})
describe('isIPv4', () => {
it('returns true for valid IPv4 addresses', () => {
expect(isIPv4('192.168.1.1')).toBe(true)
expect(isIPv4('10.0.0.1')).toBe(true)
expect(isIPv4('0.0.0.0')).toBe(true)
expect(isIPv4('255.255.255.255')).toBe(true)
})
it('returns false for invalid IPv4 addresses', () => {
expect(isIPv4('256.0.0.1')).toBe(false)
expect(isIPv4('1.2.3')).toBe(false)
expect(isIPv4('1.2.3.4.5')).toBe(false)
expect(isIPv4('1.2.3.4.')).toBe(false)
expect(isIPv4('abc')).toBe(false)
expect(isIPv4('192.168.1.a')).toBe(false)
expect(isIPv4('')).toBe(false)
})
})
describe('isPrivateOrDockerIP', () => {
it('returns true for private IP ranges', () => {
expect(isPrivateOrDockerIP('10.0.0.1')).toBe(true)
expect(isPrivateOrDockerIP('10.255.255.255')).toBe(true)
expect(isPrivateOrDockerIP('192.168.0.1')).toBe(true)
expect(isPrivateOrDockerIP('192.168.255.255')).toBe(true)
expect(isPrivateOrDockerIP('172.16.0.1')).toBe(true) // Start of 172.16.x.x
expect(isPrivateOrDockerIP('172.31.255.255')).toBe(true) // End of 172.31.x.x
})
it('returns false for public or non-private IP ranges', () => {
expect(isPrivateOrDockerIP('8.8.8.8')).toBe(false)
expect(isPrivateOrDockerIP('1.1.1.1')).toBe(false)
expect(isPrivateOrDockerIP('172.15.0.1')).toBe(false) // Below 172.16...
expect(isPrivateOrDockerIP('172.32.0.1')).toBe(false) // Above 172.31...
expect(isPrivateOrDockerIP('192.167.1.1')).toBe(false)
expect(isPrivateOrDockerIP('192.169.1.1')).toBe(false)
})
it('returns false for invalid IPs', () => {
expect(isPrivateOrDockerIP('invalid')).toBe(false)
expect(isPrivateOrDockerIP('999.999.999.999')).toBe(false)
})
})
describe('isLikelyDockerContainerIP', () => {
it('returns true for likely Docker IPs', () => {
// Docker default bridge: 172.17.x.x
expect(isLikelyDockerContainerIP('172.17.0.2')).toBe(true)
// Docker user-defined: 172.18.x.x - 172.31.x.x
expect(isLikelyDockerContainerIP('172.18.0.1')).toBe(true)
expect(isLikelyDockerContainerIP('172.31.255.255')).toBe(true)
})
it('returns false for non-Docker IPs', () => {
expect(isLikelyDockerContainerIP('172.16.0.1')).toBe(false) // Private but often not Docker default
expect(isLikelyDockerContainerIP('192.168.1.1')).toBe(false)
expect(isLikelyDockerContainerIP('10.0.0.1')).toBe(false)
expect(isLikelyDockerContainerIP('8.8.8.8')).toBe(false)
})
it('returns false for invalid IPs', () => {
expect(isLikelyDockerContainerIP('invalid')).toBe(false)
})
})
})