fix(mesh): hide node and stack management controls from non-admins (#1284)

* fix(mesh): hide node and stack management controls from non-admins

The Routing tab rendered the per-node mesh enable/disable toggle and the
stack opt-in/opt-out controls for any Admiral-tier user, but those backend
routes require the admin role. A non-admin viewer on an Admiral instance
saw controls that returned 403.

Thread a canManage flag (true only for admins) from the Fleet view into
the Routing tab, its node cards, and the opt-in sheet so non-admins get a
read-only Routing tab: the enable/disable toggle, add-stack, and
opt-in/opt-out controls are hidden, while status, aliases, topology,
activity, diagnostics, and the alias test probe stay available. This
mirrors the Federation tab's existing read-only treatment for non-admins.

Add backend route-gating tests covering the tier and admin-role guards on
every mesh route, and frontend render-gate tests for the node card and the
opt-in sheet in both density layouts.

* refactor(mesh): require canManage on the routing-node-card primitive

Remove the permissive `canManage = true` default on the shared
routing-node-card primitive so a new call site cannot render the
management controls without an explicit decision. Every current caller
already passes the flag; the type now enforces it. Drop the omitted-prop
test, which covered a state the compiler now prevents.
This commit is contained in:
Anso
2026-06-02 16:09:25 -04:00
committed by GitHub
parent 02f98ab90a
commit c82a39c65a
8 changed files with 417 additions and 34 deletions
@@ -0,0 +1,73 @@
/**
* Render-gate coverage for MeshOptInSheet's opt-in/out controls.
*
* Opting a stack in or out is admin-only on the backend
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
* test locks the matching UI gate: a manager sees Add/Remove buttons, a
* non-manager sees the membership read-only with a hint and still gets the
* read-only topology affordance. The read-only branch must never issue the
* admin-only mutation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { MeshStackEntry } from '@/types/mesh';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
import { apiFetch } from '@/lib/api';
import { MeshOptInSheet } from './MeshOptInSheet';
const STACKS: MeshStackEntry[] = [
{ name: 'web', optedIn: true },
{ name: 'db', optedIn: false },
];
beforeEach(() => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ stacks: STACKS }),
} as unknown as Response);
});
function renderSheet(canManage: boolean) {
return render(
<MeshOptInSheet
open={true}
onOpenChange={() => {}}
nodeId={1}
nodeName="node-alpha"
onChanged={() => {}}
onViewTopology={() => {}}
canManage={canManage}
/>,
);
}
describe('MeshOptInSheet canManage gate', () => {
it('shows opt-in/out controls for a manager', async () => {
renderSheet(true);
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument();
expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
});
it('renders the membership read-only for a non-manager', async () => {
renderSheet(false);
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
// Topology is read-only and stays available for opted-in stacks.
expect(screen.getByRole('button', { name: /View topology for web/i })).toBeInTheDocument();
// The read-only branch must never issue an opt-in/opt-out request.
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
expect.stringContaining('/opt-'),
expect.anything(),
);
});
});