feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569)

* feat: block self-stack lifecycle ops with UI and preflight guardrails

Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project.

Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions.

Add SelfStackProtectedDialog and self-managed-stack preflight warning.

Closes #1564

* fix: add missing stackSelfFlags mock to useSidebarContextMenu test

The production hook now reads stackListState.stackSelfFlags[file], but the
test mock did not include it, causing 6 tests to fail with TypeError:
Cannot read properties of undefined (reading 'web.yml').

* fix: harden self-stack protection during startup

Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR.

Align status decoration and route guards on Docker label fallback detection.

Block rollback and service-level stop on the protected self stack.

* fix: add self_stack_location to diagnostics-route expected check IDs
This commit is contained in:
Anso
2026-07-06 02:08:16 -04:00
committed by GitHub
parent f30a65ee08
commit 0f9925e04f
30 changed files with 905 additions and 31 deletions
@@ -56,6 +56,7 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
files: ['web.yml'],
stackStatuses: { 'web.yml': 'running' },
stackPorts: {},
stackSelfFlags: {},
setSelectedFile: vi.fn(),
setOptimisticStatus: vi.fn(),
setStackAction: vi.fn(),
@@ -88,6 +89,7 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
setUpdateReadiness: vi.fn(),
preDeployAdvisory: null,
setPreDeployAdvisory: vi.fn(),
openSelfStackProtected: vi.fn(),
setDiffPreview: vi.fn(),
...over,
} as unknown as OverlayState;
@@ -876,6 +878,72 @@ describe('useStackActions.getStackMenuVisibility', () => {
showDeploy: true, showStop: false, showRestart: false, showUpdate: false,
});
});
it('hides guarded lifecycle actions for the self stack but keeps restart', () => {
const { result } = setup({
stackList: {
stackStatuses: { 'sencho.yml': 'running' } as never,
stackSelfFlags: { 'sencho.yml': true },
},
});
expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({
showDeploy: false, showStop: false, showRestart: true, showUpdate: false,
});
});
it('opens the self-stack modal instead of calling update on a protected stack', async () => {
const { result, overlayState, stackListState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.updateStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.setStackAction).not.toHaveBeenCalled();
});
it('opens the self-stack modal instead of calling rollback on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState, stackListState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.rollbackStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.setStackAction).not.toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('opens the self-stack modal for rollback 409 fallback', async () => {
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 }));
const { result, overlayState, stackListState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('opens the self-stack modal instead of calling service stop on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState } = setup({
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.serviceAction('stop', 'web'); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('opens the self-stack modal for service stop 409 fallback', async () => {
vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 }));
const { result, overlayState } = setup();
await act(async () => { await result.current.serviceAction('stop', 'web'); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
});
});
describe('useStackActions.openStackApp', () => {