chore: enhance coverage for AccessListSelector and ProxyHostForm components

- Added new test suite for AccessListSelector to cover token normalization and emitted values.
- Updated existing tests for AccessListSelector to handle prefixed and numeric-string form values.
- Introduced tests for ProxyHostForm to validate DNS detection, including error handling and success scenarios.
- Enhanced ProxyHostForm tests to cover token normalization for security headers and ensure proper handling of existing host values.
- Implemented additional tests for ProxyHostForm to verify domain updates based on selected containers and prompt for new base domains.
This commit is contained in:
GitHub Actions
2026-02-28 21:07:41 +00:00
parent 89281c4255
commit 10f5e5dd1d
9 changed files with 1367 additions and 0 deletions
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AccessListSelector from '../AccessListSelector';
import * as useAccessListsHook from '../../hooks/useAccessLists';
vi.mock('../../hooks/useAccessLists');
vi.mock('../ui/Select', () => {
const findText = (children: React.ReactNode): string => {
if (typeof children === 'string') {
return children;
}
if (Array.isArray(children)) {
return children.map((child) => findText(child)).join(' ');
}
if (children && typeof children === 'object' && 'props' in children) {
const node = children as { props?: { children?: React.ReactNode } };
return findText(node.props?.children);
}
return '';
};
const Select = ({ value, onValueChange, children }: { value?: string; onValueChange?: (value: string) => void; children?: React.ReactNode }) => {
const text = findText(children);
const isAccessList = text.includes('No Access Control (Public)');
return (
<div>
{isAccessList && (
<>
<div data-testid="access-list-select-value">{value}</div>
<button type="button" onClick={() => onValueChange?.('uuid:acl-uuid-7')}>emit-uuid-token</button>
<button type="button" onClick={() => onValueChange?.('123')}>emit-numeric-token</button>
<button type="button" onClick={() => onValueChange?.('custom-token')}>emit-custom-token</button>
</>
)}
{children}
</div>
);
};
const SelectTrigger = ({ children, ...rest }: React.ComponentProps<'button'>) => <button type="button" {...rest}>{children}</button>;
const SelectContent = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
const SelectItem = ({ children }: { value: string; children?: React.ReactNode }) => <div>{children}</div>;
const SelectValue = ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>;
return {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
};
});
describe('AccessListSelector token coverage branches', () => {
beforeEach(() => {
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: [
{
id: 7,
uuid: 'acl-uuid-7',
name: 'ACL Seven',
description: 'Coverage ACL',
type: 'whitelist',
enabled: true,
},
],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
});
it('normalizes whitespace and prefixed UUID values in resolver', () => {
const onChange = vi.fn();
const { rerender } = render(<AccessListSelector value={' '} onChange={onChange} />);
expect(screen.getByTestId('access-list-select-value')).toHaveTextContent('none');
rerender(<AccessListSelector value={'uuid:acl-uuid-7'} onChange={onChange} />);
expect(screen.getByTestId('access-list-select-value')).toHaveTextContent('id:7');
});
it('maps emitted UUID, numeric, and fallback tokens through handleValueChange', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<AccessListSelector value={null} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'emit-uuid-token' }));
await user.click(screen.getByRole('button', { name: 'emit-numeric-token' }));
await user.click(screen.getByRole('button', { name: 'emit-custom-token' }));
expect(onChange).toHaveBeenNthCalledWith(1, 7);
expect(onChange).toHaveBeenNthCalledWith(2, 123);
expect(onChange).toHaveBeenNthCalledWith(3, 'custom-token');
});
});
@@ -231,4 +231,207 @@ describe('AccessListSelector', () => {
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('Hybrid ACL');
});
it('handles prefixed and numeric-string form values as stable selections', () => {
const mockLists = [
{
id: 7,
uuid: 'uuid-7',
name: 'ACL Seven',
description: 'Has both ID and UUID',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists as unknown as AccessList[],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const Wrapper = createWrapper();
const mockOnChange = vi.fn();
const { rerender } = render(
<Wrapper>
<AccessListSelector value={'id:7'} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('ACL Seven');
rerender(
<Wrapper>
<AccessListSelector value={'7'} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('ACL Seven');
});
it('treats whitespace-only values as no selection', () => {
const mockLists = [
{
id: 1,
uuid: 'uuid-1',
name: 'ACL One',
description: 'Baseline ACL',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists as unknown as AccessList[],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const Wrapper = createWrapper();
const mockOnChange = vi.fn();
render(
<Wrapper>
<AccessListSelector value={' '} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('No Access Control (Public)');
});
it('resolves prefixed uuid values to matching id-backed ACL tokens', () => {
const mockLists = [
{
id: 42,
uuid: 'acl-uuid-42',
name: 'Resolved ACL',
description: 'UUID maps to numeric token',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists as unknown as AccessList[],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const Wrapper = createWrapper();
const mockOnChange = vi.fn();
render(
<Wrapper>
<AccessListSelector value={'uuid:acl-uuid-42'} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('Resolved ACL');
});
it('supports UUID-only ACL selection and local-network details', async () => {
const uuidOnly = '9f63b8c9-1d26-4b2f-a2c8-001122334455';
const mockLists = [
{
id: undefined,
uuid: uuidOnly,
name: 'Local UUID ACL',
description: 'Only internal network',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: true,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists as unknown as AccessList[],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
const user = userEvent.setup();
const { rerender } = render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
await user.click(screen.getByRole('combobox', { name: /Access Control List/i }));
await user.click(await screen.findByRole('option', { name: 'Local UUID ACL (whitelist)' }));
expect(mockOnChange).toHaveBeenCalledWith(uuidOnly);
rerender(
<Wrapper>
<AccessListSelector value={uuidOnly} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByText(/Local Network Only \(RFC1918\)/)).toBeInTheDocument();
});
it('skips malformed ACL entries without id or uuid tokens', async () => {
const mockLists = [
{
id: 4,
uuid: 'valid-uuid-4',
name: 'Valid ACL',
description: 'valid option',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
{
id: undefined,
uuid: undefined,
name: 'Malformed ACL',
description: 'should be ignored',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists as unknown as AccessList[],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
const user = userEvent.setup();
render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
await user.click(screen.getByRole('combobox', { name: /Access Control List/i }));
expect(screen.getByRole('option', { name: 'Valid ACL (whitelist)' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Malformed ACL (whitelist)' })).not.toBeInTheDocument();
});
});
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProxyHostForm from '../ProxyHostForm'
import type { ProxyHost } from '../../api/proxyHosts'
import { mockRemoteServers } from '../../test/mockData'
import { toast } from 'react-hot-toast'
// Mock the hooks
vi.mock('../../hooks/useRemoteServers', () => ({
@@ -103,6 +104,36 @@ vi.mock('../../hooks/useDNSDetection', () => ({
})),
}))
vi.mock('../DNSDetectionResult', () => ({
DNSDetectionResult: ({ result, onUseSuggested, onSelectManually }: {
result?: { suggested_provider?: { id: number; name: string } }
isLoading: boolean
onUseSuggested: (provider: { id: number; name: string }) => void
onSelectManually: () => void
}) => (
<div>
<button
type="button"
onClick={() => {
if (result?.suggested_provider) {
onUseSuggested(result.suggested_provider)
}
}}
>
Use Suggested DNS
</button>
<button type="button" onClick={onSelectManually}>Select Manually DNS</button>
</div>
),
}))
vi.mock('react-hot-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../api/dnsDetection', () => ({
detectDNSProvider: vi.fn().mockResolvedValue({
domain: 'example.com',
@@ -436,4 +467,139 @@ describe('ProxyHostForm - DNS Provider Integration', () => {
})
})
})
describe('DNS Detection Branches', () => {
it('skips detection call when wildcard has provider set and no suggestion', async () => {
vi.useFakeTimers()
const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection')
const detectSpy = vi.fn().mockResolvedValue({
domain: 'example.com',
detected: false,
nameservers: [],
confidence: 'none',
})
vi.mocked(useDetectDNSProvider).mockReturnValue({
mutateAsync: detectSpy,
isPending: false,
data: undefined,
reset: vi.fn(),
} as unknown as ReturnType<typeof useDetectDNSProvider>)
const existingHost: ProxyHost = {
uuid: 'test-uuid-skip-detect',
name: 'Existing Wildcard Provider',
domain_names: '*.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
dns_provider_id: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
renderWithClient(
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await vi.advanceTimersByTimeAsync(600)
expect(detectSpy).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('logs detection errors when detectProvider rejects', async () => {
const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection')
const detectSpy = vi.fn().mockRejectedValue(new Error('detect failed'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(useDetectDNSProvider).mockReturnValue({
mutateAsync: detectSpy,
isPending: false,
data: undefined,
reset: vi.fn(),
} as unknown as ReturnType<typeof useDetectDNSProvider>)
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, '*.example.com')
await new Promise((resolve) => setTimeout(resolve, 700))
await waitFor(() => {
expect(errorSpy).toHaveBeenCalledWith('DNS detection failed:', expect.any(Error))
})
errorSpy.mockRestore()
})
it('auto-selects high confidence suggestion and emits success toast', async () => {
const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection')
vi.mocked(useDetectDNSProvider).mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
data: {
domain: 'example.com',
detected: true,
nameservers: ['ns1.cloudflare.com'],
confidence: 'high',
suggested_provider: { id: 1, name: 'Cloudflare' },
},
reset: vi.fn(),
} as unknown as ReturnType<typeof useDetectDNSProvider>)
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
await userEvent.type(screen.getByPlaceholderText('My Service'), 'Auto Select')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), '*.example.com')
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Auto-selected: Cloudflare')
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({ dns_provider_id: 1 }))
})
})
it('handles suggested and manual selection callbacks from detection result card', async () => {
const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection')
vi.mocked(useDetectDNSProvider).mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
data: {
domain: 'example.com',
detected: true,
nameservers: ['ns1.cloudflare.com'],
confidence: 'medium',
suggested_provider: { id: 1, name: 'Cloudflare' },
},
reset: vi.fn(),
} as unknown as ReturnType<typeof useDetectDNSProvider>)
renderWithClient(<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />)
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), '*.example.com')
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Use Suggested DNS' })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: 'Use Suggested DNS' }))
expect(toast.success).toHaveBeenCalledWith('Selected: Cloudflare')
await userEvent.click(screen.getByRole('button', { name: 'Select Manually DNS' }))
})
})
})
@@ -663,4 +663,147 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => {
)
})
})
it('initializes edit mode from nested ACL and security header UUID references', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
const existingHost = {
uuid: 'host-uuid-nested-ref',
name: 'Nested Ref Host',
domain_names: 'test.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enable_standard_headers: true,
application: 'none',
advanced_config: '',
enabled: true,
locations: [],
certificate_id: null,
access_list_id: null,
security_header_profile_id: null,
access_list: { uuid: 'acl-uuid-2' },
security_header_profile: { uuid: 'profile-uuid-2' },
dns_provider_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
} as unknown as ProxyHost
render(
<Wrapper>
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('VPN Users')
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
access_list_id: 'acl-uuid-2',
security_header_profile_id: 'profile-uuid-2',
})
)
})
})
it('normalizes empty and numeric-string ACL/security references on submit', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
const hostWithStringReferences = {
uuid: 'host-uuid-string-refs',
name: 'String Ref Host',
domain_names: 'test.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enable_standard_headers: true,
application: 'none',
advanced_config: '',
enabled: true,
locations: [],
certificate_id: null,
access_list_id: '2',
security_header_profile_id: ' ',
dns_provider_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
} as unknown as ProxyHost
render(
<Wrapper>
<ProxyHostForm host={hostWithStringReferences} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('VPN Users')
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
access_list_id: 2,
security_header_profile_id: null,
})
)
})
})
it('filters out security profiles missing both id and uuid', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
vi.mocked(useSecurityHeaderProfiles).mockReturnValue({
data: [
{
...mockSecurityProfiles[0],
id: undefined,
uuid: undefined,
name: 'Broken Profile',
},
{
...mockSecurityProfiles[1],
id: 2,
uuid: 'profile-uuid-2',
name: 'Strict Security',
},
] as unknown as SecurityHeaderProfile[],
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useSecurityHeaderProfiles>)
render(
<Wrapper>
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
await user.type(screen.getByLabelText(/^Name/), 'Filter Profile Host')
await user.type(screen.getByLabelText(/Domain Names/), 'test.com')
await user.type(screen.getByLabelText(/^Host$/), 'localhost')
await user.clear(screen.getByLabelText(/^Port$/))
await user.type(screen.getByLabelText(/^Port$/), '8080')
await user.click(screen.getByRole('combobox', { name: /Security Headers/i }))
expect(screen.queryByRole('option', { name: /Broken Profile/i })).not.toBeInTheDocument()
expect(screen.getByRole('option', { name: /Strict Security/i })).toBeInTheDocument()
})
})
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ProxyHostForm from '../ProxyHostForm';
import type { ProxyHost } from '../../api/proxyHosts';
vi.mock('../../hooks/useRemoteServers', () => ({
useRemoteServers: vi.fn(() => ({
servers: [],
isLoading: false,
error: null,
})),
}));
vi.mock('../../hooks/useDocker', () => ({
useDocker: vi.fn(() => ({
containers: [],
isLoading: false,
error: null,
refetch: vi.fn(),
})),
}));
vi.mock('../../hooks/useDomains', () => ({
useDomains: vi.fn(() => ({
domains: [{ uuid: 'domain-1', name: 'test.com' }],
createDomain: vi.fn().mockResolvedValue({}),
isLoading: false,
error: null,
})),
}));
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({
certificates: [],
isLoading: false,
error: null,
})),
}));
vi.mock('../../hooks/useDNSDetection', () => ({
useDetectDNSProvider: vi.fn(() => ({
mutateAsync: vi.fn(),
isPending: false,
data: undefined,
reset: vi.fn(),
})),
}));
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(() => ({
data: [
{
id: 1,
uuid: 'acl-uuid-1',
name: 'Office Network',
description: 'Office IP range',
type: 'whitelist',
enabled: true,
},
],
isLoading: false,
error: null,
})),
}));
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({
data: [
{
id: 1,
uuid: 'profile-uuid-1',
name: 'Basic Security',
description: 'Basic security headers',
is_preset: true,
preset_type: 'basic',
security_score: 60,
},
{
id: undefined,
uuid: undefined,
name: 'Malformed Custom',
description: 'Should be skipped in options map',
is_preset: false,
preset_type: 'custom',
security_score: 10,
},
],
isLoading: false,
error: null,
})),
}));
vi.mock('../ui/Select', () => {
const findText = (children: React.ReactNode): string => {
if (typeof children === 'string') {
return children;
}
if (Array.isArray(children)) {
return children.map((child) => findText(child)).join(' ');
}
if (children && typeof children === 'object' && 'props' in children) {
const node = children as { props?: { children?: React.ReactNode } };
return findText(node.props?.children);
}
return '';
};
const Select = ({ value, onValueChange, children }: { value?: string; onValueChange?: (value: string) => void; children?: React.ReactNode }) => {
const text = findText(children);
const isSecurityHeaders = text.includes('None (No Security Headers)');
return (
<div>
{isSecurityHeaders && (
<>
<div data-testid="security-select-value">{value}</div>
<button type="button" onClick={() => onValueChange?.('42')}>emit-security-plain-numeric</button>
<button type="button" onClick={() => onValueChange?.('custom-header-token')}>emit-security-custom</button>
</>
)}
{children}
</div>
);
};
const SelectTrigger = ({ children, ...rest }: React.ComponentProps<'button'>) => <button type="button" {...rest}>{children}</button>;
const SelectContent = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
const SelectItem = ({ children }: { value: string; children?: React.ReactNode }) => <div>{children}</div>;
const SelectValue = () => <span />;
return {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
};
});
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
const fillRequiredFields = async () => {
await userEvent.type(screen.getByLabelText(/^Name/), 'Coverage Host');
await userEvent.type(screen.getByLabelText(/Domain Names/), 'test.com');
await userEvent.type(screen.getByLabelText(/^Host$/), 'localhost');
await userEvent.clear(screen.getByLabelText(/^Port$/));
await userEvent.type(screen.getByLabelText(/^Port$/), '8080');
};
describe('ProxyHostForm token coverage branches', () => {
const onCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('normalizes prefixed and numeric-string security header IDs', async () => {
const onSubmit = vi.fn<(data: Partial<ProxyHost>) => Promise<void>>().mockResolvedValue();
const Wrapper = createWrapper();
const { rerender } = render(
<Wrapper>
<ProxyHostForm
host={{
uuid: 'host-1',
domain_names: 'a.test',
forward_host: 'localhost',
forward_port: 8080,
security_header_profile_id: 'id:7',
} as ProxyHost}
onSubmit={onSubmit}
onCancel={onCancel}
/>
</Wrapper>
);
expect(screen.getByTestId('security-select-value')).toHaveTextContent('id:7');
rerender(
<Wrapper>
<ProxyHostForm
host={{
uuid: 'host-2',
domain_names: 'b.test',
forward_host: 'localhost',
forward_port: 8080,
security_header_profile_id: '12',
} as ProxyHost}
onSubmit={onSubmit}
onCancel={onCancel}
/>
</Wrapper>
);
expect(screen.getByTestId('security-select-value')).toHaveTextContent('id:12');
});
it('converts plain numeric and custom security tokens on submit', async () => {
const onSubmit = vi.fn<(data: Partial<ProxyHost>) => Promise<void>>().mockResolvedValue();
const Wrapper = createWrapper();
render(
<Wrapper>
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
</Wrapper>
);
await fillRequiredFields();
await userEvent.click(screen.getByRole('button', { name: 'emit-security-plain-numeric' }));
await userEvent.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ security_header_profile_id: 42 })
);
});
onSubmit.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'emit-security-custom' }));
await userEvent.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ security_header_profile_id: 'custom-header-token' })
);
});
});
});
@@ -109,4 +109,39 @@ describe('ProxyHostForm Add Uptime flow', () => {
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
})
it('shows uptime sync fallback error toast when monitor request fails with empty string error', async () => {
const onSubmit = vi.fn(() => Promise.resolve())
const onCancel = vi.fn()
const uptime = await import('../../api/uptime')
const syncMock = uptime.syncMonitors as unknown as import('vitest').Mock
syncMock.mockRejectedValueOnce('')
const toastModule = await import('react-hot-toast')
const errorSpy = vi.spyOn(toastModule.toast, 'error')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
</QueryClientProvider>
)
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
await userEvent.clear(screen.getByLabelText(/^Port$/))
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
await userEvent.click(screen.getByLabelText(/Add Uptime monitoring for this host/i))
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
expect(syncMock).toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledWith('Failed to request uptime creation')
})
})
})
@@ -123,6 +123,13 @@ vi.mock('../../api/proxyHosts', () => ({
testProxyHostConnection: vi.fn(),
}))
vi.mock('react-hot-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
// Mock global fetch for health API
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
@@ -552,6 +559,51 @@ describe('ProxyHostForm', () => {
})
})
it('closes preset overwrite modal when cancel is clicked', async () => {
const existingHost = {
uuid: 'test-uuid',
name: 'CancelOverwrite',
domain_names: 'test.example.com',
forward_scheme: 'http',
forward_host: '192.168.1.2',
forward_port: 8080,
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
advanced_config_backup: '',
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
application: 'none' as const,
locations: [],
enabled: true,
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
await waitFor(() => {
expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument()
})
const modal = screen.getByText('Confirm Preset Overwrite').closest('div')?.parentElement
if (!modal) {
throw new Error('Preset overwrite modal not found')
}
await userEvent.click(within(modal).getByRole('button', { name: 'Cancel' }))
await waitFor(() => {
expect(screen.queryByText('Confirm Preset Overwrite')).not.toBeInTheDocument()
})
})
it('restores previous advanced_config from backup when clicking restore', async () => {
const existingHost = {
uuid: 'test-uuid',
@@ -700,6 +752,83 @@ describe('ProxyHostForm', () => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('copies plex trusted proxy IP helper snippet', async () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
await userEvent.click(screen.getAllByRole('button', { name: /Copy/i })[1])
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('192.168.1.50')
})
})
it('copies jellyfin trusted proxy IP helper snippet', async () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com')
await selectComboboxOption(/Application Preset/i, 'Jellyfin - Open source media server')
await userEvent.click(screen.getByRole('button', { name: /Copy/i }))
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('192.168.1.50')
})
})
it('copies home assistant helper yaml snippet', async () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com')
await selectComboboxOption(/Application Preset/i, 'Home Assistant - Home automation')
await userEvent.click(screen.getByRole('button', { name: /Copy/i }))
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('http:\n use_x_forwarded_for: true\n trusted_proxies:\n - 192.168.1.50')
})
})
it('copies nextcloud helper php snippet', async () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, {
clipboard: { writeText: mockWriteText },
})
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com')
await selectComboboxOption(/Application Preset/i, 'Nextcloud - File sync and share')
await userEvent.click(screen.getByRole('button', { name: /Copy/i }))
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith("'trusted_proxies' => ['192.168.1.50'],\n'overwriteprotocol' => 'https',")
})
})
})
describe('Security Options', () => {
@@ -943,6 +1072,85 @@ describe('ProxyHostForm', () => {
await selectComboboxOption(/Security Headers/i, 'Custom Profile (Score: 70/100)')
expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Custom Profile')
})
it('resolves prefixed security header id tokens from existing host values', async () => {
const existingHost = {
uuid: 'security-token-host',
name: 'Token Host',
domain_names: 'token.example.com',
forward_scheme: 'http',
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: true,
application: 'none' as const,
locations: [],
enabled: true,
security_header_profile_id: 'id:100',
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost as unknown as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Strict Profile')
})
it('resolves numeric-string security header ids from existing host values', async () => {
const existingHost = {
uuid: 'security-numeric-host',
name: 'Numeric Host',
domain_names: 'numeric.example.com',
forward_scheme: 'http',
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: true,
application: 'none' as const,
locations: [],
enabled: true,
security_header_profile_id: '100',
created_at: '2025-01-01',
updated_at: '2025-01-01',
}
renderWithClient(
<ProxyHostForm host={existingHost as unknown as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Strict Profile')
})
it('skips non-preset profiles that have neither id nor uuid', async () => {
const { useSecurityHeaderProfiles } = await import('../../hooks/useSecurityHeaders')
vi.mocked(useSecurityHeaderProfiles).mockReturnValue({
data: [
{ id: 100, name: 'Strict Profile', description: 'Very strict', security_score: 90, is_preset: true, preset_type: 'strict' },
{ name: 'Invalid Custom', description: 'No identity token', security_score: 10, is_preset: false },
],
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useSecurityHeaderProfiles>)
renderWithClient(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.click(screen.getByRole('combobox', { name: /Security Headers/i }))
expect(screen.queryByRole('option', { name: /Invalid Custom/i })).not.toBeInTheDocument()
})
})
describe('Edit Mode vs Create Mode', () => {
@@ -1247,6 +1455,55 @@ describe('ProxyHostForm', () => {
}))
})
})
it('updates domain using selected container when base domain changes', async () => {
const { useDocker } = await import('../../hooks/useDocker')
vi.mocked(useDocker).mockReturnValue({
containers: [
{
id: 'container-123',
names: ['my-app'],
image: 'nginx:latest',
state: 'running',
status: 'Up 2 hours',
network: 'bridge',
ip: '172.17.0.2',
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }],
},
],
isLoading: false,
error: null,
refetch: vi.fn(),
})
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await selectComboboxOption('Source', 'Local (Docker Socket)')
await selectComboboxOption('Containers', 'my-app (nginx:latest)')
await selectComboboxOption(/Base Domain/i, 'existing.com')
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
})
it('prompts to save a new base domain when user enters a base domain directly', async () => {
localStorage.removeItem('charon_dont_ask_domain')
localStorage.removeItem('cpmp_dont_ask_domain')
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
await userEvent.type(domainInput, 'brandnewdomain.com')
await userEvent.tab()
await waitFor(() => {
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
expect(screen.getByText('brandnewdomain.com')).toBeInTheDocument()
})
})
})
describe('Host and Port Combination', () => {