fix: harden stack management operations (#1046)

This commit is contained in:
Anso
2026-05-14 10:21:18 -04:00
committed by GitHub
parent 8dd0fce621
commit 5461bc316b
5 changed files with 325 additions and 58 deletions
+64 -7
View File
@@ -4,6 +4,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -102,16 +103,23 @@ vi.mock('../services/LogFormatter', () => ({
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
proc.killed = false;
proc.kill = vi.fn(() => {
proc.killed = true;
return true;
});
return proc;
}
@@ -125,14 +133,22 @@ function setupAutoCloseSpawn(exitCode = 0) {
});
}
function createMockWs() {
return {
type MockWebSocket = EventEmitter & {
readyState: number;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
OPEN: number;
} & WebSocket;
function createMockWs(): MockWebSocket {
const ws = new EventEmitter() as MockWebSocket;
Object.assign(ws, {
readyState: 1,
send: vi.fn(),
on: vi.fn(),
close: vi.fn(),
OPEN: 1,
};
});
return ws;
}
beforeEach(() => {
@@ -142,6 +158,11 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
if (originalComposeTimeout === undefined) {
delete process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
} else {
process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = originalComposeTimeout;
}
});
// ── runCommand ─────────────────────────────────────────────────────────
@@ -206,13 +227,49 @@ describe('ComposeService - runCommand', () => {
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws as any);
const promise = svc.runCommand('my-stack', 'restart', ws);
proc.stdout.emit('data', Buffer.from('Restarting...'));
proc.emit('close', 0);
await promise;
expect(ws.send).toHaveBeenCalledWith('Restarting...');
});
it('kills and rejects commands that exceed the compose timeout', async () => {
process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = '1000';
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
const expectation = expect(promise).rejects.toThrow('Command timed out after 1s');
let settled = false;
promise.finally(() => { settled = true; }).catch(() => undefined);
await vi.advanceTimersByTimeAsync(1000);
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(settled).toBe(false);
proc.emit('close', null);
await expectation;
});
it('kills and rejects running commands when the WebSocket disconnects', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
const expectation = expect(promise).rejects.toThrow('client disconnected');
let settled = false;
promise.finally(() => { settled = true; }).catch(() => undefined);
ws.emit('close');
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(settled).toBe(false);
proc.emit('close', null);
await expectation;
});
});
// ── deployStack ────────────────────────────────────────────────────────
@@ -389,7 +446,7 @@ describe('ComposeService - withRegistryAuth', () => {
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack', ws as any);
const promise = svc.deployStack('my-stack', ws);
await vi.advanceTimersByTimeAsync(3100);
await promise;
@@ -246,6 +246,30 @@ describe('POST /api/scheduled-tasks', () => {
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/target_services can only be used with restart/);
});
it('rejects invalid stack target_id values', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
...basePayload, target_id: '../etc/passwd',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid stack name/);
});
it('rejects stack target_id values with surrounding whitespace', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
...basePayload, target_id: ' my-stack ',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid stack name/);
});
it('rejects invalid stack node_id values', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
...basePayload, node_id: 'not-a-node',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid node_id/);
});
});
describe('GET /api/scheduled-tasks/:id', () => {
@@ -410,3 +434,67 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
expect(res2.body.delete_after_run).toBe(0);
});
});
describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
let taskId: number;
beforeEach(() => {
const now = Date.now();
taskId = DatabaseService.getInstance().createScheduledTask({
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
});
});
it('rejects updates that introduce path traversal in stack target_id', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
.set('Cookie', adminCookie)
.send({ target_id: '../bad' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid stack name/);
});
it('rejects updates that introduce whitespace in stack target_id', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
.set('Cookie', adminCookie)
.send({ target_id: ' s ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid stack name/);
});
it('rejects updates that clear node_id for a stack target', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
.set('Cookie', adminCookie)
.send({ node_id: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/node_id/);
});
it('rejects updates that clear target_type', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
.set('Cookie', adminCookie)
.send({ target_type: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid target_type/);
});
it('rejects updates that clear action', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
.set('Cookie', adminCookie)
.send({ action: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid action/);
});
});
@@ -340,4 +340,18 @@ describe('deploy_failure notification on /update error', () => {
expect(res.status).toBe(500);
expect(res.body).toMatchObject({ rolledBack: false });
});
it('uses trusted proxy tier headers for remote atomic updates', async () => {
mockUpdateStack.mockResolvedValue(undefined);
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/stacks/myapp/update')
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'paid')
.set('x-sencho-variant', 'skipper');
expect(res.status).toBe(200);
expect(mockUpdateStack.mock.calls[0][2]).toBe(true);
});
});