mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
fix(fleet): expose Community cordon on NodeCard (#1646)
Drop the stale isPaid conjunct so desktop cordon matches backend and mobile permission-only gating.
This commit is contained in:
@@ -22,7 +22,6 @@ import { apiFetch } from '@/lib/api';
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { formatVersion } from '@/lib/version';
|
import { formatVersion } from '@/lib/version';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
|
||||||
import { useNodes, type Node } from '@/context/NodeContext';
|
import { useNodes, type Node } from '@/context/NodeContext';
|
||||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||||
@@ -77,15 +76,13 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
|||||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||||
|
|
||||||
const { isAdmin, can } = useAuth();
|
const { isAdmin, can } = useAuth();
|
||||||
const { isPaid } = useLicense();
|
|
||||||
const { nodes: registryNodes } = useNodes();
|
const { nodes: registryNodes } = useNodes();
|
||||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||||
// Cordon requires the paid tier AND node:manage, matching the backend guard
|
// Cordon is permission-gated only (node:manage), matching the backend route guard.
|
||||||
// (requirePermission('node:manage','node',id) + requirePaid).
|
const canCordon = can('node:manage', 'node', String(node.id));
|
||||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
|
||||||
const nodeMuteActions = useNodeMuteActions(
|
const nodeMuteActions = useNodeMuteActions(
|
||||||
node.id,
|
node.id,
|
||||||
node.name,
|
node.name,
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const useLicenseMock = vi.fn();
|
|
||||||
const useAuthMock = vi.fn();
|
const useAuthMock = vi.fn();
|
||||||
const useNodesMock = vi.fn();
|
const useNodesMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
|
|
||||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
||||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||||
@@ -36,7 +34,6 @@ function baseProps(node: FleetNode) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
|
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
|
||||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
|
||||||
});
|
});
|
||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -55,31 +52,9 @@ describe('NodeCard', () => {
|
|||||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the actions menu for a free-tier user', () => {
|
it('exposes the actions menu and Cordon node for a user with node:manage', async () => {
|
||||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
|
||||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
|
||||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the actions menu for a Community admin with node:manage when cordon requires Admiral', () => {
|
|
||||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
|
||||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
|
||||||
// Cordon is Admiral-only; without edit/delete props, no menu items are available to Community.
|
|
||||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
|
|
||||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
|
||||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
|
||||||
// With no edit/delete affordances wired, the menu renders iff cordon is
|
|
||||||
// allowed: isPaid && can('node:manage'). The admin's can() returns true.
|
|
||||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the cordon control for an Admiral node-admin via the node:manage permission', async () => {
|
|
||||||
const can = vi.fn((action: string) => action === 'node:manage');
|
const can = vi.fn((action: string) => action === 'node:manage');
|
||||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
|
||||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||||
@@ -87,18 +62,15 @@ describe('NodeCard', () => {
|
|||||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the cordon control from a paid user lacking node:manage', () => {
|
it('hides the cordon control from a user lacking node:manage', () => {
|
||||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
|
||||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||||
// The paid tier alone must not surface cordon to a deployer/viewer/auditor.
|
|
||||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Uncordon when the node is already cordoned', async () => {
|
it('shows Uncordon when the node is already cordoned', async () => {
|
||||||
const can = vi.fn((action: string) => action === 'node:manage');
|
const can = vi.fn((action: string) => action === 'node:manage');
|
||||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
|
||||||
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||||
@@ -111,13 +83,13 @@ describe('NodeCard', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('renders the update button for an admin when an update is available', () => {
|
it('renders the update button for an admin when an update is available', () => {
|
||||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||||
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the update button and shows Pinned when updateBlocked', () => {
|
it('hides the update button and shows Pinned when updateBlocked', () => {
|
||||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||||
render(
|
render(
|
||||||
<NodeCard
|
<NodeCard
|
||||||
{...baseProps(onlineNode())}
|
{...baseProps(onlineNode())}
|
||||||
|
|||||||
Reference in New Issue
Block a user