mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
fix(nodes): gate node-management actions by role and release pilot tunnels on delete (#1280)
* fix(nodes): gate node-management actions by role and release pilot tunnels on delete The node-management write actions in the Nodes panel (add, edit, delete, generate node token, reset fleet-sync anchor) rendered for every signed-in role, but the API enforces the manage-nodes permission on them, so lower-privilege roles saw buttons that returned 403. The panel now renders each action against the same permission its route enforces; the read-only node table stays visible to every role. Deleting a node now also tears down its live pilot-agent tunnel (and any mesh bridge) immediately, releasing the loopback server, heartbeat timer, and open streams instead of leaving them until the agent next disconnects, matching the cleanup the re-enrollment path already performed. Adds backend route tests for the permission boundaries and tunnel teardown, and a Nodes panel render test covering the viewer, admin, and node-admin views. * fix(nodes): close proxy mesh bridges via the dialer on node delete to skip a redial Deleting a node closed any active mesh bridge through PilotTunnelManager, but a proxy-mode bridge is owned by the mesh dialer, whose close listener then treated the close as unexpected and scheduled a reactive redial against the node being removed. The delete handler now closes a proxy bridge through the dialer's intentional-close path first (which suppresses the redial), then closes a pilot-agent tunnel as before. Adds a backend test that primes a live proxy bridge and asserts deletion closes it without scheduling a redial.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
// NodeManager pulls in several contexts and a child action hook. Mock them so
|
||||
// the test renders the panel in isolation and can drive the role/permission
|
||||
// inputs that gate the write affordances.
|
||||
const useAuthMock = vi.fn();
|
||||
const useLicenseMock = vi.fn();
|
||||
|
||||
const testNode: Node = {
|
||||
id: 2,
|
||||
name: 'Edge',
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
status: 'online',
|
||||
created_at: 0,
|
||||
api_url: '',
|
||||
pilot_last_seen: Date.now(),
|
||||
};
|
||||
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [testNode], refreshNodeMeta: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
|
||||
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/useFleetSyncStatus', () => ({
|
||||
useFleetSyncStatus: () => ({ statuses: [], refresh: vi.fn() }),
|
||||
}));
|
||||
vi.mock('../settings/MastheadStatsContext', () => ({ useMastheadStats: vi.fn() }));
|
||||
vi.mock('../nodes/useNodeActions', () => ({
|
||||
useNodeActions: () => ({
|
||||
openCreate: vi.fn(),
|
||||
openEdit: vi.fn(),
|
||||
openDelete: vi.fn(),
|
||||
NodeActionModals: null,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../blueprints/NodeLabelPicker', () => ({ NodeLabelPicker: () => null }));
|
||||
|
||||
import { NodeManager } from '../NodeManager';
|
||||
|
||||
/** can() that grants only the named action regardless of resource scope. */
|
||||
function canFor(...granted: string[]) {
|
||||
return (action: string) => granted.includes(action);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NodeManager write-affordance gating', () => {
|
||||
it('hides every write affordance from a viewer but still shows the node table', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: canFor() });
|
||||
render(<NodeManager />);
|
||||
|
||||
// Read-only surface stays visible.
|
||||
expect(screen.getByText('Edge')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Add node/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Generate Node Token')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Edit node' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Delete node' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows every affordance to an admin', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: canFor('node:manage') });
|
||||
render(<NodeManager />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Add node/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Generate Node Token')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Edit node' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete node' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets a node-admin manage nodes but hides the admin-only token card', () => {
|
||||
// node-admin: holds node:manage but is not a global admin.
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: canFor('node:manage') });
|
||||
render(<NodeManager />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Add node/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Edit node' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete node' })).toBeInTheDocument();
|
||||
// Generate Node Token mirrors requireAdmin, so a node-admin must not see it.
|
||||
expect(screen.queryByText('Generate Node Token')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user