mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys (#1248)
* fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys A blocked deploy only opened the policy dialog from the editor deploy button. The update action and the sidebar context-menu deploy/update fell through to a generic error toast, so an admin could not review the violations or bypass the block from those entry points. Route the 409 policy response through a shared handler on all three paths and make the "Deploy anyway" bypass retry the originating action (deploy or update) so an update bypass still re-pulls images. Also: - Correct the "Block on deploy" policy-editor helper text, which described post-deploy alerting rather than the pre-flight rejection it actually performs. - Dispatch the documented scan_finding warning (policy name and the offending images) when a scheduled auto-update or auto-start is blocked, instead of recording an opaque failure. - Add a standard log line when the gate blocks a deploy, plus developer-mode diagnostics for the matched policy and per-image severity decision. - Fix deploy-enforcement docs: complete the enforced entry-point list, correct the policy-precedence wording, and remove inaccurate tier and audit-actor claims. * fix(deploy-enforcement): surface policy block on rollback and name images in remote auto-update alert Addresses two gaps found in independent review: - Rollback is a policy-gated deploy path (it restores the saved files then re-runs the gate before redeploying), but the frontend treated a blocked rollback as a generic error toast. Route the 409 through the same handler as deploy and update so the block dialog opens, and let an admin "Deploy anyway" retry the rollback with the bypass flag (the rollback route already honors it). - The remote auto-update path dispatched its policy-block warning without the offending image refs, unlike the local scheduler. Append the images so the alert matches the documented contract on every node. Also list rollback as an enforced entry point in the docs and clarify that Git Source enforcement covers both the create-time deploy and a manual apply-with-deploy.
This commit is contained in:
@@ -66,26 +66,28 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
|
||||
return { ...base, ...over } as unknown as StackListState;
|
||||
}
|
||||
|
||||
function makeOverlay(): OverlayState {
|
||||
function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
|
||||
return {
|
||||
setPendingUnsavedLoad: vi.fn(),
|
||||
setPendingUnsavedNode: vi.fn(),
|
||||
pendingUnsavedLoad: null,
|
||||
pendingUnsavedNode: null,
|
||||
policyBlock: null,
|
||||
setPolicyBlock: vi.fn(),
|
||||
setPolicyBypassing: vi.fn(),
|
||||
setDiffPreview: vi.fn(),
|
||||
...over,
|
||||
} as unknown as OverlayState;
|
||||
}
|
||||
|
||||
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
|
||||
run(Promise.resolve(), 'test-session');
|
||||
|
||||
function setup(over: { editorState?: Partial<EditorState> } = {}) {
|
||||
function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<OverlayState> } = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
const stackListState = makeStackListState();
|
||||
const navState = { setActiveView: vi.fn() } as unknown as NavState;
|
||||
const overlayState = makeOverlay();
|
||||
const overlayState = makeOverlay(over.overlay);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStackActions({
|
||||
@@ -177,3 +179,126 @@ describe('useStackActions.handleSaveAndDeploy', () => {
|
||||
expect(calls.some(c => String(c).includes('/deploy'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions policy-block dialog wiring', () => {
|
||||
const policyPayload = {
|
||||
error: 'Policy "block-high" blocked deploy: 1 image(s) exceed HIGH',
|
||||
policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' },
|
||||
violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 5, scanId: 9 }],
|
||||
};
|
||||
const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
it('opens the dialog with action "deploy" when an editor deploy is blocked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.deployStack(mouseEvent);
|
||||
expect(overlayState.setPolicyBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the dialog with action "update" when an update is blocked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.updateStack(mouseEvent);
|
||||
expect(overlayState.setPolicyBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the dialog with action "deploy" when a sidebar deploy is blocked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.executeStackActionByFile('web.yml', 'deploy', 'deploy');
|
||||
expect(overlayState.setPolicyBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not open the dialog for a stack-op-in-progress 409', async () => {
|
||||
const inProgress = JSON.stringify({
|
||||
code: 'stack_op_in_progress',
|
||||
inProgress: { action: 'deploy', startedAt: Date.now(), user: 'someone' },
|
||||
});
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(inProgress, { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.updateStack(mouseEvent);
|
||||
expect(overlayState.setPolicyBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the dialog with action "update" via the sidebar update entry point', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.executeStackActionByFile('web.yml', 'update', 'update');
|
||||
expect(overlayState.setPolicyBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the dialog with action "rollback" when a rollback is blocked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 }));
|
||||
const { result, overlayState } = setup();
|
||||
await result.current.rollbackStack();
|
||||
expect(overlayState.setPolicyBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'rollback' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions.bypassPolicyAndRetry', () => {
|
||||
const payload = {
|
||||
error: 'blocked',
|
||||
policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' },
|
||||
violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 1, highCount: 0, scanId: 1 }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
it('retries an update bypass against the update endpoint with ?ignorePolicy=true', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // update OK
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh
|
||||
const { result } = setup({
|
||||
overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'update', payload } as never },
|
||||
});
|
||||
await result.current.bypassPolicyAndRetry();
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls).toContain('/stacks/web/update?ignorePolicy=true');
|
||||
expect(urls.some(u => u.includes('/deploy'))).toBe(false);
|
||||
});
|
||||
|
||||
it('retries a deploy bypass against the deploy endpoint with ?ignorePolicy=true', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // deploy OK
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh
|
||||
const { result } = setup({
|
||||
overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'deploy', payload } as never },
|
||||
});
|
||||
await result.current.bypassPolicyAndRetry();
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls).toContain('/stacks/web/deploy?ignorePolicy=true');
|
||||
expect(urls.some(u => u.includes('/update'))).toBe(false);
|
||||
});
|
||||
|
||||
it('retries a rollback bypass against the rollback endpoint with ?ignorePolicy=true', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // rollback OK
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response('content', { status: 200 })); // content reload
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ exists: true }), { status: 200 })); // backup info
|
||||
const { result } = setup({
|
||||
overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'rollback', payload } as never },
|
||||
});
|
||||
await result.current.bypassPolicyAndRetry();
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls).toContain('/stacks/web.yml/rollback?ignorePolicy=true');
|
||||
});
|
||||
|
||||
it('does nothing when no policy block is stored', async () => {
|
||||
const { result } = setup({ overlay: { policyBlock: null as never } });
|
||||
await result.current.bypassPolicyAndRetry();
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user