mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
fix: require node:read for fleet topology reads and hide Fleet without it (#1507)
The fleet overview, configuration, dependency-map, and networking-summary reads were authentication-only, so a role without node:read (deployer) could read node names, host stats, and cross-node topology. They now require node:read, matching the role model where every role except deployer holds it. For parity, the Fleet nav entry is gated on node:read (hiding it from the top nav, mobile menu, and command palette), the Fleet view redirects to the dashboard when reached without it, and the dashboard fleet heartbeat falls back to the single-node restart map for a role that cannot read fleet data.
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { FleetHeartbeat } from './FleetHeartbeat';
|
||||
import { StackRestartMap } from './StackRestartMap';
|
||||
|
||||
export function DashboardActivityCard() {
|
||||
const { nodes } = useNodes();
|
||||
const { can } = useAuth();
|
||||
const hasRemoteNodes = nodes.some(n => n.type === 'remote');
|
||||
|
||||
if (hasRemoteNodes) {
|
||||
// The fleet heartbeat reads /fleet/overview, which is gated on node:read; a
|
||||
// role without it (deployer) gets the single-node restart map instead so the
|
||||
// card never shows a fleet view it cannot load.
|
||||
if (hasRemoteNodes && can('node:read')) {
|
||||
return <FleetHeartbeat />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import * as AuthContext from '@/context/AuthContext';
|
||||
import { DashboardActivityCard } from '../DashboardActivityCard';
|
||||
|
||||
vi.mock('@/context/NodeContext');
|
||||
vi.mock('@/context/AuthContext');
|
||||
vi.mock('../FleetHeartbeat', () => ({ FleetHeartbeat: () => <div data-testid="fleet-heartbeat" /> }));
|
||||
vi.mock('../StackRestartMap', () => ({ StackRestartMap: () => <div data-testid="stack-restart-map" /> }));
|
||||
|
||||
function setup(opts: { remote: boolean; nodeRead: boolean }) {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
nodes: opts.remote ? [{ type: 'remote' }, { type: 'local' }] : [{ type: 'local' }],
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
can: (p: string) => opts.nodeRead && p === 'node:read',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
}
|
||||
|
||||
describe('DashboardActivityCard', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows the fleet heartbeat for a multi-node fleet when the user has node:read', () => {
|
||||
setup({ remote: true, nodeRead: true });
|
||||
render(<DashboardActivityCard />);
|
||||
expect(screen.getByTestId('fleet-heartbeat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the restart map for a multi-node fleet without node:read (deployer)', () => {
|
||||
setup({ remote: true, nodeRead: false });
|
||||
render(<DashboardActivityCard />);
|
||||
expect(screen.getByTestId('stack-restart-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('fleet-heartbeat')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the restart map for a single-node setup regardless of node:read', () => {
|
||||
setup({ remote: false, nodeRead: true });
|
||||
render(<DashboardActivityCard />);
|
||||
expect(screen.getByTestId('stack-restart-map')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user