Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
293 lines
8.8 KiB
JavaScript
293 lines
8.8 KiB
JavaScript
/**
|
|
* System Backup Endpoint Tests - ADR-064 Phase 1, Task 2
|
|
* Testing shell injection fix in POST /api/v3/system/backups/create
|
|
*
|
|
* Verifies:
|
|
* 1. execFile is used instead of exec (no shell injection)
|
|
* 2. Non-admin/owner users get 403
|
|
* 3. filePath is validated to prevent directory traversal
|
|
* 4. pg_dump failure is handled gracefully
|
|
* 5. PGPASSWORD is passed via env, not command string
|
|
*/
|
|
|
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import path from 'node:path';
|
|
|
|
// ============================================================
|
|
// Mock child_process BEFORE importing system.js
|
|
// ============================================================
|
|
|
|
const mockExecFile = vi.fn((_cmd, _args, _opts, cb) => {
|
|
if (cb) cb(null, '', '');
|
|
});
|
|
|
|
vi.mock('child_process', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
default: { ...actual, execFile: mockExecFile },
|
|
execFile: mockExecFile
|
|
};
|
|
});
|
|
|
|
// Mock database
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: vi.fn().mockResolvedValue(null),
|
|
dbRun: vi.fn().mockResolvedValue({ changes: 1 }),
|
|
dbAll: vi.fn().mockResolvedValue([])
|
|
}));
|
|
|
|
// Mock services
|
|
vi.mock('../../../services/SMTPService.js', () => ({
|
|
default: {
|
|
validate: vi.fn(),
|
|
generateVerificationCode: vi.fn(() => '123456'),
|
|
sendTestEmail: vi.fn(async () => ({ success: true }))
|
|
}
|
|
}));
|
|
|
|
vi.mock('../../../services/AgentToolsService.js', () => ({
|
|
AGENT_TOOLS: []
|
|
}));
|
|
|
|
vi.mock('swagger-jsdoc', () => ({
|
|
default: vi.fn(() => ({
|
|
openapi: '3.0.3',
|
|
info: { version: '0.003.001' },
|
|
paths: {},
|
|
tags: []
|
|
}))
|
|
}));
|
|
|
|
vi.mock('../../../swagger.config.js', () => ({
|
|
swaggerOptions: {}
|
|
}));
|
|
|
|
// Mock node:fs for backup file operations
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
const mockExistsSync = vi.fn((p) => {
|
|
if (typeof p === 'string' && p.includes('/backups/')) return true;
|
|
if (typeof p === 'string' && p.includes('QUICK-START')) return false;
|
|
return false;
|
|
});
|
|
const mockMkdirSync = vi.fn();
|
|
const mockStatSync = vi.fn(() => ({
|
|
size: 1024 * 1024 * 5, // 5MB
|
|
mtime: new Date('2026-01-31T10:00:00Z')
|
|
}));
|
|
const mockReaddirSync = vi.fn(() => []);
|
|
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
existsSync: mockExistsSync,
|
|
mkdirSync: mockMkdirSync,
|
|
statSync: mockStatSync,
|
|
readdirSync: mockReaddirSync,
|
|
readFileSync: actual.readFileSync
|
|
},
|
|
existsSync: mockExistsSync,
|
|
mkdirSync: mockMkdirSync,
|
|
statSync: mockStatSync,
|
|
readdirSync: mockReaddirSync,
|
|
readFileSync: actual.readFileSync
|
|
};
|
|
});
|
|
|
|
// Now import the system router (will use mocked modules)
|
|
const { default: systemRouter } = await import('../system.js');
|
|
|
|
// ============================================================
|
|
// Test Setup
|
|
// ============================================================
|
|
|
|
function createApp(userOverride) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
app.use((req, _res, next) => {
|
|
req.user = userOverride || { id: 1, email: 'admin@test.com', role: 'admin' };
|
|
next();
|
|
});
|
|
|
|
app.use('/api/v3/system', systemRouter);
|
|
return app;
|
|
}
|
|
|
|
describe('POST /api/v3/system/backups/create - ADR-064 Shell Injection Fix', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => {
|
|
if (cb) cb(null, '', '');
|
|
});
|
|
|
|
process.env.PGDATABASE = 'godcrm_test';
|
|
process.env.PGUSER = 'testuser';
|
|
process.env.PGHOST = 'localhost';
|
|
process.env.POSTGRES_PASSWORD = 'test_password';
|
|
|
|
app = createApp();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.PGDATABASE;
|
|
delete process.env.PGUSER;
|
|
delete process.env.PGHOST;
|
|
delete process.env.POSTGRES_PASSWORD;
|
|
});
|
|
|
|
test('should create backup using execFile (no shell) instead of exec', async () => {
|
|
const response = await request(app)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.filename).toMatch(/^godcrm_manual-.*\.sql$/);
|
|
expect(response.body.data.size_mb).toBeDefined();
|
|
expect(response.body.data.created_at).toBeDefined();
|
|
|
|
expect(mockExecFile).toHaveBeenCalled();
|
|
const callArgs = mockExecFile.mock.calls[0];
|
|
|
|
// First arg: binary name (not shell command string)
|
|
expect(callArgs[0]).toBe('pg_dump');
|
|
|
|
// Second arg: array of arguments (not a single string)
|
|
expect(Array.isArray(callArgs[1])).toBe(true);
|
|
expect(callArgs[1]).toContain('-h');
|
|
expect(callArgs[1]).toContain('localhost');
|
|
expect(callArgs[1]).toContain('-U');
|
|
expect(callArgs[1]).toContain('testuser');
|
|
expect(callArgs[1]).toContain('-d');
|
|
expect(callArgs[1]).toContain('godcrm_test');
|
|
expect(callArgs[1]).toContain('-f');
|
|
expect(callArgs[1]).toContain('--compress=6');
|
|
});
|
|
|
|
test('should pass PGPASSWORD via env option, not in command string', async () => {
|
|
await request(app)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(200);
|
|
|
|
expect(mockExecFile).toHaveBeenCalled();
|
|
const callArgs = mockExecFile.mock.calls[0];
|
|
|
|
// Third arg: options with env
|
|
const options = callArgs[2];
|
|
expect(options).toBeDefined();
|
|
expect(typeof options).toBe('object');
|
|
expect(options.env).toBeDefined();
|
|
expect(options.env.PGPASSWORD).toBe('test_password');
|
|
|
|
// PGPASSWORD must NOT appear in args or command name
|
|
const argsString = JSON.stringify(callArgs[1]);
|
|
expect(argsString).not.toContain('PGPASSWORD');
|
|
expect(callArgs[0]).not.toContain('PGPASSWORD');
|
|
});
|
|
|
|
test('should reject non-admin users with 403', async () => {
|
|
const regularUserApp = createApp({ id: 2, email: 'user@test.com', role: 'user' });
|
|
|
|
const response = await request(regularUserApp)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(403);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('FORBIDDEN');
|
|
});
|
|
|
|
test('should allow owner role to create backup', async () => {
|
|
const ownerApp = createApp({ id: 1, email: 'owner@test.com', role: 'owner' });
|
|
|
|
const response = await request(ownerApp)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.filename).toBeDefined();
|
|
});
|
|
|
|
test('should generate safe filePath within BACKUP_DIR only', async () => {
|
|
await request(app)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(200);
|
|
|
|
expect(mockExecFile).toHaveBeenCalled();
|
|
const callArgs = mockExecFile.mock.calls[0];
|
|
const argsArray = callArgs[1];
|
|
|
|
const fIndex = argsArray.indexOf('-f');
|
|
expect(fIndex).toBeGreaterThan(-1);
|
|
|
|
const filePath = argsArray[fIndex + 1];
|
|
|
|
expect(filePath).not.toContain('..');
|
|
expect(filePath).toMatch(/^\/home\/dev2\/backups\/daily\/godcrm_manual-.*\.sql$/);
|
|
expect(filePath).toBe(path.normalize(filePath));
|
|
});
|
|
|
|
test('should handle pg_dump failure gracefully', async () => {
|
|
mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => {
|
|
if (cb) cb(new Error('pg_dump: connection to server failed'), '', '');
|
|
});
|
|
|
|
const response = await request(app)
|
|
.post('/api/v3/system/backups/create');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error).toBeDefined();
|
|
});
|
|
|
|
test('should not interpolate env vars with special chars into shell', async () => {
|
|
process.env.PGDATABASE = 'godcrm; rm -rf /';
|
|
process.env.PGUSER = 'user$(whoami)';
|
|
process.env.PGHOST = 'host`id`';
|
|
|
|
await request(app)
|
|
.post('/api/v3/system/backups/create')
|
|
.expect(200);
|
|
|
|
expect(mockExecFile).toHaveBeenCalled();
|
|
const callArgs = mockExecFile.mock.calls[0];
|
|
|
|
expect(callArgs[0]).toBe('pg_dump');
|
|
|
|
// With execFile, raw values passed as-is (no shell interpretation)
|
|
expect(callArgs[1]).toContain('godcrm; rm -rf /');
|
|
expect(callArgs[1]).toContain('user$(whoami)');
|
|
expect(callArgs[1]).toContain('host`id`');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v3/system/backups/:filename/download - Path Traversal', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
app = createApp();
|
|
});
|
|
|
|
test('should reject filenames containing double dots', async () => {
|
|
const response = await request(app)
|
|
.get('/api/v3/system/backups/..%2F..%2Fetc%2Fpasswd/download');
|
|
|
|
expect(response.status).not.toBe(200);
|
|
});
|
|
|
|
test('should reject non-admin users for download', async () => {
|
|
const regularUserApp = createApp({ id: 2, email: 'user@test.com', role: 'user' });
|
|
|
|
const response = await request(regularUserApp)
|
|
.get('/api/v3/system/backups/test.sql/download')
|
|
.expect(403);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
});
|