feat: add dynamic per-call SSH connections and secret redaction
- execute-command/upload/download accept host/port/username/password inline to open an ephemeral connection for a single call - add redactSecret/redactSecrets to mask credentials in logs and errors - logger log()/handleError() accept optional secrets[] for redaction - add SSH_CONFIG_MISSING error code and --password-from-env CLI flag - dynamic-mode startup: server runs with no static config
This commit is contained in:
+191
@@ -0,0 +1,191 @@
|
||||
# 测试文档
|
||||
|
||||
本项目使用 Node.js 内置的测试框架进行单元测试和集成测试。
|
||||
|
||||
## 测试结构
|
||||
|
||||
```
|
||||
test/
|
||||
├── ssh-config-parser.test.js # SSH 配置解析器测试
|
||||
├── command-line-parser.test.js # 命令行参数解析器测试
|
||||
├── ssh-connection-manager.test.js # SSH 连接管理器测试
|
||||
├── integration.test.js # 集成测试
|
||||
└── fixtures/ # 测试数据(自动生成)
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### 监听模式(开发时使用)
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
监听模式会在文件变化时自动重新运行测试。
|
||||
|
||||
### 运行单个测试文件
|
||||
|
||||
```bash
|
||||
node --test test/ssh-config-parser.test.js
|
||||
```
|
||||
|
||||
## 测试覆盖范围
|
||||
|
||||
### 1. SSH Config Parser 测试
|
||||
|
||||
测试 `src/utils/ssh-config-parser.ts` 的功能:
|
||||
|
||||
- ✅ 基本 Host 别名解析
|
||||
- ✅ 多别名 Host 行(`Host a b c`)
|
||||
- ✅ 通配符匹配(`Host *.example.com`)
|
||||
- ✅ `Host *` 默认值 fallback
|
||||
- ✅ `Include` 指令支持
|
||||
- ✅ 路径展开(`~` 和相对路径)
|
||||
- ✅ First-match-wins 语义
|
||||
- ✅ 错误处理(文件不存在等)
|
||||
|
||||
### 2. Command Line Parser 测试
|
||||
|
||||
测试 `src/cli/command-line-parser.ts` 的功能:
|
||||
|
||||
- ✅ JSON 配置文件解析(对象和数组格式)
|
||||
- ✅ `--ssh` 参数解析(JSON 和旧格式)
|
||||
- ✅ 单连接模式(命令行参数和位置参数)
|
||||
- ✅ SSH config 集成
|
||||
- ✅ 参数优先级(命令行 > SSH config)
|
||||
- ✅ 命令白名单和黑名单
|
||||
- ✅ 其他选项(`--pty`, `--pre-connect`, `--proxy`, `--socksProxy`)
|
||||
- ✅ 错误处理
|
||||
|
||||
### 3. SSH Connection Manager 测试
|
||||
|
||||
测试 `src/services/ssh-connection-manager.ts` 的功能:
|
||||
|
||||
- ✅ 配置管理(初始化、获取配置)
|
||||
- ✅ 命令验证(白名单、黑名单、正则表达式)
|
||||
- ✅ 连接状态管理
|
||||
- ✅ 多服务器支持
|
||||
|
||||
### 4. 集成测试
|
||||
|
||||
端到端测试完整流程:
|
||||
|
||||
- ✅ 从命令行参数到连接管理器的完整流程
|
||||
- ✅ 从 SSH config 到连接管理器的完整流程
|
||||
- ✅ 多服务器配置场景
|
||||
- ✅ 错误处理(无效配置、缺少字段等)
|
||||
|
||||
## 编写新测试
|
||||
|
||||
### 测试文件命名
|
||||
|
||||
测试文件应该以 `.test.js` 结尾,并放在 `test/` 目录下。
|
||||
|
||||
### 测试示例
|
||||
|
||||
```javascript
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
describe('功能模块名称', () => {
|
||||
before(() => {
|
||||
// 测试前的准备工作
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// 测试后的清理工作
|
||||
});
|
||||
|
||||
describe('子功能', () => {
|
||||
it('应该做某事', () => {
|
||||
// 测试代码
|
||||
assert.strictEqual(1 + 1, 2);
|
||||
});
|
||||
|
||||
it('应该处理错误情况', () => {
|
||||
assert.throws(() => {
|
||||
throw new Error('测试错误');
|
||||
}, /测试错误/);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 测试最佳实践
|
||||
|
||||
1. **独立性**:每个测试应该独立运行,不依赖其他测试的状态
|
||||
2. **清理**:使用 `after` 钩子清理测试创建的临时文件和资源
|
||||
3. **描述性**:测试名称应该清楚地描述测试的内容
|
||||
4. **覆盖边界情况**:测试正常情况、边界情况和错误情况
|
||||
5. **使用 fixtures**:将测试数据放在 `test/fixtures/` 目录下
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
测试可以轻松集成到 CI/CD 流程中:
|
||||
|
||||
```yaml
|
||||
# GitHub Actions 示例
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
```
|
||||
|
||||
## 调试测试
|
||||
|
||||
### 使用 Node.js 调试器
|
||||
|
||||
```bash
|
||||
node --inspect-brk --test test/ssh-config-parser.test.js
|
||||
```
|
||||
|
||||
然后在 Chrome 中打开 `chrome://inspect` 进行调试。
|
||||
|
||||
### 查看详细输出
|
||||
|
||||
```bash
|
||||
node --test --test-reporter=tap test/**/*.test.js
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 测试失败但没有详细错误信息?
|
||||
|
||||
A: 使用 `--test-reporter=spec` 查看详细输出:
|
||||
|
||||
```bash
|
||||
node --test --test-reporter=spec test/**/*.test.js
|
||||
```
|
||||
|
||||
### Q: 如何跳过某个测试?
|
||||
|
||||
A: 使用 `it.skip()`:
|
||||
|
||||
```javascript
|
||||
it.skip('暂时跳过的测试', () => {
|
||||
// 测试代码
|
||||
});
|
||||
```
|
||||
|
||||
### Q: 如何只运行某个测试?
|
||||
|
||||
A: 使用 `it.only()`:
|
||||
|
||||
```javascript
|
||||
it.only('只运行这个测试', () => {
|
||||
// 测试代码
|
||||
});
|
||||
```
|
||||
|
||||
## 贡献指南
|
||||
|
||||
提交 PR 时,请确保:
|
||||
|
||||
1. 所有测试通过:`npm test`
|
||||
2. 新功能有对应的测试
|
||||
3. 测试覆盖了正常情况和边界情况
|
||||
4. 代码编译通过:`npm run build`
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SERVER_CONFIG } from '../build/config/server.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const entrypoint = path.join(rootDir, 'build', 'index.js');
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
|
||||
);
|
||||
|
||||
const expectedHelpOptions = [
|
||||
'--config-file <path>',
|
||||
'--ssh-config-file <path>',
|
||||
'--ssh <config>',
|
||||
'-h, --host <host>',
|
||||
'-p, --port <port>',
|
||||
'-u, --username <name>',
|
||||
'-w, --password <password>',
|
||||
'-k, --privateKey <path>',
|
||||
'-P, --passphrase <passphrase>',
|
||||
'-a, --agent <path>',
|
||||
'-W, --whitelist <patterns>',
|
||||
'-B, --blacklist <patterns>',
|
||||
'--proxy <url>',
|
||||
'-s, --socksProxy <url>',
|
||||
'--allowed-local-paths <paths>',
|
||||
'--allowed-remote-paths <paths>',
|
||||
'--transport-mode <mode>',
|
||||
'--shell-ready-timeout <ms>',
|
||||
'--command-template <template>',
|
||||
'--pty',
|
||||
'--try-keyboard',
|
||||
'--pre-connect',
|
||||
'--version, -v',
|
||||
'--help',
|
||||
];
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [entrypoint, ...args], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI info flags', () => {
|
||||
it('keeps the MCP server version in sync with package metadata', () => {
|
||||
assert.strictEqual(SERVER_CONFIG.version, packageJson.version);
|
||||
});
|
||||
|
||||
it('prints package version and exits successfully for --version', () => {
|
||||
const result = runCli(['--version']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout.trim(), packageJson.version);
|
||||
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||
});
|
||||
|
||||
it('prints package version and exits successfully for -v', () => {
|
||||
const result = runCli(['-v']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout.trim(), packageJson.version);
|
||||
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||
});
|
||||
|
||||
it('prints help and exits successfully for --help', () => {
|
||||
const result = runCli(['--help']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.match(result.stdout, /Usage: ssh-mcp-server/);
|
||||
for (const option of expectedHelpOptions) {
|
||||
assert.ok(
|
||||
result.stdout.includes(option),
|
||||
`Expected help output to include ${option}`
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||
});
|
||||
|
||||
it('handles info flags before normal config parsing', () => {
|
||||
const missingConfigPath = path.join(rootDir, 'missing-cli-info-config.json');
|
||||
const result = runCli(['--config-file', missingConfigPath, '--help']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.match(result.stdout, /Usage: ssh-mcp-server/);
|
||||
assert.doesNotMatch(result.stderr, /Config file not found/);
|
||||
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { CommandLineParser } from '../build/cli/command-line-parser.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Command Line Parser', () => {
|
||||
let originalArgv;
|
||||
let fixturesDir;
|
||||
let testConfigPath;
|
||||
let testSshConfigPath;
|
||||
|
||||
before(() => {
|
||||
originalArgv = process.argv;
|
||||
|
||||
// 创建测试配置文件
|
||||
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-cli-test-'));
|
||||
|
||||
testConfigPath = path.join(fixturesDir, 'test-config.json');
|
||||
testSshConfigPath = path.join(fixturesDir, 'test-ssh-config');
|
||||
|
||||
// 创建 JSON 配置文件
|
||||
fs.writeFileSync(testConfigPath, JSON.stringify({
|
||||
dev: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
password: 'devpass'
|
||||
},
|
||||
prod: {
|
||||
host: '10.0.0.50',
|
||||
port: 22,
|
||||
username: 'produser',
|
||||
privateKey: '~/.ssh/prod_key'
|
||||
}
|
||||
}));
|
||||
|
||||
// 创建 SSH 配置文件
|
||||
fs.writeFileSync(testSshConfigPath, `
|
||||
Host testhost
|
||||
HostName 172.16.0.1
|
||||
Port 2222
|
||||
User testuser
|
||||
IdentityFile ~/.ssh/test_key
|
||||
`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.argv = originalArgv;
|
||||
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('配置文件解析', () => {
|
||||
it('应该正确解析 JSON 配置文件(对象格式)', () => {
|
||||
process.argv = ['node', 'test', '--config-file', testConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||
assert.strictEqual(result.configs.dev.host, '192.168.1.100');
|
||||
assert.strictEqual(result.configs.dev.username, 'devuser');
|
||||
assert.strictEqual(result.configs.prod.privateKey, path.join(os.homedir(), '.ssh', 'prod_key'));
|
||||
});
|
||||
|
||||
it('应该正确解析 JSON 配置文件(数组格式)', () => {
|
||||
const arrayConfigPath = path.join(fixturesDir, 'array-config.json');
|
||||
fs.writeFileSync(arrayConfigPath, JSON.stringify([
|
||||
{
|
||||
name: 'server1',
|
||||
host: '1.2.3.4',
|
||||
port: 22,
|
||||
username: 'user1',
|
||||
password: 'pass1'
|
||||
},
|
||||
{
|
||||
name: 'server2',
|
||||
host: '5.6.7.8',
|
||||
port: 2222,
|
||||
username: 'user2',
|
||||
privateKey: '~/.ssh/key2'
|
||||
}
|
||||
]));
|
||||
|
||||
process.argv = ['node', 'test', '--config-file', arrayConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||
assert.strictEqual(result.configs.server1.host, '1.2.3.4');
|
||||
assert.strictEqual(result.configs.server2.port, 2222);
|
||||
|
||||
fs.unlinkSync(arrayConfigPath);
|
||||
});
|
||||
|
||||
it('应该保留 JSON 配置中的 SSH algorithms', () => {
|
||||
const algorithmsConfigPath = path.join(fixturesDir, 'algorithms-config.json');
|
||||
const algorithms = {
|
||||
serverHostKey: { append: ['ssh-rsa'] },
|
||||
hmac: ['hmac-sha1', 'hmac-md5']
|
||||
};
|
||||
fs.writeFileSync(algorithmsConfigPath, JSON.stringify({
|
||||
legacy: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'legacy-user',
|
||||
password: 'legacy-pass',
|
||||
algorithms
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', algorithmsConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.deepStrictEqual(result.configs.legacy.algorithms, algorithms);
|
||||
} finally {
|
||||
fs.unlinkSync(algorithmsConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该保留并校验 JSON 配置中的 maxOutputBytes', () => {
|
||||
const outputLimitConfigPath = path.join(fixturesDir, 'output-limit-config.json');
|
||||
fs.writeFileSync(outputLimitConfigPath, JSON.stringify({
|
||||
limited: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'limited-user',
|
||||
password: 'limited-pass',
|
||||
maxOutputBytes: 2048
|
||||
},
|
||||
unlimited: {
|
||||
host: '192.168.1.101',
|
||||
port: 22,
|
||||
username: 'unlimited-user',
|
||||
password: 'unlimited-pass',
|
||||
maxOutputBytes: 0
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', outputLimitConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.limited.maxOutputBytes, 2048);
|
||||
assert.strictEqual(result.configs.unlimited.maxOutputBytes, 0);
|
||||
} finally {
|
||||
fs.unlinkSync(outputLimitConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('应从配置文件解析 commandTimeoutMs', () => {
|
||||
const timeoutConfigPath = path.join(fixturesDir, 'command-timeout-config.json');
|
||||
fs.writeFileSync(timeoutConfigPath, JSON.stringify({
|
||||
slow: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'slow-user',
|
||||
password: 'slow-pass',
|
||||
commandTimeoutMs: 180000
|
||||
},
|
||||
plain: {
|
||||
host: '192.168.1.101',
|
||||
port: 22,
|
||||
username: 'plain-user',
|
||||
password: 'plain-pass'
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', timeoutConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.slow.commandTimeoutMs, 180000);
|
||||
assert.strictEqual(result.configs.plain.commandTimeoutMs, undefined);
|
||||
} finally {
|
||||
fs.unlinkSync(timeoutConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('无效的 commandTimeoutMs 应抛出错误', () => {
|
||||
const invalidTimeoutPath = path.join(fixturesDir, 'invalid-command-timeout-config.json');
|
||||
fs.writeFileSync(invalidTimeoutPath, JSON.stringify({
|
||||
invalid: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'invalid-user',
|
||||
password: 'invalid-pass',
|
||||
commandTimeoutMs: 0
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', invalidTimeoutPath];
|
||||
assert.throws(
|
||||
() => CommandLineParser.parseArgs(),
|
||||
/commandTimeoutMs must be a positive number/,
|
||||
);
|
||||
} finally {
|
||||
fs.unlinkSync(invalidTimeoutPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('无效的 maxOutputBytes 应抛出错误', () => {
|
||||
const invalidConfigPath = path.join(fixturesDir, 'invalid-output-limit-config.json');
|
||||
fs.writeFileSync(invalidConfigPath, JSON.stringify({
|
||||
invalid: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'invalid-user',
|
||||
password: 'invalid-pass',
|
||||
maxOutputBytes: -1
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', invalidConfigPath];
|
||||
assert.throws(
|
||||
() => CommandLineParser.parseArgs(),
|
||||
/maxOutputBytes must be a non-negative integer/,
|
||||
);
|
||||
} finally {
|
||||
fs.unlinkSync(invalidConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('配置文件不存在时应抛出错误', () => {
|
||||
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('动态模式(无连接参数)', () => {
|
||||
it('没有任何连接参数时应返回空配置以允许按调用动态连接', () => {
|
||||
process.argv = ['node', 'test'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
assert.deepStrictEqual(result.configs, {});
|
||||
assert.strictEqual(result.preConnect, false);
|
||||
});
|
||||
|
||||
it('只传 --pre-connect 而无连接参数时同样返回空配置', () => {
|
||||
process.argv = ['node', 'test', '--pre-connect'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
assert.deepStrictEqual(result.configs, {});
|
||||
assert.strictEqual(result.preConnect, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('--ssh 参数解析', () => {
|
||||
it('应该正确解析 JSON 格式的 --ssh 参数', () => {
|
||||
const sshJson = JSON.stringify({
|
||||
name: 'test',
|
||||
host: '1.2.3.4',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
transportMode: 'shell',
|
||||
shellReadyTimeoutMs: 15000,
|
||||
maxOutputBytes: 0
|
||||
});
|
||||
|
||||
process.argv = ['node', 'test', '--ssh', sshJson];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.test.host, '1.2.3.4');
|
||||
assert.strictEqual(result.configs.test.username, 'testuser');
|
||||
assert.strictEqual(result.configs.test.transportMode, 'shell');
|
||||
assert.strictEqual(result.configs.test.shellReadyTimeoutMs, 15000);
|
||||
assert.strictEqual(result.configs.test.maxOutputBytes, 0);
|
||||
});
|
||||
|
||||
it('应该正确解析旧格式的 --ssh 参数', () => {
|
||||
process.argv = ['node', 'test', '--ssh', 'name=legacy,host=1.2.3.4,port=22,user=legacyuser,password=legacypass'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.legacy.host, '1.2.3.4');
|
||||
assert.strictEqual(result.configs.legacy.username, 'legacyuser');
|
||||
});
|
||||
|
||||
it('应该支持多个 --ssh 参数', () => {
|
||||
const ssh1 = JSON.stringify({ name: 'server1', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||
const ssh2 = JSON.stringify({ name: 'server2', host: '2.2.2.2', port: 22, username: 'user2', password: 'pass2' });
|
||||
|
||||
process.argv = ['node', 'test', '--ssh', ssh1, '--ssh', ssh2];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||
assert.strictEqual(result.configs.server1.host, '1.1.1.1');
|
||||
assert.strictEqual(result.configs.server2.host, '2.2.2.2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('单连接模式(旧格式)', () => {
|
||||
it('应该正确解析命令行参数', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--password', 'testpass'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '1.2.3.4');
|
||||
assert.strictEqual(result.configs.default.port, 22);
|
||||
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||
});
|
||||
|
||||
it('应该正确解析位置参数', () => {
|
||||
process.argv = ['node', 'test', '1.2.3.4', '22', 'testuser', 'testpass'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '1.2.3.4');
|
||||
assert.strictEqual(result.configs.default.port, 22);
|
||||
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||
});
|
||||
|
||||
it('应该支持私钥认证', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
|
||||
assert.strictEqual(result.configs.default.password, undefined);
|
||||
});
|
||||
|
||||
it('显式 password 存在时不应注入环境 SSH agent', () => {
|
||||
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--password', 'testpass'];
|
||||
|
||||
try {
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||
assert.strictEqual(result.configs.default.agent, undefined);
|
||||
} finally {
|
||||
if (originalSshAuthSock === undefined) {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
} else {
|
||||
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('显式 privateKey 存在时不应注入环境 SSH agent', () => {
|
||||
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
|
||||
|
||||
try {
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
|
||||
assert.strictEqual(result.configs.default.agent, undefined);
|
||||
} finally {
|
||||
if (originalSshAuthSock === undefined) {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
} else {
|
||||
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('缺少必需参数时应抛出错误', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4'];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /Missing required parameters/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH Config 集成', () => {
|
||||
it('SSH config 缺少 Port 和 IdentityFile 时应使用默认端口和 SSH agent', () => {
|
||||
const minimalSshConfigPath = path.join(fixturesDir, 'minimal-ssh-config');
|
||||
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||
fs.writeFileSync(minimalSshConfigPath, `
|
||||
Host minimalhost
|
||||
HostName 172.16.0.2
|
||||
User minimaluser
|
||||
`);
|
||||
process.env.SSH_AUTH_SOCK = '/tmp/test-ssh-agent.sock';
|
||||
process.argv = ['node', 'test', '--host', 'minimalhost', '--ssh-config-file', minimalSshConfigPath];
|
||||
|
||||
try {
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '172.16.0.2');
|
||||
assert.strictEqual(result.configs.default.port, 22);
|
||||
assert.strictEqual(result.configs.default.username, 'minimaluser');
|
||||
assert.strictEqual(result.configs.default.agent, '/tmp/test-ssh-agent.sock');
|
||||
} finally {
|
||||
if (originalSshAuthSock === undefined) {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
} else {
|
||||
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||
}
|
||||
fs.unlinkSync(minimalSshConfigPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('命令行 port 和 agent 应覆盖 SSH config 默认值', () => {
|
||||
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node', 'test', '--host', 'testhost', '--port', '3333', '--agent', '/tmp/explicit-agent.sock',
|
||||
'--ssh-config-file', testSshConfigPath,
|
||||
];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.port, 3333);
|
||||
assert.strictEqual(result.configs.default.agent, '/tmp/explicit-agent.sock');
|
||||
} finally {
|
||||
if (originalSshAuthSock === undefined) {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
} else {
|
||||
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('应该从 SSH config 读取连接参数', () => {
|
||||
process.argv = ['node', 'test', '--host', 'testhost', '--ssh-config-file', testSshConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '172.16.0.1');
|
||||
assert.strictEqual(result.configs.default.port, 2222);
|
||||
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||
assert.ok(
|
||||
result.configs.default.privateKey.endsWith(path.join('.ssh', 'test_key')),
|
||||
);
|
||||
});
|
||||
|
||||
it('命令行参数应覆盖 SSH config 值', () => {
|
||||
process.argv = ['node', 'test', '--host', 'testhost', '--port', '3333', '--ssh-config-file', testSshConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.port, 3333); // 覆盖
|
||||
assert.strictEqual(result.configs.default.host, '172.16.0.1'); // 从 SSH config
|
||||
assert.strictEqual(result.configs.default.username, 'testuser'); // 从 SSH config
|
||||
});
|
||||
|
||||
it('应该支持 SSH config 别名 + 密码认证', () => {
|
||||
process.argv = ['node', 'test', '--host', 'testhost', '--password', 'mypass', '--ssh-config-file', testSshConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.password, 'mypass');
|
||||
assert.strictEqual(result.configs.default.host, '172.16.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('命令白名单和黑名单', () => {
|
||||
it('应该正确解析命令白名单', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--whitelist', 'ls,cat,grep'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
|
||||
});
|
||||
|
||||
it('应该正确解析命令黑名单', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--blacklist', 'rm,shutdown,reboot'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown', 'reboot']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('其他选项', () => {
|
||||
it('默认 transportMode 应为 exec', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.transportMode, 'exec');
|
||||
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 10000);
|
||||
});
|
||||
|
||||
it('应该正确解析 shell transport 相关选项', () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'test',
|
||||
'--host', '1.2.3.4',
|
||||
'--port', '22',
|
||||
'--username', 'user',
|
||||
'--password', 'pass',
|
||||
'--transport-mode', 'shell',
|
||||
'--shell-ready-timeout', '15000'
|
||||
];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.transportMode, 'shell');
|
||||
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 15000);
|
||||
});
|
||||
|
||||
it('应该正确解析 --pty 选项', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pty'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.pty, true);
|
||||
});
|
||||
|
||||
it('应该正确解析配置文件中的字符串 false pty', () => {
|
||||
const ptyConfigPath = path.join(fixturesDir, 'pty-config.json');
|
||||
fs.writeFileSync(ptyConfigPath, JSON.stringify({
|
||||
dev: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
password: 'devpass',
|
||||
pty: 'false'
|
||||
}
|
||||
}));
|
||||
|
||||
process.argv = ['node', 'test', '--config-file', ptyConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.dev.pty, false);
|
||||
|
||||
fs.unlinkSync(ptyConfigPath);
|
||||
});
|
||||
|
||||
it('应该正确解析 --pre-connect 选项', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pre-connect'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.preConnect, true);
|
||||
});
|
||||
|
||||
it('应该正确解析 SOCKS 代理', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--socksProxy', 'socks://proxy:1080'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.socksProxy, 'socks://proxy:1080');
|
||||
});
|
||||
|
||||
it('应该正确解析通用代理', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--proxy', 'http://proxy:8080'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.proxy, 'http://proxy:8080');
|
||||
});
|
||||
|
||||
it('应该正确解析 allowed local paths', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-local-paths', './tmp,~/.ssh'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.ok(Array.isArray(result.configs.default.allowedLocalPaths));
|
||||
assert.strictEqual(result.configs.default.allowedLocalPaths.length, 2);
|
||||
assert.ok(result.configs.default.allowedLocalPaths.every((entry) => path.isAbsolute(entry)));
|
||||
assert.strictEqual(result.configs.default.allowedLocalPaths[1], path.join(os.homedir(), '.ssh'));
|
||||
});
|
||||
|
||||
it('应该正确解析 allowed remote paths', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', '/var/log,/home/ops/inbox/'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
result.configs.default.allowedRemotePaths,
|
||||
['/var/log', '/home/ops/inbox']
|
||||
);
|
||||
});
|
||||
|
||||
it('相对的 allowedRemotePaths 条目应抛出错误', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', 'var/log'];
|
||||
assert.throws(() => CommandLineParser.parseArgs(), /absolute POSIX/);
|
||||
});
|
||||
|
||||
it('应该正确解析配置文件中的 commandTemplate', () => {
|
||||
const templateConfigPath = path.join(fixturesDir, 'template-config.json');
|
||||
fs.writeFileSync(templateConfigPath, JSON.stringify({
|
||||
dev: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
password: 'devpass',
|
||||
commandTemplate: "su root -c '<command>'"
|
||||
}
|
||||
}));
|
||||
|
||||
process.argv = ['node', 'test', '--config-file', templateConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c '<command>'");
|
||||
|
||||
fs.unlinkSync(templateConfigPath);
|
||||
});
|
||||
|
||||
it('应该支持 commandTemplate 的 <quotedCommand> 占位符', () => {
|
||||
const templateConfigPath = path.join(fixturesDir, 'quoted-template-config.json');
|
||||
fs.writeFileSync(templateConfigPath, JSON.stringify({
|
||||
dev: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
password: 'devpass',
|
||||
commandTemplate: "su root -c <quotedCommand>"
|
||||
}
|
||||
}));
|
||||
|
||||
process.argv = ['node', 'test', '--config-file', templateConfigPath];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c <quotedCommand>");
|
||||
|
||||
fs.unlinkSync(templateConfigPath);
|
||||
});
|
||||
|
||||
it('commandTemplate 缺少 <command> 占位符时应抛出错误', () => {
|
||||
const badConfigPath = path.join(fixturesDir, 'bad-template-config.json');
|
||||
fs.writeFileSync(badConfigPath, JSON.stringify({
|
||||
dev: {
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
password: 'devpass',
|
||||
commandTemplate: "su root -c 'missing placeholder'"
|
||||
}
|
||||
}));
|
||||
|
||||
process.argv = ['node', 'test', '--config-file', badConfigPath];
|
||||
assert.throws(() => CommandLineParser.parseArgs(), /<command>/);
|
||||
|
||||
fs.unlinkSync(badConfigPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('优先级测试', () => {
|
||||
it('配置文件应优先于 --ssh 参数', () => {
|
||||
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||
process.argv = ['node', 'test', '--config-file', testConfigPath, '--ssh', sshJson];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
// 应该只有配置文件中的服务器
|
||||
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||
assert.ok(result.configs.dev);
|
||||
assert.ok(result.configs.prod);
|
||||
assert.ok(!result.configs.test);
|
||||
});
|
||||
|
||||
it('--ssh 参数应优先于单连接模式', () => {
|
||||
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||
process.argv = ['node', 'test', '--ssh', sshJson, '--host', '2.2.2.2', '--port', '22', '--username', 'user2', '--password', 'pass2'];
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(Object.keys(result.configs).length, 1);
|
||||
assert.strictEqual(result.configs.test.host, '1.1.1.1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { CommandLineParser } from '../build/cli/command-line-parser.js';
|
||||
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
let fixturesDir;
|
||||
|
||||
describe('集成测试', () => {
|
||||
let originalArgv;
|
||||
|
||||
before(() => {
|
||||
originalArgv = process.argv;
|
||||
|
||||
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-integration-test-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.argv = originalArgv;
|
||||
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('端到端场景', () => {
|
||||
it('应该能够从命令行参数创建完整配置并传递给连接管理器', () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'test',
|
||||
'--host', '192.168.1.100',
|
||||
'--port', '22',
|
||||
'--username', 'testuser',
|
||||
'--password', 'testpass',
|
||||
'--whitelist', 'ls,cat,grep',
|
||||
'--blacklist', 'rm,shutdown'
|
||||
];
|
||||
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '192.168.1.100');
|
||||
assert.strictEqual(result.configs.default.port, 22);
|
||||
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
|
||||
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown']);
|
||||
|
||||
// 验证可以用这个配置初始化连接管理器
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
manager.setConfig(result.configs);
|
||||
|
||||
const config = manager.getConfig('default');
|
||||
assert.strictEqual(config.host, '192.168.1.100');
|
||||
assert.strictEqual(config.username, 'testuser');
|
||||
});
|
||||
|
||||
it('应该能够从命令行参数生成 shell transport 配置', () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'test',
|
||||
'--host', '192.168.1.110',
|
||||
'--port', '22',
|
||||
'--username', 'shelluser',
|
||||
'--password', 'shellpass',
|
||||
'--transport-mode', 'shell',
|
||||
'--shell-ready-timeout', '18000'
|
||||
];
|
||||
|
||||
const result = CommandLineParser.parseArgs();
|
||||
assert.strictEqual(result.configs.default.transportMode, 'shell');
|
||||
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 18000);
|
||||
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
manager.setConfig(result.configs);
|
||||
|
||||
const config = manager.getConfig('default');
|
||||
assert.strictEqual(config.transportMode, 'shell');
|
||||
assert.strictEqual(config.shellReadyTimeoutMs, 18000);
|
||||
});
|
||||
|
||||
it('应该能够从 SSH config 创建完整配置', () => {
|
||||
const tempSshConfig = path.join(fixturesDir, 'integration-ssh-config');
|
||||
fs.writeFileSync(tempSshConfig, [
|
||||
'Host integration-test',
|
||||
' HostName 192.168.1.200',
|
||||
' Port 2222',
|
||||
' User integrationuser',
|
||||
' IdentityFile ~/.ssh/integration_key',
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
process.argv = [
|
||||
'node',
|
||||
'test',
|
||||
'--host', 'integration-test',
|
||||
'--ssh-config-file', tempSshConfig
|
||||
];
|
||||
|
||||
const result = CommandLineParser.parseArgs();
|
||||
|
||||
assert.strictEqual(result.configs.default.host, '192.168.1.200');
|
||||
assert.strictEqual(result.configs.default.port, 2222);
|
||||
assert.strictEqual(result.configs.default.username, 'integrationuser');
|
||||
assert.ok(result.configs.default.privateKey.includes('integration_key'));
|
||||
|
||||
// 验证可以用这个配置初始化连接管理器
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
manager.setConfig(result.configs);
|
||||
|
||||
const config = manager.getConfig('default');
|
||||
assert.strictEqual(config.host, '192.168.1.200');
|
||||
} finally {
|
||||
fs.unlinkSync(tempSshConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该能够处理多服务器配置', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'multi-server-config.json');
|
||||
fs.writeFileSync(tempConfig, JSON.stringify({
|
||||
server1: {
|
||||
host: '192.168.1.1',
|
||||
port: 22,
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
commandWhitelist: ['^ls', '^cat']
|
||||
},
|
||||
server2: {
|
||||
host: '192.168.1.2',
|
||||
port: 2222,
|
||||
username: 'user2',
|
||||
privateKey: '~/.ssh/key2',
|
||||
commandBlacklist: ['^rm', '^shutdown']
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', tempConfig];
|
||||
|
||||
const result = CommandLineParser.parseArgs();
|
||||
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
manager.setConfig(result.configs);
|
||||
|
||||
// 验证配置列表
|
||||
const allInfos = manager.getAllServerInfos();
|
||||
assert.strictEqual(allInfos.length, 2);
|
||||
|
||||
const server1 = allInfos.find(i => i.name === 'server1');
|
||||
assert.ok(server1);
|
||||
assert.strictEqual(server1.host, '192.168.1.1');
|
||||
assert.strictEqual(server1.connected, false);
|
||||
|
||||
const server2 = allInfos.find(i => i.name === 'server2');
|
||||
assert.ok(server2);
|
||||
assert.strictEqual(server2.port, 2222);
|
||||
} finally {
|
||||
fs.unlinkSync(tempConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该能够从配置文件生成 shell transport 配置', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'shell-server-config.json');
|
||||
fs.writeFileSync(tempConfig, JSON.stringify({
|
||||
shellbox: {
|
||||
host: '192.168.1.20',
|
||||
port: 22,
|
||||
username: 'shelluser',
|
||||
password: 'shellpass',
|
||||
transportMode: 'shell',
|
||||
shellReadyTimeoutMs: 12000
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', tempConfig];
|
||||
|
||||
const result = CommandLineParser.parseArgs();
|
||||
assert.strictEqual(result.configs.shellbox.transportMode, 'shell');
|
||||
assert.strictEqual(result.configs.shellbox.shellReadyTimeoutMs, 12000);
|
||||
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
manager.setConfig(result.configs);
|
||||
|
||||
const config = manager.getConfig('shellbox');
|
||||
assert.strictEqual(config.transportMode, 'shell');
|
||||
assert.strictEqual(config.shellReadyTimeoutMs, 12000);
|
||||
} finally {
|
||||
fs.unlinkSync(tempConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('应该正确处理无效的 JSON 配置文件', () => {
|
||||
const invalidConfig = path.join(fixturesDir, 'invalid-config.json');
|
||||
fs.writeFileSync(invalidConfig, '{ invalid json }');
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', invalidConfig];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /Invalid JSON/);
|
||||
} finally {
|
||||
fs.unlinkSync(invalidConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该正确处理缺少必需字段的配置', () => {
|
||||
const incompleteConfig = path.join(fixturesDir, 'incomplete-config.json');
|
||||
fs.writeFileSync(incompleteConfig, JSON.stringify({
|
||||
server1: {
|
||||
host: '192.168.1.1'
|
||||
// 缺少 port, username 等必需字段
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
process.argv = ['node', 'test', '--config-file', incompleteConfig];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
});
|
||||
} finally {
|
||||
fs.unlinkSync(incompleteConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该正确处理不存在的配置文件', () => {
|
||||
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /not found/);
|
||||
});
|
||||
|
||||
it('应该正确处理缺少认证参数的情况', () => {
|
||||
const emptySshConfig = path.join(fixturesDir, 'empty-ssh-config');
|
||||
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||
fs.writeFileSync(emptySshConfig, '');
|
||||
|
||||
try {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
process.argv = [
|
||||
'node',
|
||||
'test',
|
||||
'--host', '1.2.3.4',
|
||||
'--port', '22',
|
||||
'--username', 'user',
|
||||
'--ssh-config-file', emptySshConfig
|
||||
];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /Missing required parameters/);
|
||||
} finally {
|
||||
if (originalSshAuthSock === undefined) {
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
} else {
|
||||
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||
}
|
||||
fs.unlinkSync(emptySshConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('应该正确处理无效的端口号', () => {
|
||||
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', 'abc', '--username', 'user', '--password', 'pass'];
|
||||
assert.throws(() => {
|
||||
CommandLineParser.parseArgs();
|
||||
}, /Port must be a valid number/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as path from 'node:path';
|
||||
|
||||
test('does not load SSH dependencies when importing the connection manager', () => {
|
||||
const managerUrl = pathToFileURL(
|
||||
path.resolve('build/services/ssh-connection-manager.js'),
|
||||
).href;
|
||||
const loader = `data:text/javascript,${encodeURIComponent(`
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'ssh2' || specifier === 'socks') {
|
||||
throw new Error('eager SSH dependency: ' + specifier);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
`)}`;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--experimental-loader',
|
||||
loader,
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
`await import(${JSON.stringify(managerUrl)});`,
|
||||
],
|
||||
{ cwd: path.resolve('.'), encoding: 'utf8', timeout: 10_000 },
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
[result.error?.stack, result.stderr, result.stdout].filter(Boolean).join('\n'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as net from 'node:net';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const entrypoint = path.join(rootDir, 'build', 'index.js');
|
||||
|
||||
function waitForOutput(stream, pattern, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const onData = (chunk) => {
|
||||
output += chunk.toString();
|
||||
if (pattern.test(output)) {
|
||||
cleanup();
|
||||
resolve(output);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
stream.off('data', onData);
|
||||
};
|
||||
|
||||
stream.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRunning(child, delayMs = 200) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
assert.strictEqual(child.exitCode, null);
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('Process did not exit before timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createTempConfig() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
test: {
|
||||
host: '127.0.0.1',
|
||||
port: 22,
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
commandWhitelist: ['^echo'],
|
||||
},
|
||||
}));
|
||||
|
||||
return { tmpDir, configPath };
|
||||
}
|
||||
|
||||
function createPreConnectConfig(port) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
test: {
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
connectionTimeoutMs: 10000,
|
||||
commandWhitelist: ['^echo'],
|
||||
},
|
||||
}));
|
||||
|
||||
return { tmpDir, configPath };
|
||||
}
|
||||
|
||||
function spawnServer(configPath) {
|
||||
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
child,
|
||||
closeInput: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
child.stdin.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP server lifecycle', () => {
|
||||
// Windows has no POSIX signals: child.kill('SIGTERM') maps to
|
||||
// TerminateProcess, so the handler under test never runs and the exit is
|
||||
// always reported as signalled. The stdin path below covers shutdown there,
|
||||
// and is what MCP clients actually use.
|
||||
it('exits after SIGTERM even when stdin remains open', { skip: process.platform === 'win32' && 'no POSIX signals on Windows' }, async () => {
|
||||
const { tmpDir, configPath } = createTempConfig();
|
||||
const { child, closeInput } = spawnServer(configPath);
|
||||
|
||||
try {
|
||||
await waitForRunning(child, 1500);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const result = await waitForExit(child);
|
||||
|
||||
assert.strictEqual(result.signal, null);
|
||||
assert.strictEqual(result.code, 0);
|
||||
} finally {
|
||||
try {
|
||||
closeInput();
|
||||
} catch {}
|
||||
child.kill('SIGKILL');
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('exits after stdin is closed', async () => {
|
||||
const { tmpDir, configPath } = createTempConfig();
|
||||
const { child, closeInput } = spawnServer(configPath);
|
||||
|
||||
try {
|
||||
await waitForRunning(child);
|
||||
|
||||
closeInput();
|
||||
const result = await waitForExit(child);
|
||||
|
||||
assert.strictEqual(result.signal, null);
|
||||
assert.strictEqual(result.code, 0);
|
||||
} finally {
|
||||
try {
|
||||
closeInput();
|
||||
} catch {}
|
||||
child.kill('SIGKILL');
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts initialize requests while pre-connect is still pending', async () => {
|
||||
const handshakeServer = net.createServer();
|
||||
const sockets = new Set();
|
||||
handshakeServer.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
// Killing the child resets this connection instead of closing it
|
||||
// cleanly on some platforms; an unhandled 'error' would fail the test.
|
||||
socket.on('error', () => {});
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
handshakeServer.once('error', reject);
|
||||
handshakeServer.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = handshakeServer.address();
|
||||
assert.ok(address && typeof address !== 'string');
|
||||
const { tmpDir, configPath } = createPreConnectConfig(address.port);
|
||||
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath, '--pre-connect'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 61,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'lifecycle-test', version: '1.0.0' },
|
||||
},
|
||||
})}\n`);
|
||||
|
||||
const output = await waitForOutput(child.stdout, /"id":61/, 1250);
|
||||
assert.match(output, /"result"/);
|
||||
} finally {
|
||||
child.stdin.end();
|
||||
if (child.exitCode === null) {
|
||||
const exitPromise = waitForExit(child);
|
||||
child.kill('SIGKILL');
|
||||
await exitPromise;
|
||||
}
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => handshakeServer.close(resolve));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { formatServerList } from '../build/tools/list-servers.js';
|
||||
|
||||
describe('List Servers Tool', () => {
|
||||
it('没有配置时应返回友好提示', () => {
|
||||
assert.strictEqual(formatServerList([]), 'No SSH servers configured.');
|
||||
});
|
||||
|
||||
it('应返回可读摘要和原始 JSON', () => {
|
||||
const output = formatServerList([
|
||||
{
|
||||
name: 'dev',
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
connected: true,
|
||||
status: {
|
||||
reachable: true,
|
||||
hostname: 'dev-box',
|
||||
osName: 'Linux',
|
||||
lastUpdated: '2026-04-02T12:00:00.000Z'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
assert.match(output, /Configured SSH servers:/);
|
||||
assert.match(output, /\[connected\] dev \| root@192.168.1.100:22/);
|
||||
assert.match(output, /hostname=dev-box/);
|
||||
assert.match(output, /Raw JSON:/);
|
||||
assert.match(output, /"name":"dev"/);
|
||||
});
|
||||
|
||||
it('原始 JSON 不缩进,且仍可解析回等价对象', () => {
|
||||
const servers = [
|
||||
{
|
||||
name: 'dev',
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
connected: true,
|
||||
status: {
|
||||
reachable: true,
|
||||
hostname: 'dev-box',
|
||||
osName: 'Linux',
|
||||
drives: [
|
||||
{
|
||||
device: '/dev/sda1',
|
||||
mountPoint: '/',
|
||||
total: '512G',
|
||||
used: '380G',
|
||||
free: '106G',
|
||||
usagePercent: '78%',
|
||||
},
|
||||
],
|
||||
lastUpdated: '2026-04-02T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rawJson = formatServerList(servers).split('\nRaw JSON:\n')[1];
|
||||
|
||||
assert.deepStrictEqual(JSON.parse(rawJson), servers);
|
||||
// 缩进换行是这里的主要体积来源,压缩后应只剩一行
|
||||
assert.ok(!rawJson.includes('\n'));
|
||||
assert.ok(rawJson.length < JSON.stringify(servers, null, 2).length * 0.7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { redactSecret, redactSecrets } from '../build/utils/redact.js';
|
||||
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
|
||||
|
||||
test('redactSecret masks long secrets everywhere', () => {
|
||||
const text = 'login with hunter2hunter2 then done';
|
||||
assert.strictEqual(redactSecret(text, 'hunter2'), 'login with [REDACTED][REDACTED] then done');
|
||||
});
|
||||
|
||||
test('redactSecret masks long secret even mid-word', () => {
|
||||
assert.strictEqual(
|
||||
redactSecret('password: secretvalue and secretvalueagain', 'secretvalue'),
|
||||
'password: [REDACTED] and [REDACTED]again',
|
||||
);
|
||||
});
|
||||
|
||||
test('redactSecret masks short secrets only as whole tokens', () => {
|
||||
// 2-3 char secrets are only replaced when they stand alone
|
||||
const text = 'the cat sat on a mat';
|
||||
assert.strictEqual(redactSecret(text, 'cat'), 'the [REDACTED] sat on a mat');
|
||||
});
|
||||
|
||||
test('redactSecret ignores short secret inside a word', () => {
|
||||
// "cat" inside "catastrophe" must not be destroyed
|
||||
assert.strictEqual(redactSecret('a catastrophe', 'cat'), 'a catastrophe');
|
||||
});
|
||||
|
||||
test('redactSecret ignores empty and 1-char secrets', () => {
|
||||
assert.strictEqual(redactSecret('abc', ''), 'abc');
|
||||
assert.strictEqual(redactSecret('abc', 'a'), 'abc');
|
||||
assert.strictEqual(redactSecret('abc', undefined), 'abc');
|
||||
assert.strictEqual(redactSecret('abc', null), 'abc');
|
||||
assert.strictEqual(redactSecret('', 'secret'), '');
|
||||
});
|
||||
|
||||
test('redactSecret escapes regex special characters', () => {
|
||||
const text = 'tokens like a.b and aXb';
|
||||
assert.strictEqual(redactSecret(text, 'a.b'), 'tokens like [REDACTED] and aXb');
|
||||
});
|
||||
|
||||
test('redactSecrets redacts many secrets at once', () => {
|
||||
const text = 'user=alice pass=topsecret key=pLskey-end';
|
||||
assert.strictEqual(
|
||||
redactSecrets(text, ['topsecret', 'pLskey']),
|
||||
'user=alice pass=[REDACTED] key=[REDACTED]-end',
|
||||
);
|
||||
});
|
||||
|
||||
test('redactSecret returns original when no match', () => {
|
||||
const text = 'nothing sensitive here';
|
||||
assert.strictEqual(redactSecret(text, 'nope'), text);
|
||||
});
|
||||
|
||||
test('dynamic mode config requires host and username', async () => {
|
||||
const manager = SSHConnectionManager.getInstance();
|
||||
|
||||
await assert.rejects(
|
||||
() => manager.executeCommandDynamic({ username: 'root' }, 'id'),
|
||||
(err) => err?.code === 'SSH_CONFIG_MISSING',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { lookupSshConfig } from '../build/utils/ssh-config-parser.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
let fixturesDir;
|
||||
|
||||
describe('SSH Config Parser', () => {
|
||||
let testConfigPath;
|
||||
let testConfigWithIncludePath;
|
||||
let includedConfigPath;
|
||||
let originalHome;
|
||||
|
||||
before(() => {
|
||||
originalHome = process.env.HOME;
|
||||
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-config-test-'));
|
||||
|
||||
testConfigPath = path.join(fixturesDir, 'ssh-config-basic');
|
||||
testConfigWithIncludePath = path.join(fixturesDir, 'ssh-config-include');
|
||||
includedConfigPath = path.join(fixturesDir, 'ssh-config-included');
|
||||
|
||||
// 基本测试配置
|
||||
fs.writeFileSync(testConfigPath, [
|
||||
'# 多别名测试',
|
||||
'Host dev staging',
|
||||
' HostName 192.168.1.100',
|
||||
' Port 2222',
|
||||
' User devuser',
|
||||
' IdentityFile ~/.ssh/dev_key',
|
||||
'',
|
||||
'# 单别名测试',
|
||||
'Host prod',
|
||||
' HostName 10.0.0.50',
|
||||
' User produser',
|
||||
' IdentityFile ~/.ssh/prod_key',
|
||||
'',
|
||||
'# 通配符测试',
|
||||
'Host *.example.com',
|
||||
' User wildcarduser',
|
||||
' Port 2200',
|
||||
'',
|
||||
'# 全局默认值',
|
||||
'Host *',
|
||||
' Port 22',
|
||||
' User defaultuser',
|
||||
].join('\n'));
|
||||
|
||||
// 被包含的配置文件
|
||||
fs.writeFileSync(includedConfigPath, [
|
||||
'Host included-host',
|
||||
' HostName 172.16.0.1',
|
||||
' Port 3333',
|
||||
' User includeduser',
|
||||
].join('\n'));
|
||||
|
||||
// 带 Include 的配置文件
|
||||
fs.writeFileSync(testConfigWithIncludePath, [
|
||||
`Include ${includedConfigPath}`,
|
||||
'',
|
||||
'Host main-host',
|
||||
' HostName 192.168.1.1',
|
||||
' User mainuser',
|
||||
'',
|
||||
'Host *',
|
||||
' Port 22',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('基本功能', () => {
|
||||
it('应该正确解析单个 Host 别名', () => {
|
||||
const config = lookupSshConfig('prod', testConfigPath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '10.0.0.50');
|
||||
assert.strictEqual(config.user, 'produser');
|
||||
assert.strictEqual(config.identityFile, path.join(os.homedir(), '.ssh', 'prod_key'));
|
||||
assert.strictEqual(config.port, 22); // 从 Host * fallback
|
||||
});
|
||||
|
||||
it('应该正确解析多别名 Host 行 - dev', () => {
|
||||
const config = lookupSshConfig('dev', testConfigPath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '192.168.1.100');
|
||||
assert.strictEqual(config.port, 2222);
|
||||
assert.strictEqual(config.user, 'devuser');
|
||||
});
|
||||
|
||||
it('应该正确解析多别名 Host 行 - staging', () => {
|
||||
const config = lookupSshConfig('staging', testConfigPath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '192.168.1.100');
|
||||
assert.strictEqual(config.port, 2222);
|
||||
assert.strictEqual(config.user, 'devuser');
|
||||
});
|
||||
|
||||
it('应该支持通配符匹配', () => {
|
||||
const config = lookupSshConfig('server.example.com', testConfigPath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.user, 'wildcarduser');
|
||||
assert.strictEqual(config.port, 2200);
|
||||
});
|
||||
|
||||
it('应该使用 Host * 作为默认值', () => {
|
||||
const config = lookupSshConfig('unknown-host', testConfigPath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.user, 'defaultuser');
|
||||
assert.strictEqual(config.port, 22);
|
||||
assert.strictEqual(config.hostName, undefined);
|
||||
});
|
||||
|
||||
it('通配符匹配应转义正则特殊字符', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'special-pattern-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'Host host[1]',
|
||||
' User literaluser',
|
||||
'',
|
||||
'Host *',
|
||||
' User defaultuser',
|
||||
].join('\n'));
|
||||
|
||||
const literalConfig = lookupSshConfig('host[1]', tempConfig);
|
||||
const regexLikeConfig = lookupSshConfig('host1', tempConfig);
|
||||
|
||||
assert.ok(literalConfig);
|
||||
assert.strictEqual(literalConfig.user, 'literaluser');
|
||||
assert.ok(regexLikeConfig);
|
||||
assert.strictEqual(regexLikeConfig.user, 'defaultuser');
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
|
||||
it('应该支持 Host 中的否定模式', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'negated-pattern-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'Host *.example.com !blocked.example.com',
|
||||
' User wildcarduser',
|
||||
'',
|
||||
'Host *',
|
||||
' User defaultuser',
|
||||
].join('\n'));
|
||||
|
||||
const allowedConfig = lookupSshConfig('app.example.com', tempConfig);
|
||||
const blockedConfig = lookupSshConfig('blocked.example.com', tempConfig);
|
||||
|
||||
assert.ok(allowedConfig);
|
||||
assert.strictEqual(allowedConfig.user, 'wildcarduser');
|
||||
assert.ok(blockedConfig);
|
||||
assert.strictEqual(blockedConfig.user, 'defaultuser');
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Include 指令', () => {
|
||||
it('应该正确处理 Include 指令', () => {
|
||||
const config = lookupSshConfig('included-host', testConfigWithIncludePath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '172.16.0.1');
|
||||
assert.strictEqual(config.port, 3333);
|
||||
assert.strictEqual(config.user, 'includeduser');
|
||||
});
|
||||
|
||||
it('应该在 Include 后继续解析主配置文件', () => {
|
||||
const config = lookupSshConfig('main-host', testConfigWithIncludePath);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '192.168.1.1');
|
||||
assert.strictEqual(config.user, 'mainuser');
|
||||
assert.strictEqual(config.port, 22);
|
||||
});
|
||||
|
||||
it('应该静默跳过不存在的 Include 文件', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'temp-include-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'Include /nonexistent/path/config',
|
||||
'',
|
||||
'Host test',
|
||||
' HostName 1.2.3.4',
|
||||
].join('\n'));
|
||||
|
||||
const config = lookupSshConfig('test', tempConfig);
|
||||
assert.ok(config, '应该返回非 null 的配置');
|
||||
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('默认配置文件不存在时应返回 null', () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(fixturesDir, 'fake-home-'));
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
try {
|
||||
const config = lookupSshConfig('any-host');
|
||||
assert.strictEqual(config, null);
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('显式指定的配置文件不存在时应抛出错误', () => {
|
||||
assert.throws(() => {
|
||||
lookupSshConfig('any-host', '/nonexistent/config');
|
||||
}, /not found/);
|
||||
});
|
||||
|
||||
it('未找到匹配的 Host 时应返回 null', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'empty-config');
|
||||
fs.writeFileSync(tempConfig, '# Empty config\n');
|
||||
|
||||
const config = lookupSshConfig('any-host', tempConfig);
|
||||
assert.strictEqual(config, null);
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
|
||||
it('应该正确展开 ~ 路径', () => {
|
||||
const config = lookupSshConfig('dev', testConfigPath);
|
||||
assert.ok(config);
|
||||
assert.ok(config.identityFile.startsWith(os.homedir()));
|
||||
assert.ok(!config.identityFile.includes('~'));
|
||||
});
|
||||
|
||||
it('应该正确处理注释行', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'comment-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'# 这是注释',
|
||||
'Host test',
|
||||
' HostName 1.2.3.4 # 行内注释',
|
||||
' Port 2222',
|
||||
].join('\n'));
|
||||
|
||||
const config = lookupSshConfig('test', tempConfig);
|
||||
assert.ok(config);
|
||||
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||
assert.strictEqual(config.port, 2222);
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
|
||||
it('应该正确处理空白行和缩进', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'whitespace-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'',
|
||||
' Host test ',
|
||||
' HostName 1.2.3.4 ',
|
||||
'',
|
||||
' Port 2222 ',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const config = lookupSshConfig('test', tempConfig);
|
||||
assert.ok(config);
|
||||
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||
assert.strictEqual(config.port, 2222);
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('First-match-wins 语义', () => {
|
||||
it('应该使用第一个匹配的值', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'priority-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'Host test',
|
||||
' Port 2222',
|
||||
' User firstuser',
|
||||
'',
|
||||
'Host test',
|
||||
' Port 3333',
|
||||
' User seconduser',
|
||||
'',
|
||||
'Host *',
|
||||
' Port 22',
|
||||
].join('\n'));
|
||||
|
||||
const config = lookupSshConfig('test', tempConfig);
|
||||
assert.ok(config);
|
||||
assert.strictEqual(config.port, 2222);
|
||||
assert.strictEqual(config.user, 'firstuser');
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
|
||||
it('特定 Host 的值应优先于 Host *', () => {
|
||||
const tempConfig = path.join(fixturesDir, 'specific-priority-config');
|
||||
fs.writeFileSync(tempConfig, [
|
||||
'Host specific',
|
||||
' Port 2222',
|
||||
'',
|
||||
'Host *',
|
||||
' Port 22',
|
||||
' User globaluser',
|
||||
].join('\n'));
|
||||
|
||||
const config = lookupSshConfig('specific', tempConfig);
|
||||
assert.ok(config);
|
||||
assert.strictEqual(config.port, 2222); // 来自 Host specific
|
||||
assert.strictEqual(config.user, 'globaluser'); // 来自 Host *
|
||||
|
||||
fs.unlinkSync(tempConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { collectSystemStatus } from '../build/utils/status-collector.js';
|
||||
|
||||
const PROBE_PATTERN = /printf '\\n(__MCP_FIELD_\w+_)(\w+)\\n'/g;
|
||||
|
||||
/** Field names in the order the script probes them, plus the shared marker. */
|
||||
function readProbes(script) {
|
||||
const probes = [...script.matchAll(PROBE_PATTERN)];
|
||||
return {
|
||||
marker: probes[0]?.[1],
|
||||
fields: probes.map((probe) => probe[2]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for the remote shell: emits the marker line for every probe the
|
||||
* script declares, followed by whatever `values` supplies for it.
|
||||
*/
|
||||
function fakeRemote(values, { lineEnding = '\n' } = {}) {
|
||||
return (script) => {
|
||||
const { marker, fields } = readProbes(script);
|
||||
const output = fields
|
||||
.map((field) => `${marker}${field}${lineEnding}${values[field] ?? ''}`)
|
||||
.join(lineEnding);
|
||||
return Promise.resolve(lineEnding + output);
|
||||
};
|
||||
}
|
||||
|
||||
describe('status collector', () => {
|
||||
it('所有探针合并为一条单行命令', async () => {
|
||||
const scripts = [];
|
||||
await collectSystemStatus((script) => {
|
||||
scripts.push(script);
|
||||
return Promise.resolve('');
|
||||
}, 'dev');
|
||||
|
||||
assert.strictEqual(scripts.length, 1);
|
||||
// 多行脚本会被 commandTemplate 的引号包裹破坏
|
||||
assert.ok(!scripts[0].includes('\n'));
|
||||
// 每个探针都被隔离,单个失败不影响其余,且整体以成功退出
|
||||
assert.ok(scripts[0].endsWith('; true'));
|
||||
|
||||
const { fields } = readProbes(scripts[0]);
|
||||
assert.ok(fields.includes('hostname'));
|
||||
assert.ok(fields.includes('servicesInstalled'));
|
||||
assert.strictEqual(new Set(fields).size, fields.length);
|
||||
});
|
||||
|
||||
it('按 marker 还原各字段', async () => {
|
||||
const status = await collectSystemStatus(
|
||||
fakeRemote({
|
||||
hostname: 'web-01',
|
||||
osName: 'Linux',
|
||||
kernelVersion: '6.1.0',
|
||||
memory: 'free:2.1G total:7.7G',
|
||||
processes: '181',
|
||||
threads: '901',
|
||||
}),
|
||||
'dev',
|
||||
);
|
||||
|
||||
assert.strictEqual(status.reachable, true);
|
||||
assert.strictEqual(status.hostname, 'web-01');
|
||||
assert.strictEqual(status.osName, 'Linux');
|
||||
assert.strictEqual(status.kernelVersion, '6.1.0');
|
||||
assert.deepStrictEqual(status.memory, { free: '2.1G', total: '7.7G' });
|
||||
// 解析时会各减去一行表头
|
||||
assert.deepStrictEqual(status.processes, { running: 180, threads: 900 });
|
||||
});
|
||||
|
||||
// 多行字段是 CRLF 归一化真正起作用的地方:逐行拆分的值不会再单独 trim,
|
||||
// 残留的 \r 会直接进入结果。
|
||||
it('pty 的 CRLF 输出同样能解析', async () => {
|
||||
const status = await collectSystemStatus(
|
||||
fakeRemote(
|
||||
{
|
||||
hostname: 'web-02',
|
||||
ipAddresses: '10.0.0.7\r\n192.168.1.5',
|
||||
drives: '/dev/sda1|50G|20G|30G|40%|/',
|
||||
},
|
||||
{ lineEnding: '\r\n' },
|
||||
),
|
||||
'dev',
|
||||
);
|
||||
|
||||
assert.strictEqual(status.hostname, 'web-02');
|
||||
assert.deepStrictEqual(status.ipAddresses, ['10.0.0.7', '192.168.1.5']);
|
||||
assert.deepStrictEqual(status.drives, [
|
||||
{
|
||||
device: '/dev/sda1',
|
||||
total: '50G',
|
||||
used: '20G',
|
||||
free: '30G',
|
||||
usagePercent: '40%',
|
||||
mountPoint: '/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('空探针不影响其它字段', async () => {
|
||||
const status = await collectSystemStatus(
|
||||
fakeRemote({ hostname: 'web-03', ipAddresses: '', osName: 'Linux' }),
|
||||
'dev',
|
||||
);
|
||||
|
||||
assert.strictEqual(status.hostname, 'web-03');
|
||||
assert.strictEqual(status.osName, 'Linux');
|
||||
assert.strictEqual(status.ipAddresses, undefined);
|
||||
});
|
||||
|
||||
// 命令白名单会拒绝这条探针脚本;那种情况下字段留空即可,
|
||||
// 不能把主机报成不可达。
|
||||
it('命令被拒绝时字段留空但仍视为可达', async () => {
|
||||
const status = await collectSystemStatus(
|
||||
() => Promise.reject(new Error('Command validation failed')),
|
||||
'dev',
|
||||
);
|
||||
|
||||
assert.strictEqual(status.reachable, true);
|
||||
assert.strictEqual(status.hostname, undefined);
|
||||
assert.ok(status.lastUpdated);
|
||||
});
|
||||
|
||||
it('先逐条授权探针,再只批量执行允许的命令', async () => {
|
||||
const scripts = [];
|
||||
const recordingRemote = fakeRemote({ hostname: 'allowed-host' });
|
||||
const status = await collectSystemStatus(
|
||||
(script) => {
|
||||
scripts.push(script);
|
||||
return recordingRemote(script);
|
||||
},
|
||||
'dev',
|
||||
(command) => command === 'hostname',
|
||||
);
|
||||
|
||||
assert.strictEqual(status.hostname, 'allowed-host');
|
||||
assert.strictEqual(scripts.length, 1);
|
||||
assert.match(scripts[0], /hostname/);
|
||||
assert.ok(!scripts[0].includes('uname -s'));
|
||||
assert.ok(!scripts[0].includes('cat /etc/os-release'));
|
||||
});
|
||||
|
||||
it('没有获准探针时不执行远端脚本', async () => {
|
||||
let calls = 0;
|
||||
const status = await collectSystemStatus(
|
||||
async () => {
|
||||
calls += 1;
|
||||
return '';
|
||||
},
|
||||
'dev',
|
||||
() => false,
|
||||
);
|
||||
|
||||
assert.strictEqual(calls, 0);
|
||||
assert.strictEqual(status.reachable, true);
|
||||
assert.strictEqual(status.hostname, undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user