mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user