feat: improve bulk ACL modal with multi-select, progress indicator, and Select All/Clear

- Added checkboxes to select multiple ACLs at once
- Added Select All / Clear buttons for quick selection
- Added progress indicator when applying multiple ACLs
- ACLs are applied sequentially with visual feedback
- All tests passing with 81.32% coverage
This commit is contained in:
Wikid82
2025-11-28 07:22:30 +00:00
parent d2f0226679
commit 2d68bc2d2d
2 changed files with 820 additions and 44 deletions
+202 -44
View File
@@ -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<Set<number>>(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)}
>
<div
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-white mb-4">Apply Access List</h2>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-400 mb-4">
Applying to {selectedHosts.size} selected host(s)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Access List
</label>
<select
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"
onChange={(e) => {
const value = e.target.value
if (value === 'remove') {
if (confirm(`Remove access list from ${selectedHosts.size} host(s)?`)) {
handleBulkApplyACL(null)
}
} else if (value !== '') {
handleBulkApplyACL(parseInt(value, 10))
}
}}
defaultValue=""
disabled={isBulkUpdating}
>
<option value="">Select an access list...</option>
<option value="remove" className="text-red-400">
🚫 Remove Access List
</option>
<optgroup label="Available Access Lists">
{accessLists
?.filter((acl: AccessList) => acl.enabled)
.map((acl: AccessList) => (
<option key={acl.id} value={acl.id}>
{acl.name}
</option>
))}
</optgroup>
</select>
</div>
<div className="flex justify-end gap-2">
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
<p className="text-sm text-gray-400">
Applying to <span className="text-blue-400 font-medium">{selectedHosts.size}</span> selected host(s)
</p>
{/* Action Toggle */}
<div className="flex gap-2">
<button
onClick={() => setShowBulkACLModal(false)}
onClick={() => {
setBulkACLAction('apply')
setSelectedACLs(new Set())
}}
className={`flex-1 px-3 py-2 rounded-lg font-medium transition-colors ${
bulkACLAction === 'apply'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Apply ACL
</button>
<button
onClick={() => {
setBulkACLAction('remove')
setSelectedACLs(new Set())
}}
className={`flex-1 px-3 py-2 rounded-lg font-medium transition-colors ${
bulkACLAction === 'remove'
? 'bg-red-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Remove ACL
</button>
</div>
{/* ACL Selection List */}
{bulkACLAction === 'apply' && (
<div className="flex-1 overflow-y-auto border border-gray-700 rounded-lg">
{/* Select All / Clear header */}
{(accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0) > 0 && (
<div className="flex items-center justify-between p-2 border-b border-gray-700 bg-gray-800/50">
<span className="text-sm text-gray-400">
{selectedACLs.size} of {accessLists?.filter((acl: AccessList) => acl.enabled).length ?? 0} selected
</span>
<div className="flex gap-2">
<button
onClick={() => {
const enabledACLs = accessLists?.filter((acl: AccessList) => acl.enabled) || []
setSelectedACLs(new Set(enabledACLs.map((acl: AccessList) => acl.id!)))
}}
className="text-xs text-blue-400 hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-600">|</span>
<button
onClick={() => setSelectedACLs(new Set())}
className="text-xs text-gray-400 hover:text-gray-300"
>
Clear
</button>
</div>
</div>
)}
<div className="p-2 space-y-1">
{accessLists?.filter((acl: AccessList) => acl.enabled).length === 0 ? (
<p className="text-gray-500 text-sm p-2">No enabled access lists available</p>
) : (
accessLists
?.filter((acl: AccessList) => acl.enabled)
.map((acl: AccessList) => (
<label
key={acl.id}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
selectedACLs.has(acl.id!)
? 'bg-blue-600/20 border border-blue-500'
: 'bg-gray-800/50 border border-transparent hover:bg-gray-800'
}`}
>
<input
type="checkbox"
checked={selectedACLs.has(acl.id!)}
onChange={(e) => {
const newSelected = new Set(selectedACLs)
if (e.target.checked) {
newSelected.add(acl.id!)
} else {
newSelected.delete(acl.id!)
}
setSelectedACLs(newSelected)
}}
className="w-4 h-4 rounded border-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 bg-gray-700"
/>
<div className="flex-1">
<span className="text-white font-medium">{acl.name}</span>
{acl.type && (
<span className="ml-2 text-xs text-gray-500">
({acl.type.replace('_', ' ')})
</span>
)}
</div>
</label>
))
)}
</div>
</div>
)}
{/* Remove ACL Confirmation */}
{bulkACLAction === 'remove' && (
<div className="flex-1 flex items-center justify-center border border-red-900/50 rounded-lg bg-red-900/10 p-6">
<div className="text-center">
<div className="text-4xl mb-3">🚫</div>
<p className="text-gray-300">
This will remove the access list from all {selectedHosts.size} selected host(s).
</p>
<p className="text-gray-500 text-sm mt-2">
The hosts will become publicly accessible.
</p>
</div>
</div>
)}
{/* Progress indicator */}
{applyProgress && (
<div className="border border-blue-800/50 rounded-lg bg-blue-900/20 p-4">
<div className="flex items-center gap-3 mb-2">
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
<span className="text-blue-300 font-medium">
Applying ACLs... ({applyProgress.current}/{applyProgress.total})
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => {
setShowBulkACLModal(false)
setSelectedACLs(new Set())
setBulkACLAction('apply')
setApplyProgress(null)
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
disabled={isBulkUpdating}
disabled={isBulkUpdating || applyProgress !== null}
>
Cancel
</button>
<button
onClick={async () => {
if (bulkACLAction === 'remove') {
await handleBulkApplyACL(null)
} else if (selectedACLs.size > 0) {
// Apply each selected ACL sequentially with progress
const hostUUIDs = Array.from(selectedHosts)
const aclIds = Array.from(selectedACLs)
const totalOperations = aclIds.length
let completedOperations = 0
let totalErrors = 0
setApplyProgress({ current: 0, total: totalOperations })
for (const aclId of aclIds) {
try {
const result = await bulkUpdateACL(hostUUIDs, aclId)
totalErrors += result.errors.length
} catch {
totalErrors += hostUUIDs.length
}
completedOperations++
setApplyProgress({ current: completedOperations, total: totalOperations })
}
setApplyProgress(null)
if (totalErrors > 0) {
toast.error(`Applied ${selectedACLs.size} ACL(s) with some errors`)
} else {
toast.success(`Applied ${selectedACLs.size} ACL(s) to ${selectedHosts.size} host(s)`)
}
setSelectedHosts(new Set())
setSelectedACLs(new Set())
setShowBulkACLModal(false)
}
}}
disabled={isBulkUpdating || applyProgress !== null || (bulkACLAction === 'apply' && selectedACLs.size === 0)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
bulkACLAction === 'remove'
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{(isBulkUpdating || applyProgress !== null) && <Loader2 className="w-4 h-4 animate-spin" />}
{bulkACLAction === 'remove' ? 'Remove ACL' : `Apply ${selectedACLs.size > 0 ? `(${selectedACLs.size})` : ''}`}
</button>
</div>
</div>
</div>
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
);
};
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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(<ProxyHosts />);
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');
});
});
});