mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user