Files
sencho/backend/src/__tests__/filesystem.test.ts
T
Anso 10d16361fa fix(stacks): avoid resource busy error in Docker fallback deletion (#271)
The previous command `rm -rf /cleanup` tried to remove the bind mount
point itself, which the kernel rejects with EBUSY. Changed to
`find /cleanup -mindepth 1 -maxdepth 1 -exec rm -rf {} +` which
removes all contents without touching the mount point. The existing
fsPromises.rmdir() call then cleans up the empty host directory.
2026-03-29 23:52:40 -04:00

176 lines
5.4 KiB
TypeScript

/**
* Unit tests for FileSystemService.deleteStack() including the
* Docker-based fallback for permission-denied scenarios.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
import { EventEmitter } from 'events';
const { mockSpawn, mockRm, mockRmdir } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockRm: vi.fn(),
mockRmdir: vi.fn(),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn }));
vi.mock('fs', () => ({
promises: {
rm: mockRm,
rmdir: mockRmdir,
mkdir: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
access: vi.fn(),
stat: vi.fn(),
rename: vi.fn(),
copyFile: vi.fn(),
unlink: vi.fn(),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => '/test/compose',
getDefaultNodeId: () => 1,
}),
},
}));
import { FileSystemService } from '../services/FileSystemService';
function createMockProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
return proc;
}
const expectedDir = path.join('/test/compose', 'my-stack');
describe('FileSystemService.deleteStack', () => {
let service: FileSystemService;
beforeEach(() => {
vi.clearAllMocks();
service = FileSystemService.getInstance();
});
it('deletes a stack directory successfully via fsPromises.rm', async () => {
mockRm.mockResolvedValueOnce(undefined);
await service.deleteStack('my-stack');
expect(mockRm).toHaveBeenCalledWith(expectedDir, { recursive: true, force: true });
});
it('silently ignores ENOENT (directory already gone)', async () => {
const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('gone-stack')).resolves.toBeUndefined();
});
it('falls back to Docker removal on EACCES', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockResolvedValueOnce(undefined);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('restricted-stack');
// Emit close asynchronously so listeners are attached first
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['run', '--rm', '-v', expect.stringContaining(':/cleanup'), 'alpine', 'sh', '-c', 'find /cleanup -mindepth 1 -maxdepth 1 -exec rm -rf {} +']),
expect.objectContaining({ env: expect.any(Object) }),
);
});
it('falls back to Docker removal on EPERM', async () => {
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockResolvedValueOnce(undefined);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('eperm-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
});
it('throws descriptive error when Docker fallback fails', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('stuck-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.stderr.emit('data', Buffer.from('container error'));
proc.emit('close', 1);
await expect(promise).rejects.toThrow(/Docker cleanup exited with code 1/);
});
it('throws descriptive error when Docker is unavailable', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('no-docker-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('error', new Error('spawn docker ENOENT'));
await expect(promise).rejects.toThrow(/could not run Docker for cleanup/);
});
it('still succeeds if rmdir of empty shell fails after Docker cleanup', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockRejectedValueOnce(new Error('rmdir failed'));
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('partial-cleanup');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
});
it('throws on unexpected errors (not ENOENT/EACCES/EPERM)', async () => {
const err = Object.assign(new Error('disk I/O error'), { code: 'EIO' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('io-error-stack')).rejects.toThrow(/disk I\/O error/);
expect(mockSpawn).not.toHaveBeenCalled();
});
});