mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
feat(ui): add host removal action to hosts table
Add an actions menu to the hosts overview with a "Remove host from Pulse" button. Includes permission checks (requires settings:write scope), confirmation handling, and a security regression test for the delete endpoint scope enforcement.
This commit is contained in:
parent
5bd0563283
commit
d43dfbc490
6 changed files with 580 additions and 1 deletions
|
|
@ -0,0 +1,31 @@
|
|||
import { Component } from 'solid-js';
|
||||
|
||||
interface HostRemoveActionButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const HostRemoveActionButton: Component<HostRemoveActionButtonProps> = (props) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
class={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-300 dark:hover:bg-red-900/20 ${props.class ?? ''}`.trim()}
|
||||
>
|
||||
{props.loading ? (
|
||||
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="3" />
|
||||
<path class="opacity-75" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M4 12a8 8 0 018-8" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{props.loading ? 'Removing...' : 'Remove host from Pulse'}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,12 +21,16 @@ import { STORAGE_KEYS } from '@/utils/localStorage';
|
|||
import { useResourcesAsLegacy } from '@/hooks/useResources';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata';
|
||||
import { MonitoringAPI } from '@/api/monitoring';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import { hasSettingsWriteAccess } from './permissions';
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||
import { isKioskMode, subscribeToKioskMode } from '@/utils/url';
|
||||
import { HostDrawer } from './HostDrawer';
|
||||
import { HostRemoveActionButton } from './HostRemoveActionButton';
|
||||
import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
|
||||
|
|
@ -653,6 +657,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) {
|
|||
}
|
||||
|
||||
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime';
|
||||
|
||||
export const HostsOverview: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const wsContext = useWebSocket();
|
||||
|
|
@ -695,6 +700,10 @@ export const HostsOverview: Component = () => {
|
|||
// Host metadata management (for custom URLs)
|
||||
const [hostMetadata, setHostMetadata] = createSignal<Record<string, HostMetadata>>({});
|
||||
const [hostMetadataVersion, setHostMetadataVersion] = createSignal(0);
|
||||
const [canRemoveHostAgents, setCanRemoveHostAgents] = createSignal(false);
|
||||
const [removingHostIds, setRemovingHostIds] = createSignal<Record<string, boolean>>({});
|
||||
const [actionsMenuHostId, setActionsMenuHostId] = createSignal<string | null>(null);
|
||||
const [actionsMenuPosition, setActionsMenuPosition] = createSignal<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Load host metadata on mount
|
||||
createEffect(() => {
|
||||
|
|
@ -708,6 +717,53 @@ export const HostsOverview: Component = () => {
|
|||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let isActive = true;
|
||||
void SecurityAPI.getStatus()
|
||||
.then((status) => {
|
||||
if (!isActive) return;
|
||||
setCanRemoveHostAgents(hasSettingsWriteAccess(status.tokenScopes));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isActive) return;
|
||||
setCanRemoveHostAgents(false);
|
||||
logger.debug('Failed to load token scopes for host removal action', { error: err });
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const clickedMenu = target.closest('[data-host-actions-menu]');
|
||||
const clickedTrigger = target.closest('[data-host-actions-trigger]');
|
||||
if (!clickedMenu && !clickedTrigger) {
|
||||
setActionsMenuHostId(null);
|
||||
setActionsMenuPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setActionsMenuHostId(null);
|
||||
setActionsMenuPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleMenuEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleMenuEscape);
|
||||
};
|
||||
});
|
||||
|
||||
// Get custom URL for a host
|
||||
const getHostCustomUrl = (hostId: string): string | undefined => {
|
||||
// Access version to trigger reactivity when metadata changes
|
||||
|
|
@ -750,6 +806,63 @@ export const HostsOverview: Component = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRemoveHostAgent = async (host: Host): Promise<void> => {
|
||||
setActionsMenuHostId(null);
|
||||
setActionsMenuPosition(null);
|
||||
|
||||
const hostName = host.displayName || host.hostname || host.id;
|
||||
const currentRemoving = removingHostIds();
|
||||
if (currentRemoving[host.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
`Remove "${hostName}" from Pulse?\n\nThis only removes it from the Pulse host list. It does not uninstall the agent from the server.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingHostIds(prev => ({ ...prev, [host.id]: true }));
|
||||
|
||||
try {
|
||||
await MonitoringAPI.deleteHostAgent(host.id);
|
||||
|
||||
try {
|
||||
await HostMetadataAPI.deleteMetadata(host.id);
|
||||
setHostMetadata(prev => {
|
||||
if (!prev[host.id]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[host.id];
|
||||
return next;
|
||||
});
|
||||
setHostMetadataVersion(v => v + 1);
|
||||
} catch (metaErr) {
|
||||
logger.debug('Failed to clean up host metadata after host removal', { hostId: host.id, error: metaErr });
|
||||
}
|
||||
|
||||
setExpandedHostId(prev => (prev === host.id ? null : prev));
|
||||
showSuccess(`${hostName} removed from Pulse`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to remove host';
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('settings:write') || normalized.includes('forbidden') || normalized.includes('status 403')) {
|
||||
showError('You need settings:write permission to remove hosts.');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
logger.error('Failed to remove host from Pulse', { hostId: host.id, error: err });
|
||||
} finally {
|
||||
setRemovingHostIds(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[host.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey() === key) {
|
||||
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
|
||||
|
|
@ -796,6 +909,50 @@ export const HostsOverview: Component = () => {
|
|||
|
||||
// Access asHosts() directly inside the memo to maintain reactivity
|
||||
const hosts = () => asHosts() as Host[];
|
||||
const menuHost = createMemo(() => {
|
||||
const hostID = actionsMenuHostId();
|
||||
if (!hostID) {
|
||||
return null;
|
||||
}
|
||||
return hosts().find(h => h.id === hostID) || null;
|
||||
});
|
||||
|
||||
const toggleRowActionsMenu = (hostId: string, event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (actionsMenuHostId() === hostId) {
|
||||
setActionsMenuHostId(null);
|
||||
setActionsMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuWidth = 200;
|
||||
const menuHeight = 80;
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const viewportOffsetTop = window.visualViewport?.offsetTop ?? 0;
|
||||
|
||||
const spaceBelow = viewportHeight - (rect.bottom - viewportOffsetTop);
|
||||
const top = spaceBelow >= menuHeight + 8 ? rect.bottom + 6 : rect.top - menuHeight - 6;
|
||||
const left = Math.max(8, Math.min(rect.right - menuWidth, viewportWidth - menuWidth - 8));
|
||||
|
||||
setActionsMenuHostId(hostId);
|
||||
setActionsMenuPosition({ top, left });
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const hostID = actionsMenuHostId();
|
||||
if (!hostID) {
|
||||
return;
|
||||
}
|
||||
if (!hosts().some(h => h.id === hostID)) {
|
||||
setActionsMenuHostId(null);
|
||||
setActionsMenuPosition(null);
|
||||
}
|
||||
});
|
||||
|
||||
const isInitialLoading = createMemo(() => {
|
||||
return !connected() && !reconnecting() && hosts().length === 0;
|
||||
|
|
@ -1054,7 +1211,7 @@ export const HostsOverview: Component = () => {
|
|||
<th class={thClass} title="Linux Software RAID (mdadm) Status">RAID</th>
|
||||
</Show>
|
||||
<Show when={isColVisible('link')}>
|
||||
<th class={thClass}></th>
|
||||
<th class={thClass} style={{ "width": isMobile() ? "64px" : "84px" }}></th>
|
||||
</Show>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -1072,6 +1229,11 @@ export const HostsOverview: Component = () => {
|
|||
isExpanded={expandedHostId() === host.id}
|
||||
onToggleExpand={() => toggleHostExpand(host.id)}
|
||||
totalColumns={visibleColumnIds().length}
|
||||
canRemoveHost={canRemoveHostAgents()}
|
||||
isRemovingHost={Boolean(removingHostIds()[host.id])}
|
||||
onRemoveHost={handleRemoveHostAgent}
|
||||
isActionsMenuOpen={actionsMenuHostId() === host.id}
|
||||
onToggleActionsMenu={(event) => toggleRowActionsMenu(host.id, event)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -1113,6 +1275,31 @@ export const HostsOverview: Component = () => {
|
|||
</Card>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={menuHost() && actionsMenuPosition() && canRemoveHostAgents()}>
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-host-actions-menu
|
||||
class="fixed z-[9999] w-[200px] rounded-lg border border-gray-200 bg-white p-1.5 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||
style={{
|
||||
top: `${actionsMenuPosition()!.top}px`,
|
||||
left: `${actionsMenuPosition()!.left}px`,
|
||||
}}
|
||||
>
|
||||
<HostRemoveActionButton
|
||||
loading={Boolean(menuHost() && removingHostIds()[menuHost()!.id])}
|
||||
disabled={Boolean(menuHost() && removingHostIds()[menuHost()!.id])}
|
||||
onClick={() => {
|
||||
const host = menuHost();
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
void handleRemoveHostAgent(host);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
|
@ -1129,6 +1316,11 @@ interface HostRowProps {
|
|||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
totalColumns: number;
|
||||
canRemoveHost: boolean;
|
||||
isRemovingHost: boolean;
|
||||
onRemoveHost: (host: Host) => Promise<void>;
|
||||
isActionsMenuOpen: boolean;
|
||||
onToggleActionsMenu: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const HostRow: Component<HostRowProps> = (props) => {
|
||||
|
|
@ -1413,6 +1605,19 @@ const HostRow: Component<HostRowProps> = (props) => {
|
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={props.canRemoveHost}>
|
||||
<button
|
||||
type="button"
|
||||
data-host-actions-trigger
|
||||
onClick={props.onToggleActionsMenu}
|
||||
class={`inline-flex justify-center items-center text-gray-400 transition-colors ${props.isActionsMenuOpen ? 'text-gray-700 dark:text-gray-200' : 'hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||
title="Host actions"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5h.01M12 12h.01M12 19h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, cleanup } from '@solidjs/testing-library';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import type { Host } from '@/types/api';
|
||||
import { HostsOverview } from '@/components/Hosts/HostsOverview';
|
||||
|
||||
let currentHosts: Host[] = [];
|
||||
|
||||
const deleteHostAgentMock = vi.fn();
|
||||
const getSecurityStatusMock = vi.fn();
|
||||
const getAllHostMetadataMock = vi.fn();
|
||||
const deleteHostMetadataMock = vi.fn();
|
||||
const showSuccessMock = vi.fn();
|
||||
const showErrorMock = vi.fn();
|
||||
|
||||
vi.mock('@/App', () => ({
|
||||
useWebSocket: () => ({
|
||||
connected: () => true,
|
||||
reconnecting: () => false,
|
||||
reconnect: vi.fn(),
|
||||
state: {},
|
||||
activeAlerts: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResources', () => ({
|
||||
useResourcesAsLegacy: () => ({
|
||||
asHosts: () => currentHosts,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useBreakpoint', () => ({
|
||||
useBreakpoint: () => ({
|
||||
isMobile: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useColumnVisibility', () => ({
|
||||
useColumnVisibility: (_storageKey: string, columns: unknown[]) => ({
|
||||
visibleColumns: () => columns,
|
||||
availableToggles: () => columns,
|
||||
isHiddenByUser: () => false,
|
||||
toggle: vi.fn(),
|
||||
resetToDefaults: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/alertsActivation', () => ({
|
||||
useAlertsActivation: () => ({
|
||||
getTemperatureThreshold: () => 80,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/security', () => ({
|
||||
SecurityAPI: {
|
||||
getStatus: (...args: unknown[]) => getSecurityStatusMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/monitoring', () => ({
|
||||
MonitoringAPI: {
|
||||
deleteHostAgent: (...args: unknown[]) => deleteHostAgentMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/hostMetadata', () => ({
|
||||
HostMetadataAPI: {
|
||||
getAllMetadata: (...args: unknown[]) => getAllHostMetadataMock(...args),
|
||||
deleteMetadata: (...args: unknown[]) => deleteHostMetadataMock(...args),
|
||||
updateMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/toast', () => ({
|
||||
showSuccess: (...args: unknown[]) => showSuccessMock(...args),
|
||||
showError: (...args: unknown[]) => showErrorMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/url', () => ({
|
||||
isKioskMode: () => false,
|
||||
subscribeToKioskMode: () => () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/Card', () => ({
|
||||
Card: (props: any) => <div>{props.children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/EmptyState', () => ({
|
||||
EmptyState: (props: { title?: string; description?: string }) => (
|
||||
<div>
|
||||
<div>{props.title}</div>
|
||||
<div>{props.description}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/ScrollableTable', () => ({
|
||||
ScrollableTable: (props: any) => <div>{props.children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Hosts/HostsFilter', () => ({
|
||||
HostsFilter: () => <div data-testid="hosts-filter" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Hosts/HostDrawer', () => ({
|
||||
HostDrawer: () => <div data-testid="host-drawer" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/StatusDot', () => ({
|
||||
StatusDot: () => <span data-testid="status-dot" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Dashboard/EnhancedCPUBar', () => ({
|
||||
EnhancedCPUBar: () => <div data-testid="cpu-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Dashboard/StackedMemoryBar', () => ({
|
||||
StackedMemoryBar: () => <div data-testid="memory-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Dashboard/StackedDiskBar', () => ({
|
||||
StackedDiskBar: () => <div data-testid="disk-bar" />,
|
||||
}));
|
||||
|
||||
const createHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: 'host-1',
|
||||
hostname: 'host-1.local',
|
||||
displayName: 'Host One',
|
||||
platform: 'linux',
|
||||
osName: 'Ubuntu',
|
||||
osVersion: '24.04',
|
||||
kernelVersion: '6.8.0',
|
||||
architecture: 'x86_64',
|
||||
cpuCount: 8,
|
||||
cpuUsage: 10,
|
||||
loadAverage: [0.2],
|
||||
memory: {
|
||||
total: 16 * 1024 * 1024 * 1024,
|
||||
used: 8 * 1024 * 1024 * 1024,
|
||||
free: 8 * 1024 * 1024 * 1024,
|
||||
usage: 50,
|
||||
balloon: 0,
|
||||
swapUsed: 0,
|
||||
swapTotal: 0,
|
||||
},
|
||||
disks: [],
|
||||
networkInterfaces: [],
|
||||
sensors: {
|
||||
temperatureCelsius: {},
|
||||
fanRpm: {},
|
||||
additional: {},
|
||||
},
|
||||
raid: [],
|
||||
status: 'online',
|
||||
uptimeSeconds: 12345,
|
||||
lastSeen: Date.now(),
|
||||
intervalSeconds: 30,
|
||||
agentVersion: '1.0.0',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderComponent = () =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="/" component={() => <HostsOverview />} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
describe('HostsOverview row actions menu', () => {
|
||||
beforeEach(() => {
|
||||
currentHosts = [createHost()];
|
||||
|
||||
deleteHostAgentMock.mockReset().mockResolvedValue(undefined);
|
||||
getSecurityStatusMock.mockReset().mockResolvedValue({ tokenScopes: ['settings:write'] });
|
||||
getAllHostMetadataMock.mockReset().mockResolvedValue({});
|
||||
deleteHostMetadataMock.mockReset().mockResolvedValue(undefined);
|
||||
showSuccessMock.mockReset();
|
||||
showErrorMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens and closes the row actions menu via outside click', async () => {
|
||||
renderComponent();
|
||||
|
||||
const trigger = await screen.findByTitle('Host actions');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /remove host from pulse/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /remove host from pulse/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the row actions menu when Escape is pressed', async () => {
|
||||
renderComponent();
|
||||
|
||||
const trigger = await screen.findByTitle('Host actions');
|
||||
fireEvent.click(trigger);
|
||||
expect(await screen.findByRole('button', { name: /remove host from pulse/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /remove host from pulse/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a host from the row actions menu', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
renderComponent();
|
||||
|
||||
const trigger = await screen.findByTitle('Host actions');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const removeButton = await screen.findByRole('button', { name: /remove host from pulse/i });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteHostAgentMock).toHaveBeenCalledWith('host-1');
|
||||
});
|
||||
expect(deleteHostMetadataMock).toHaveBeenCalledWith('host-1');
|
||||
await waitFor(() => {
|
||||
expect(showSuccessMock).toHaveBeenCalledWith('Host One removed from Pulse');
|
||||
});
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides row actions for read-only scoped tokens', async () => {
|
||||
getSecurityStatusMock.mockResolvedValue({ tokenScopes: ['monitoring:read'] });
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => expect(getSecurityStatusMock).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByTitle('Host actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions menu within viewport bounds near the trigger', async () => {
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: {
|
||||
width: 320,
|
||||
height: 200,
|
||||
offsetTop: 0,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const trigger = await screen.findByTitle('Host actions');
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 302,
|
||||
y: 180,
|
||||
top: 180,
|
||||
left: 302,
|
||||
bottom: 196,
|
||||
right: 318,
|
||||
width: 16,
|
||||
height: 16,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const removeButton = await screen.findByRole('button', { name: /remove host from pulse/i });
|
||||
const menu = removeButton.closest('[data-host-actions-menu]') as HTMLElement;
|
||||
|
||||
expect(menu).toBeInTheDocument();
|
||||
expect(menu.style.top).toBe('94px');
|
||||
expect(menu.style.left).toBe('112px');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { hasSettingsWriteAccess } from '@/components/Hosts/permissions';
|
||||
|
||||
describe('hasSettingsWriteAccess', () => {
|
||||
it('allows session-based auth with no scoped token', () => {
|
||||
expect(hasSettingsWriteAccess(undefined)).toBe(true);
|
||||
expect(hasSettingsWriteAccess([])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows wildcard-scoped tokens', () => {
|
||||
expect(hasSettingsWriteAccess(['*'])).toBe(true);
|
||||
expect(hasSettingsWriteAccess(['monitoring:read', '*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows tokens with explicit settings:write scope', () => {
|
||||
expect(hasSettingsWriteAccess(['settings:write'])).toBe(true);
|
||||
expect(hasSettingsWriteAccess(['monitoring:read', 'settings:write'])).toBe(true);
|
||||
});
|
||||
|
||||
it('denies tokens without settings:write', () => {
|
||||
expect(hasSettingsWriteAccess(['monitoring:read'])).toBe(false);
|
||||
expect(hasSettingsWriteAccess(['settings:read'])).toBe(false);
|
||||
expect(hasSettingsWriteAccess(['monitoring:read', 'host-agent:report'])).toBe(false);
|
||||
});
|
||||
});
|
||||
9
frontend-modern/src/components/Hosts/permissions.ts
Normal file
9
frontend-modern/src/components/Hosts/permissions.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SETTINGS_WRITE_SCOPE } from '@/constants/apiScopes';
|
||||
|
||||
export const hasSettingsWriteAccess = (tokenScopes?: string[]): boolean => {
|
||||
if (!tokenScopes || tokenScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return tokenScopes.includes('*') || tokenScopes.includes(SETTINGS_WRITE_SCOPE);
|
||||
};
|
||||
|
|
@ -510,6 +510,37 @@ func TestHostAgentManagementRequiresSettingsWriteScope(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHostAgentDeleteMissingSettingsWriteErrorContract(t *testing.T) {
|
||||
rawToken := "host-delete-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeHostManage}, nil)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/agents/host/agent-1", nil)
|
||||
req.Header.Set("X-API-Token", rawToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
RequiredScope string `json:"requiredScope"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("expected JSON error contract, decode failed: %v", err)
|
||||
}
|
||||
|
||||
if body.Error != "missing_scope" {
|
||||
t.Fatalf("expected error code %q, got %q", "missing_scope", body.Error)
|
||||
}
|
||||
if body.RequiredScope != config.ScopeSettingsWrite {
|
||||
t.Fatalf("expected required scope %q, got %q", config.ScopeSettingsWrite, body.RequiredScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestNotificationRequiresSettingsWriteScope(t *testing.T) {
|
||||
rawToken := "notify-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue