fix(blueprints): gate Federation pin control on admin role (#1252)

* fix(blueprints): gate Federation pin control on admin role

The Federation tab rendered an editable pin control to any Admiral-tier user, but
PUT /api/blueprints/:id/pin requires admin role, so a non-admin Admiral user saw a
dropdown that returned 403 on use. Thread the admin flag into FederationTab and render
the pin placement read-only (with an administrator-required hint) for non-admins,
matching the existing canEdit pattern in the Deployments tab. The backend guard already
enforced admin; this aligns the UI affordance with it.

Add backend coverage for the tier/role authorization matrix across the blueprint routes,
remote-node deploy/withdraw ordering and failure mapping, edge cases (disable-with-active
409, selector cap, marker drift, cross-blueprint withdraw refusal), service developer-mode
diagnostics, and a frontend render-gate test for both admin and non-admin states.

* fix(blueprints): gate Apply action on admin role in blueprint detail

The blueprint detail sheet rendered an enabled "Apply now" control to any paid user,
but POST /api/blueprints/:id/apply requires admin. Gate the primary action on canEdit
so it matches the already-gated Edit / Disable / Delete actions and the backend guard;
non-admins keep a read-only detail view. Add a render test covering both the admin and
non-admin action bars.

Also strengthen the remote-deploy ordering test to assert global call order across spies
(create < compose < marker < deploy) via invocationCallOrder, not just per-method indices.
This commit is contained in:
Anso
2026-05-29 15:12:48 -04:00
committed by GitHub
parent eed7e04e71
commit d41282e352
8 changed files with 828 additions and 21 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{isAdmiral && (
<TabsContent value="federation">
<AdmiralGate>
<FederationTab />
<FederationTab canManage={isAdmin} />
</AdmiralGate>
</TabsContent>
)}
@@ -0,0 +1,84 @@
/**
* Render-gate coverage for BlueprintDetail's action bar.
*
* The Apply / Edit / Disable / Delete actions all hit admin-only routes
* (e.g. POST /api/blueprints/:id/apply requires admin). This locks the UI gate:
* an admin (canEdit) sees the action affordances; a non-admin viewer sees none
* of them, so the sheet can never issue a request the API answers with 403.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { BlueprintSummary } from '@/lib/blueprintsApi';
vi.mock('@/lib/blueprintsApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/blueprintsApi')>();
return { ...actual, getBlueprint: vi.fn(), applyBlueprint: vi.fn() };
});
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ nodes: [] }) }));
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() },
}));
vi.mock('./BlueprintDeploymentTable', () => ({
BlueprintDeploymentTable: () => <div data-testid="deployment-table" />,
}));
import { getBlueprint } from '@/lib/blueprintsApi';
import { BlueprintDetail } from './BlueprintDetail';
function summary(): BlueprintSummary {
return {
blueprint: {
id: 1,
name: 'web-blueprint',
description: null,
compose_content: 'services:\n web:\n image: nginx\n',
selector: { type: 'labels', any: ['prod'], all: [] },
drift_mode: 'suggest',
classification: 'stateless',
classification_reasons: [],
enabled: true,
revision: 1,
created_at: 0,
updated_at: 0,
created_by: 'admin',
pinned_node_id: null,
},
deployments: [],
statusCounts: {},
};
}
const noop = () => {};
beforeEach(() => {
vi.mocked(getBlueprint).mockResolvedValue(summary());
});
describe('BlueprintDetail action gating', () => {
it('shows the Apply / Edit / Delete actions for an admin (canEdit)', async () => {
render(
<BlueprintDetail blueprintId={1} open onOpenChange={noop} onChanged={noop} canEdit distinctLabels={[]} />,
);
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /apply now/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
});
it('hides every mutating action for a non-admin (read-only)', async () => {
render(
<BlueprintDetail blueprintId={1} open onOpenChange={noop} onChanged={noop} canEdit={false} distinctLabels={[]} />,
);
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /apply now/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument();
// The detail is still viewable: the compose source and deployment table render.
expect(screen.getByTestId('deployment-table')).toBeInTheDocument();
});
});
@@ -229,7 +229,7 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
crumb={['Blueprints', blueprint?.name ?? '…']}
name={blueprint?.name ?? <Skeleton className="h-7 w-40 inline-block" />}
meta={meta}
primaryAction={blueprint ? {
primaryAction={blueprint && canEdit ? {
label: 'Apply now',
icon: Play,
onClick: handleApply,
@@ -0,0 +1,94 @@
/**
* Render-gate coverage for FederationTab's pin control.
*
* Pinning a blueprint to a node is admin-only on the backend
* (PUT /api/blueprints/:id/pin requires admin). This test locks the matching UI
* gate: an admin sees an editable Select, a non-admin sees the placement
* read-only with an explanatory hint. Without this the affordance can drift
* back to rendering an enabled control that the API rejects with 403.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { BlueprintListItem } from '@/lib/blueprintsApi';
import type { NodeRecord } from '@/lib/nodesApi';
vi.mock('@/lib/blueprintsApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/blueprintsApi')>();
return { ...actual, listBlueprints: vi.fn(), pinBlueprint: vi.fn() };
});
vi.mock('@/lib/nodesApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nodesApi')>();
return { ...actual, listNodes: 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 { listBlueprints, pinBlueprint } from '@/lib/blueprintsApi';
import { listNodes } from '@/lib/nodesApi';
import { FederationTab } from './FederationTab';
function node(id: number, name: string, overrides: Partial<NodeRecord> = {}): NodeRecord {
return { id, name, type: 'local', status: 'online', cordoned: false, cordoned_at: null, cordoned_reason: null, ...overrides };
}
function blueprint(overrides: Partial<BlueprintListItem> = {}): BlueprintListItem {
return {
id: 1,
name: 'web-blueprint',
description: 'edge web tier',
compose_content: 'services:\n web:\n image: nginx\n',
selector: { type: 'labels', any: ['prod'], all: [] },
drift_mode: 'suggest',
classification: 'stateless',
classification_reasons: [],
enabled: true,
revision: 1,
created_at: 0,
updated_at: 0,
created_by: 'admin',
pinned_node_id: null,
deploymentCounts: {},
deploymentTotal: 0,
...overrides,
};
}
beforeEach(() => {
vi.mocked(listNodes).mockResolvedValue([node(1, 'node-alpha')]);
vi.mocked(listBlueprints).mockResolvedValue([blueprint()]);
});
describe('FederationTab pin gating', () => {
it('renders an editable pin control for an admin', async () => {
render(<FederationTab canManage={true} />);
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.queryByText(/Pin changes require an administrator/i)).not.toBeInTheDocument();
});
it('renders the pin placement read-only for a non-admin', async () => {
render(<FederationTab canManage={false} />);
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getByText(/Pin changes require an administrator/i)).toBeInTheDocument();
expect(screen.getByText('(unpinned)')).toBeInTheDocument();
// The read-only branch must never be able to issue the admin-only pin request.
expect(vi.mocked(pinBlueprint)).not.toHaveBeenCalled();
});
it('shows the pinned node name read-only for a non-admin when a pin exists', async () => {
vi.mocked(listBlueprints).mockResolvedValue([blueprint({ pinned_node_id: 1 })]);
render(<FederationTab canManage={false} />);
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
// The pinned node name renders in both the read-only "Pinned to" cell and the
// "Effective" column, so getAllByText (not getByText) is required.
expect(screen.getAllByText('node-alpha').length).toBeGreaterThan(0);
});
});
+32 -19
View File
@@ -24,7 +24,13 @@ function formatTimestamp(ms: number | null): string {
return date.toLocaleString();
}
export function FederationTab() {
interface FederationTabProps {
/** Whether the current user may change pin placement. Pinning is admin-only on the backend
* (PUT /api/blueprints/:id/pin requires admin); non-admins see the placement read-only. */
canManage: boolean;
}
export function FederationTab({ canManage }: FederationTabProps) {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -150,6 +156,7 @@ export function FederationTab() {
<h3 className="text-sm font-medium">Pin policy</h3>
<span className="text-xs text-muted-foreground">
Force a blueprint onto a specific node, overriding its selector.
{!canManage && ' Pin changes require an administrator.'}
</span>
</div>
<div className="p-4">
@@ -188,24 +195,30 @@ export function FederationTab() {
{describeSelector(bp.selector)}
</td>
<td className="py-2 pr-4 align-top">
<Select
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
onValueChange={(value) => void handlePinChange(bp.id, value)}
disabled={savingId === bp.id}
>
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder="(unpinned)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
{nodes.map(node => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name}
{node.cordoned ? ' · cordoned' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{canManage ? (
<Select
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
onValueChange={(value) => void handlePinChange(bp.id, value)}
disabled={savingId === bp.id}
>
<SelectTrigger className="h-8 w-56">
<SelectValue placeholder="(unpinned)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
{nodes.map(node => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name}
{node.cordoned ? ' · cordoned' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={pinnedName ? 'text-sm' : 'text-xs text-muted-foreground'}>
{pinnedName ?? '(unpinned)'}
</span>
)}
</td>
<td className="py-2 pr-4 align-top text-xs text-muted-foreground">
{effective}