mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user