fix: resolve stale closure bugs in ProxyHostForm and enhance ACL/Security Headers management

This commit is contained in:
GitHub Actions
2026-02-13 00:05:41 +00:00
parent 9ff12a80bf
commit 2904b7435e
4 changed files with 630 additions and 173 deletions

View File

@@ -571,7 +571,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
required
value={formData.name}
onChange={e => {
setFormData({ ...formData, name: e.target.value })
setFormData(prev => ({ ...prev, name: e.target.value }))
if (nameError && e.target.value.trim()) {
setNameError(null)
}
@@ -688,7 +688,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
onChange={e => setFormData(prev => ({ ...prev, domain_names: e.target.value }))}
onBlur={e => checkNewDomains(e.target.value)}
placeholder="example.com, www.example.com"
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"
@@ -700,7 +700,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<Select value={formData.forward_scheme} onValueChange={scheme => setFormData({ ...formData, forward_scheme: scheme })}>
<Select value={formData.forward_scheme} onValueChange={scheme => setFormData(prev => ({ ...prev, forward_scheme: scheme }))}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Scheme">
<SelectValue />
</SelectTrigger>
@@ -725,7 +725,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
type="text"
required
value={formData.forward_host}
onChange={e => setFormData({ ...formData, forward_host: e.target.value })}
onChange={e => setFormData(prev => ({ ...prev, forward_host: e.target.value }))}
placeholder="my-container or 192.168.1.100"
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"
/>
@@ -748,7 +748,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
onChange={e => {
const v = parseInt(e.target.value)
portInputRef.current?.setCustomValidity('')
setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v })
setFormData(prev => ({ ...prev, forward_port: Number.isNaN(v) ? 0 : v }))
}}
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"
/>
@@ -760,7 +760,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<label className="block text-sm font-medium text-gray-300 mb-2">
SSL Certificate
</label>
<Select value={String(formData.certificate_id || 0)} onValueChange={e => setFormData({ ...formData, certificate_id: parseInt(e) || null })}>
<Select value={String(formData.certificate_id || 0)} onValueChange={e => setFormData(prev => ({ ...prev, certificate_id: parseInt(e) || null }))}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="SSL Certificate">
<SelectValue />
</SelectTrigger>
@@ -819,7 +819,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
{/* Access Control List */}
<AccessListSelector
value={formData.access_list_id || null}
onChange={id => setFormData({ ...formData, access_list_id: id })}
onChange={id => setFormData(prev => ({ ...prev, access_list_id: id }))}
/>
{/* Security Headers Profile */}
@@ -833,7 +833,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
value={String(formData.security_header_profile_id || 0)}
onValueChange={e => {
const value = e === "0" ? null : parseInt(e) || null
setFormData({ ...formData, security_header_profile_id: value })
setFormData(prev => ({ ...prev, security_header_profile_id: value }))
}}
>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Security Headers">
@@ -1096,7 +1096,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.ssl_forced}
onChange={e => setFormData({ ...formData, ssl_forced: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, ssl_forced: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Force SSL</span>
@@ -1108,7 +1108,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.http2_support}
onChange={e => setFormData({ ...formData, http2_support: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, http2_support: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HTTP/2 Support</span>
@@ -1120,7 +1120,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.hsts_enabled}
onChange={e => setFormData({ ...formData, hsts_enabled: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, hsts_enabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Enabled</span>
@@ -1132,7 +1132,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.hsts_subdomains}
onChange={e => setFormData({ ...formData, hsts_subdomains: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, hsts_subdomains: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Subdomains</span>
@@ -1144,7 +1144,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.block_exploits}
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, block_exploits: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Block Exploits</span>
@@ -1156,7 +1156,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.websocket_support}
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, websocket_support: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Websockets Support</span>
@@ -1168,7 +1168,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.enable_standard_headers ?? true}
onChange={e => setFormData({ ...formData, enable_standard_headers: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, enable_standard_headers: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enable Standard Proxy Headers</span>
@@ -1214,7 +1214,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<textarea
id="advanced-config"
value={formData.advanced_config}
onChange={e => setFormData({ ...formData, advanced_config: e.target.value })}
onChange={e => setFormData(prev => ({ ...prev, advanced_config: e.target.value }))}
placeholder="Additional Caddy directives..."
rows={4}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -1228,7 +1228,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
onChange={e => setFormData(prev => ({ ...prev, enabled: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-white">Enable Proxy Host</span>

View File

@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import userEvent from '@testing-library/user-event'
import ProxyHostForm from '../ProxyHostForm'
import type { ProxyHost } from '../../api/proxyHosts'
import type { AccessList } from '../../api/accessLists'
import type { SecurityHeaderProfile } from '../../api/securityHeaders'
// Mock all required hooks
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' }], // Add test.com so modal doesn't appear
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(),
})),
}))
const mockAccessLists: AccessList[] = [
{
id: 1,
uuid: 'acl-uuid-1',
name: 'Office Network',
description: 'Office IP range',
type: 'whitelist',
ip_rules: JSON.stringify([]),
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
{
id: 2,
uuid: 'acl-uuid-2',
name: 'VPN Users',
description: 'VPN IP range',
type: 'whitelist',
ip_rules: JSON.stringify([]),
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
]
const mockSecurityProfiles: SecurityHeaderProfile[] = [
{
id: 1,
uuid: 'profile-uuid-1',
name: 'Basic Security',
description: 'Basic security headers',
is_preset: true,
preset_type: 'basic',
security_score: 60,
created_at: '2024-01-01',
updated_at: '2024-01-01',
hsts_enabled: false,
hsts_max_age: 0,
hsts_include_subdomains: false,
hsts_preload: false,
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
x_frame_options: '',
x_content_type_options: false,
referrer_policy: '',
permissions_policy: '',
cross_origin_opener_policy: '',
cross_origin_resource_policy: '',
cross_origin_embedder_policy: '',
xss_protection: false,
cache_control_no_store: false,
},
{
id: 2,
uuid: 'profile-uuid-2',
name: 'Strict Security',
description: 'Strict security headers',
is_preset: true,
preset_type: 'strict',
security_score: 90,
created_at: '2024-01-01',
updated_at: '2024-01-01',
hsts_enabled: true,
hsts_max_age: 31536000,
hsts_include_subdomains: true,
hsts_preload: true,
csp_enabled: true,
csp_directives: "default-src 'self'",
csp_report_only: false,
csp_report_uri: '',
x_frame_options: 'DENY',
x_content_type_options: true,
referrer_policy: 'no-referrer',
permissions_policy: '',
cross_origin_opener_policy: 'same-origin',
cross_origin_resource_policy: 'same-origin',
cross_origin_embedder_policy: 'require-corp',
xss_protection: true,
cache_control_no_store: false,
},
]
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(() => ({
data: mockAccessLists,
isLoading: false,
error: null,
})),
}))
vi.mock('../../hooks/useSecurityHeaders', () => ({
useSecurityHeaderProfiles: vi.fn(() => ({
data: mockSecurityProfiles,
isLoading: false,
error: null,
})),
}))
// Mock fetch for health endpoint
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>
)
}
describe('ProxyHostForm Dropdown Change Bug Fix', () => {
let mockOnSubmit: (data: Partial<ProxyHost>) => Promise<void>
let mockOnCancel: () => void
beforeEach(() => {
mockOnSubmit = vi.fn<(data: Partial<ProxyHost>) => Promise<void>>()
mockOnCancel = vi.fn<() => void>()
})
it('allows changing ACL selection after initial selection', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
render(
<Wrapper>
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
// Fill required fields
await user.type(screen.getByLabelText(/^Name/), 'Test Service')
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')
// Select first ACL
const aclTrigger = screen.getByRole('combobox', { name: /Access Control List/i })
await user.click(aclTrigger)
await user.click(await screen.findByRole('option', { name: /Office Network/i }))
// Verify first ACL is selected
expect(screen.getByText('Office Network')).toBeInTheDocument()
// Change to second ACL
await user.click(aclTrigger)
await user.click(await screen.findByRole('option', { name: /VPN Users/i }))
// Verify second ACL is now selected
expect(screen.getByText('VPN Users')).toBeInTheDocument()
// Submit and verify the correct ACL ID is in the payload
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
access_list_id: 2, // Should be second ACL
})
)
})
})
it('allows removing ACL selection', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
render(
<Wrapper>
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
// Fill required fields
await user.type(screen.getByLabelText(/^Name/), 'Test Service')
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')
// Select an ACL
const aclTrigger = screen.getByRole('combobox', { name: /Access Control List/i })
await user.click(aclTrigger)
await user.click(await screen.findByRole('option', { name: /Office Network/i }))
// Verify ACL is selected
expect(screen.getByText('Office Network')).toBeInTheDocument()
// Remove ACL by selecting "No Access Control"
await user.click(aclTrigger)
await user.click(await screen.findByRole('option', { name: /No Access Control/i }))
// Submit and verify ACL is null
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
access_list_id: null,
})
)
})
})
it('allows changing Security Headers selection after initial selection', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
render(
<Wrapper>
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
// Fill required fields
await user.type(screen.getByLabelText(/^Name/), 'Test Service')
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')
// Select first security profile
const headersTrigger = screen.getByRole('combobox', { name: /Security Headers/i })
await user.click(headersTrigger)
await user.click(await screen.findByRole('option', { name: /Basic Security/i }))
// Change to second profile
await user.click(headersTrigger)
await user.click(await screen.findByRole('option', { name: /Strict Security/i }))
// Submit and verify the correct profile ID is in the payload
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
security_header_profile_id: 2, // Should be second profile
})
)
})
})
it('allows removing Security Headers selection', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
render(
<Wrapper>
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
// Fill required fields
await user.type(screen.getByLabelText(/^Name/), 'Test Service')
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')
// Select a security profile
const headersTrigger = screen.getByRole('combobox', { name: /Security Headers/i })
await user.click(headersTrigger)
await user.click(await screen.findByRole('option', { name: /Basic Security/i }))
// Remove security headers by selecting "None"
await user.click(headersTrigger)
await user.click(await screen.findByRole('option', { name: /None \(No Security Headers\)/i }))
// Submit and verify security_header_profile_id is null
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
security_header_profile_id: null,
})
)
})
})
it('allows editing existing host with ACL and changing it', async () => {
const user = userEvent.setup()
const Wrapper = createWrapper()
const existingHost: ProxyHost = {
uuid: 'host-uuid-1',
name: 'Existing Service',
domain_names: 'existing.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: 1, // Initially has first ACL
security_header_profile_id: 1, // Initially has first profile
dns_provider_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
render(
<Wrapper>
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
</Wrapper>
)
// Verify initial ACL is shown
expect(screen.getByText('Office Network')).toBeInTheDocument()
// Change ACL
const aclTrigger = screen.getByRole('combobox', { name: /Access Control List/i })
await user.click(aclTrigger)
await user.click(await screen.findByRole('option', { name: /VPN Users/i }))
// Verify new ACL is shown
expect(screen.getByText('VPN Users')).toBeInTheDocument()
// Change security headers
const headersTrigger = screen.getByRole('combobox', { name: /Security Headers/i })
await user.click(headersTrigger)
await user.click(await screen.findByRole('option', { name: /Strict Security/i }))
// Submit and verify changes
await user.click(screen.getByRole('button', { name: /Save/i }))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
access_list_id: 2,
security_header_profile_id: 2,
})
)
})
})
})