mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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 { formatVersion } from '@/lib/version';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -77,15 +76,13 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon requires the paid tier AND node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid).
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
// Cordon is permission-gated only (node:manage), matching the backend route guard.
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
|
||||
@@ -2,11 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const useLicenseMock = vi.fn();
|
||||
const useAuthMock = vi.fn();
|
||||
const useNodesMock = vi.fn();
|
||||
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
@@ -36,7 +34,6 @@ function baseProps(node: FleetNode) {
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -55,31 +52,9 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a free-tier user', () => {
|
||||
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 () => {
|
||||
it('exposes the actions menu and Cordon node for a user with node:manage', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
@@ -87,18 +62,15 @@ describe('NodeCard', () => {
|
||||
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) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows Uncordon when the node is already cordoned', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
||||
|
||||
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', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the update button and shows Pinned when updateBlocked', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
render(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
|
||||
Reference in New Issue
Block a user