mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
fix: harden stack management operations (#1046)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user