diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 35e89e1c..3bcd3cd2 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -27,6 +27,9 @@ export default function ProxyHosts() { const [showBulkACLModal, setShowBulkACLModal] = useState(false) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [isCreatingBackup, setIsCreatingBackup] = useState(false) + const [selectedACLs, setSelectedACLs] = useState>(new Set()) + const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply') + const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null) const { data: settings } = useQuery({ queryKey: ['settings'], @@ -474,58 +477,213 @@ export default function ProxyHosts() { onClick={() => setShowBulkACLModal(false)} >
e.stopPropagation()} >

Apply Access List

-
-
-

- Applying to {selectedHosts.size} selected host(s) -

-
-
- - -
-
+
+

+ Applying to {selectedHosts.size} selected host(s) +

+ + {/* Action Toggle */} +
+ +
+ + {/* ACL Selection List */} + {bulkACLAction === 'apply' && ( +
+ {/* Select All / Clear header */} + {(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && ( +
+ + {selectedACLs.size} of {accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0} selected + +
+ + | + +
+
+ )} +
+ {accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? ( +

No enabled access lists available

+ ) : ( + accessLists + ?.filter((acl: AccessList) => acl.enabled) + .map((acl: AccessList) => ( + + )) + )} +
+
+ )} + + {/* Remove ACL Confirmation */} + {bulkACLAction === 'remove' && ( +
+
+
🚫
+

+ This will remove the access list from all {selectedHosts.size} selected host(s). +

+

+ The hosts will become publicly accessible. +

+
+
+ )} + + {/* Progress indicator */} + {applyProgress && ( +
+
+ + + Applying ACLs... ({applyProgress.current}/{applyProgress.total}) + +
+
+
+
+
+ )} + + {/* Action Buttons */} +
+ +
diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx new file mode 100644 index 00000000..33fab10f --- /dev/null +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx @@ -0,0 +1,618 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import ProxyHosts from '../ProxyHosts'; +import * as proxyHostsApi from '../../api/proxyHosts'; +import * as certificatesApi from '../../api/certificates'; +import * as accessListsApi from '../../api/accessLists'; +import * as settingsApi from '../../api/settings'; +import { toast } from 'react-hot-toast'; + +// Mock toast +vi.mock('react-hot-toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +// Mock API modules +vi.mock('../../api/proxyHosts', () => ({ + getProxyHosts: vi.fn(), + createProxyHost: vi.fn(), + updateProxyHost: vi.fn(), + deleteProxyHost: vi.fn(), + bulkUpdateACL: vi.fn(), + testProxyHostConnection: vi.fn(), +})); + +vi.mock('../../api/backups', () => ({ + createBackup: vi.fn(), + getBackups: vi.fn(), + restoreBackup: vi.fn(), + deleteBackup: vi.fn(), +})); + +vi.mock('../../api/certificates', () => ({ + getCertificates: vi.fn(), +})); + +vi.mock('../../api/accessLists', () => ({ + accessListsApi: { + list: vi.fn(), + get: vi.fn(), + getTemplates: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + testIP: vi.fn(), + }, +})); + +vi.mock('../../api/settings', () => ({ + getSettings: vi.fn(), +})); + +const mockProxyHosts = [ + { + uuid: 'host-1', + name: 'Test Host 1', + domain_names: 'test1.example.com', + forward_host: '192.168.1.10', + forward_port: 8080, + forward_scheme: 'http' as const, + enabled: true, + ssl_forced: false, + http2_support: true, + hsts_enabled: false, + hsts_subdomains: false, + block_exploits: true, + websocket_support: false, + application: 'none' as const, + locations: [], + access_list_id: null, + certificate_id: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + uuid: 'host-2', + name: 'Test Host 2', + domain_names: 'test2.example.com', + forward_host: '192.168.1.20', + forward_port: 8080, + forward_scheme: 'http' as const, + enabled: true, + ssl_forced: false, + http2_support: true, + hsts_enabled: false, + hsts_subdomains: false, + block_exploits: true, + websocket_support: false, + application: 'none' as const, + locations: [], + access_list_id: null, + certificate_id: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, +]; + +const mockAccessLists = [ + { + id: 1, + uuid: 'acl-1', + name: 'Admin Only', + description: 'Admin access', + type: 'whitelist' as const, + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 2, + uuid: 'acl-2', + name: 'Local Network', + description: 'Local network only', + type: 'whitelist' as const, + ip_rules: '[]', + country_codes: '', + local_network_only: true, + enabled: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 3, + uuid: 'acl-3', + name: 'Disabled ACL', + description: 'This is disabled', + type: 'blacklist' as const, + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, +]; + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, +}); + +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient(); + return render( + + + {ui} + + + ); +}; + +describe('ProxyHosts - Bulk ACL Modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup default mocks + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts); + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]); + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue(mockAccessLists); + vi.mocked(settingsApi.getSettings).mockResolvedValue({}); + }); + + it('renders Manage ACL button when hosts are selected', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select all hosts using the select-all checkbox + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + // Manage ACL button should appear + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + }); + + it('opens bulk ACL modal when Manage ACL is clicked', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + + // Click Manage ACL + fireEvent.click(screen.getByText('Manage ACL')); + + // Modal should open + await waitFor(() => { + expect(screen.getByText('Apply Access List')).toBeTruthy(); + }); + }); + + it('shows Apply ACL and Remove ACL toggle buttons', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Should show toggle buttons + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Apply ACL' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Remove ACL' })).toBeTruthy(); + }); + }); + + it('shows only enabled access lists in the selection', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Should show enabled ACLs + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + expect(screen.getByText('Local Network')).toBeTruthy(); + }); + + // Should NOT show disabled ACL + expect(screen.queryByText('Disabled ACL')).toBeNull(); + }); + + it('shows ACL type alongside name', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Should show type - the modal should display ACL types + await waitFor(() => { + // Check that the ACL list is rendered with names + expect(screen.getByText('Admin Only')).toBeTruthy(); + expect(screen.getByText('Local Network')).toBeTruthy(); + }); + }); + + it('has Apply button disabled when no ACL is selected', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Wait for modal to open + await waitFor(() => { + expect(screen.getByText('Apply Access List')).toBeTruthy(); + }); + + // Apply button should be disabled - find it by looking for the action button (not toggle) + // The action button has bg-blue-600 class, the toggle has flex-1 class + const buttons = screen.getAllByRole('button'); + const applyButton = buttons.find(btn => { + const text = btn.textContent?.trim() || ''; + const hasApply = text.startsWith('Apply') && !text.includes('ACL'); // "Apply" or "Apply (N)" but not "Apply ACL" + const isActionButton = btn.className.includes('bg-blue-600'); + return hasApply && isActionButton; + }); + expect(applyButton).toBeTruthy(); + expect((applyButton as HTMLButtonElement)?.disabled).toBe(true); + }); + + it('enables Apply button when ACL is selected', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Wait for ACL list + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + }); + + // Select an ACL + const aclCheckboxes = screen.getAllByRole('checkbox'); + // Find the checkbox for Admin Only (should be after the host selection checkboxes) + const aclCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Admin Only') + ); + if (aclCheckbox) { + fireEvent.click(aclCheckbox); + } + + // Apply button should be enabled and show count + await waitFor(() => { + const applyButton = screen.getByRole('button', { name: /Apply \(1\)/ }); + expect(applyButton).toBeTruthy(); + expect(applyButton).toHaveProperty('disabled', false); + }); + }); + + it('can select multiple ACLs', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Wait for ACL list + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + }); + + // Select multiple ACLs + const aclCheckboxes = screen.getAllByRole('checkbox'); + const adminCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Admin Only') + ); + const localCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Local Network') + ); + + if (adminCheckbox) fireEvent.click(adminCheckbox); + if (localCheckbox) fireEvent.click(localCheckbox); + + // Apply button should show count of 2 + await waitFor(() => { + expect(screen.getByRole('button', { name: /Apply \(2\)/ })).toBeTruthy(); + }); + }); + + it('applies ACL to selected hosts successfully', async () => { + vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ + updated: 2, + errors: [], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Wait for ACL list and select one + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + }); + + const aclCheckboxes = screen.getAllByRole('checkbox'); + const adminCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Admin Only') + ); + if (adminCheckbox) fireEvent.click(adminCheckbox); + + // Click Apply + await waitFor(() => { + expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ })); + + // Should call API + await waitFor(() => { + expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith( + ['host-1', 'host-2'], + 1 + ); + }); + + // Should show success toast + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Applied 1 ACL(s) to 2 host(s)'); + }); + }); + + it('shows Remove ACL confirmation when Remove is selected', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Wait for modal and find Remove ACL toggle (it's a button with flex-1 class) + await waitFor(() => { + expect(screen.getByText('Apply Access List')).toBeTruthy(); + }); + + // Find the toggle button by looking for flex-1 class + const buttons = screen.getAllByRole('button'); + const removeToggle = buttons.find(btn => + btn.textContent === 'Remove ACL' && btn.className.includes('flex-1') + ); + expect(removeToggle).toBeTruthy(); + if (removeToggle) fireEvent.click(removeToggle); + + // Should show warning message + await waitFor(() => { + expect(screen.getByText(/will become publicly accessible/i)).toBeTruthy(); + }); + }); + + it('closes modal on Cancel', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Modal should open + await waitFor(() => { + expect(screen.getByText('Apply Access List')).toBeTruthy(); + }); + + // Click Cancel + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('Apply Access List')).toBeNull(); + }); + }); + + it('clears selection and closes modal after successful apply', async () => { + vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ + updated: 2, + errors: [], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Select ACL and apply + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + }); + + const aclCheckboxes = screen.getAllByRole('checkbox'); + const adminCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Admin Only') + ); + if (adminCheckbox) fireEvent.click(adminCheckbox); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ })); + + // Wait for completion + await waitFor(() => { + expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalled(); + }); + + // Modal should close + await waitFor(() => { + expect(screen.queryByText('Apply Access List')).toBeNull(); + }); + + // Selection should be cleared (Manage ACL button should be gone) + await waitFor(() => { + expect(screen.queryByText('Manage ACL')).toBeNull(); + }); + }); + + it('shows error toast on API failure', async () => { + vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ + updated: 1, + errors: [{ uuid: 'host-2', error: 'Failed' }], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Test Host 1')).toBeTruthy(); + }); + + // Select hosts and open modal + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(screen.getByText('Manage ACL')).toBeTruthy(); + }); + fireEvent.click(screen.getByText('Manage ACL')); + + // Select ACL and apply + await waitFor(() => { + expect(screen.getByText('Admin Only')).toBeTruthy(); + }); + + const aclCheckboxes = screen.getAllByRole('checkbox'); + const adminCheckbox = aclCheckboxes.find(cb => + cb.closest('label')?.textContent?.includes('Admin Only') + ); + if (adminCheckbox) fireEvent.click(adminCheckbox); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Apply \(1\)/ })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole('button', { name: /Apply \(1\)/ })); + + // Should show error toast + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Applied 1 ACL(s) with some errors'); + }); + }); +});