Files
sencho/backend/src/__tests__/stack-docker-disconnect.test.ts
T
Anso d113004359 feat: add confirmed Take down stack action with optional volume removal (#1599)
* feat: add confirmed Take down stack action with optional volume removal

Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.

Closes #1582

* fix: reset take-down volume checkbox when dialog closes

* test: align getStackMenuVisibility assertions with showTakeDown key

getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.

* test: cover Take down visibility for running non-self stacks

The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.

* fix: drop Take down from header overflow and wire activity shortcut

Remove duplicate Take down from More actions.

Keep inline button when running, sidebar menu, and Cmd+ArrowDown.

Record stack_taken_down in activity on successful POST /down.
2026-07-09 12:20:13 -04:00

243 lines
7.8 KiB
TypeScript

/**
* Integration tests for Docker-daemon disconnect handling on stack lifecycle
* routes. When the engine socket is unreachable, the lifecycle routes must
* return a structured 503 envelope with `code: docker_unavailable` so the UI
* can surface a "Docker is down" message instead of raw ECONNREFUSED text.
*
* Disconnect is simulated by injecting ECONNREFUSED at the Dockerode test
* seam (DockerController.getInstance(...).getContainersByStack throws) and at
* the ComposeService spawn path (deploy/down/update rejection with the same
* error shape). Both shapes flow through isDockerUnavailableError in the
* route layer.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { isDockerUnavailableError } from '../routes/stacks';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
mockStopContainer,
mockStartContainer,
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
mockStopContainer: vi.fn(),
mockStartContainer: vi.fn(),
}));
vi.mock('../services/ComposeService', async () => {
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
'../services/ComposeService',
);
return {
...actual,
ComposeService: {
...actual.ComposeService,
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
};
});
vi.mock('../services/DockerController', async () => {
const actual = await vi.importActual<typeof import('../services/DockerController')>(
'../services/DockerController',
);
return {
...actual,
default: {
...actual.default,
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
restartContainer: mockRestartContainer,
stopContainer: mockStopContainer,
startContainer: mockStartContainer,
}),
},
};
});
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getBaseDir: () => '/tmp/compose',
hasComposeFile: vi.fn().mockResolvedValue(true),
}),
},
}));
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
const { NotificationService } = await import('../services/NotificationService');
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
mockStopContainer.mockReset();
mockStartContainer.mockReset();
});
/** Build an Error that mimics the shape Dockerode throws on socket failure. */
function dockerSocketDownError(): NodeJS.ErrnoException {
const err = Object.assign(new Error('connect ECONNREFUSED /var/run/docker.sock'), {
code: 'ECONNREFUSED',
errno: -111,
syscall: 'connect',
address: '/var/run/docker.sock',
}) as NodeJS.ErrnoException;
return err;
}
/** Build an Error that mimics docker compose CLI output when dockerd is down. */
function composeDaemonDownError(): Error {
return new Error(
'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?',
);
}
describe('isDockerUnavailableError helper', () => {
it('matches plain ECONNREFUSED node errors', () => {
expect(isDockerUnavailableError(dockerSocketDownError())).toBe(true);
});
it('matches docker compose CLI "cannot connect to the Docker daemon" output', () => {
expect(isDockerUnavailableError(composeDaemonDownError())).toBe(true);
});
it('matches docker.sock ENOENT errors', () => {
const err = Object.assign(new Error('ENOENT: no such file or directory, connect /var/run/docker.sock'), {
code: 'ENOENT',
});
expect(isDockerUnavailableError(err)).toBe(true);
});
it('does not match unrelated errors', () => {
expect(isDockerUnavailableError(new Error('image pull failed: manifest unknown'))).toBe(false);
expect(isDockerUnavailableError(new Error('compose YAML parse error'))).toBe(false);
expect(isDockerUnavailableError(null)).toBe(false);
expect(isDockerUnavailableError(undefined)).toBe(false);
});
});
describe('POST /api/stacks/:name/restart with daemon down', () => {
it('returns 503 with code: docker_unavailable when Dockerode listContainers refuses', async () => {
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
const res = await request(app)
.post('/api/stacks/web/restart')
.set('Cookie', authCookie);
expect(res.status).toBe(503);
expect(res.body).toMatchObject({ code: 'docker_unavailable' });
expect(res.body.error).toMatch(/ECONNREFUSED|docker daemon|unreachable/i);
});
it('still returns 503 on stop when the daemon is down', async () => {
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
const res = await request(app)
.post('/api/stacks/web/stop')
.set('Cookie', authCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_unavailable');
});
it('still returns 503 on start when the daemon is down', async () => {
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
const res = await request(app)
.post('/api/stacks/web/start')
.set('Cookie', authCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_unavailable');
});
});
describe('POST /api/stacks/:name/deploy with daemon down', () => {
it('returns 503 with code: docker_unavailable when ComposeService surfaces the daemon error', async () => {
mockDeployStack.mockRejectedValue(composeDaemonDownError());
const res = await request(app)
.post('/api/stacks/web/deploy')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_unavailable');
expect(res.body.error).toMatch(/Cannot connect to the Docker daemon|unreachable/i);
});
it('still returns 500 (not 503) for unrelated deploy failures', async () => {
mockDeployStack.mockRejectedValue(new Error('compose YAML parse error'));
const res = await request(app)
.post('/api/stacks/web/deploy')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(500);
expect(res.body.code).toBeUndefined();
});
});
describe('POST /api/stacks/:name/down with daemon down', () => {
it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => {
mockRunDown.mockRejectedValue(composeDaemonDownError());
const res = await request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie);
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_unavailable');
});
});
describe('POST /api/stacks/:name/update with daemon down', () => {
it('returns 503 with code: docker_unavailable when updateStack surfaces the daemon error', async () => {
mockUpdateStack.mockRejectedValue(composeDaemonDownError());
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_unavailable');
});
});