feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { BashPermissionChecker } from '../../../src/permission/checkers/bash.js';
|
||||
import type { PermissionDecision, PermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock fs 和 path 以避免实际文件操作
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BashPermissionChecker - Bash 权限检查器', () => {
|
||||
let checker: BashPermissionChecker;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
checker = new BashPermissionChecker(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('默认配置', () => {
|
||||
it('加载默认配置', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.rules).toBeDefined();
|
||||
expect(config.rules.length).toBeGreaterThan(0);
|
||||
expect(config.default).toBe('ask');
|
||||
expect(config.externalDirectory).toBe('ask');
|
||||
});
|
||||
|
||||
it('默认规则包含安全命令', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
// 检查一些默认允许的规则
|
||||
const allowRules = config.rules.filter(r => r.action === 'allow');
|
||||
const patterns = allowRules.map(r => r.pattern);
|
||||
|
||||
expect(patterns.some(p => p.startsWith('ls'))).toBe(true);
|
||||
expect(patterns.some(p => p.startsWith('cat'))).toBe(true);
|
||||
expect(patterns.some(p => p.startsWith('git status'))).toBe(true);
|
||||
});
|
||||
|
||||
it('默认规则包含危险命令', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
const denyRules = config.rules.filter(r => r.action === 'deny');
|
||||
const patterns = denyRules.map(r => r.pattern);
|
||||
|
||||
expect(patterns.some(p => p.includes('rm -rf'))).toBe(true);
|
||||
expect(patterns.some(p => p.includes('sudo'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('安全命令检查(默认允许)', () => {
|
||||
const safeCommands = [
|
||||
'ls -la',
|
||||
'cat file.txt',
|
||||
'head -n 10 log.txt',
|
||||
'tail -f server.log',
|
||||
'grep pattern file.txt',
|
||||
'find . -name "*.js"',
|
||||
'echo hello',
|
||||
'pwd',
|
||||
'which node',
|
||||
'git status',
|
||||
'git log --oneline',
|
||||
'git diff HEAD',
|
||||
];
|
||||
|
||||
for (const command of safeCommands) {
|
||||
it(`允许安全命令: ${command}`, async () => {
|
||||
const result = await checker.check({
|
||||
command,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('危险命令检查(默认拒绝)', () => {
|
||||
// 这些命令精确匹配默认拒绝规则
|
||||
const denyCommands = [
|
||||
'sudo rm file',
|
||||
];
|
||||
|
||||
for (const command of denyCommands) {
|
||||
it(`拒绝危险命令: ${command}`, async () => {
|
||||
const result = await checker.check({
|
||||
command,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
}
|
||||
|
||||
// 这些命令可能匹配 ask 规则或默认行为
|
||||
const askCommands = [
|
||||
'rm -rf /',
|
||||
'rm -rf /*',
|
||||
'chmod 777 /',
|
||||
];
|
||||
|
||||
for (const command of askCommands) {
|
||||
it(`危险命令需要确认或拒绝: ${command}`, async () => {
|
||||
const result = await checker.check({
|
||||
command,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 这些命令应该不允许直接执行
|
||||
expect(result.allowed).toBe(false);
|
||||
// 可能是 deny 或 ask(取决于具体规则匹配)
|
||||
expect(['deny', 'ask']).toContain(result.action);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('需要确认的命令', () => {
|
||||
const askCommands = [
|
||||
'git push origin main',
|
||||
'git commit -m "test"',
|
||||
'git checkout feature',
|
||||
'npm install lodash',
|
||||
];
|
||||
|
||||
for (const command of askCommands) {
|
||||
it(`需要确认: ${command}`, async () => {
|
||||
// 不设置回调,应该返回 ask
|
||||
const result = await checker.check({
|
||||
command,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('回调处理', () => {
|
||||
it('用户允许时返回允许', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.check({
|
||||
command: 'git push origin main',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户拒绝时返回拒绝', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.check({
|
||||
command: 'git push origin main',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
|
||||
it('remember=true 时记住决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.check({
|
||||
command: 'git push origin main',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 第二次调用应该不再询问
|
||||
const result = await checker.check({
|
||||
command: 'git commit -m "test"',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 记住的是整个模式,第二次可能仍需询问(取决于实现)
|
||||
expect(result.allowed).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('清除会话权限', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.check({
|
||||
command: 'git push origin main',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 清除会话权限
|
||||
checker.clearSessionPermissions();
|
||||
|
||||
// 再次调用应该重新询问
|
||||
await checker.check({
|
||||
command: 'git push origin main',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 应该被调用两次
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('规则管理', () => {
|
||||
it('添加新规则', () => {
|
||||
checker.addRule({
|
||||
pattern: 'custom-cmd *',
|
||||
action: 'allow',
|
||||
});
|
||||
|
||||
const config = checker.getConfig();
|
||||
const hasRule = config.rules.some(r => r.pattern === 'custom-cmd *');
|
||||
expect(hasRule).toBe(true);
|
||||
});
|
||||
|
||||
it('更新已有规则', () => {
|
||||
// 添加规则
|
||||
checker.addRule({
|
||||
pattern: 'test-cmd',
|
||||
action: 'allow',
|
||||
});
|
||||
|
||||
// 更新规则
|
||||
checker.addRule({
|
||||
pattern: 'test-cmd',
|
||||
action: 'deny',
|
||||
});
|
||||
|
||||
const config = checker.getConfig();
|
||||
const rule = config.rules.find(r => r.pattern === 'test-cmd');
|
||||
expect(rule?.action).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('项目目录检查', () => {
|
||||
it('识别项目内路径', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'cat ./src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 项目内路径应该正常检查
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('识别项目外路径', async () => {
|
||||
// 访问项目外的绝对路径
|
||||
const result = await checker.check({
|
||||
command: 'cat /etc/passwd',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 外部路径可能需要确认或拒绝
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommandSimple } from '../../../src/permission/bash-parser.js';
|
||||
|
||||
// 注意:parseBashCommand 需要初始化 tree-sitter wasm,在单元测试中使用 parseCommandSimple
|
||||
|
||||
describe('parseCommandSimple - 简单命令解析', () => {
|
||||
describe('基本命令解析', () => {
|
||||
it('解析单个命令', () => {
|
||||
const result = parseCommandSimple('ls');
|
||||
|
||||
expect(result.name).toBe('ls');
|
||||
expect(result.subcommand).toBeUndefined();
|
||||
expect(result.args).toEqual([]);
|
||||
expect(result.text).toBe('ls');
|
||||
});
|
||||
|
||||
it('解析带子命令的命令', () => {
|
||||
const result = parseCommandSimple('git status');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('status');
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it('解析带参数的命令', () => {
|
||||
const result = parseCommandSimple('git commit -m "message"');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('commit');
|
||||
expect(result.args).toContain('-m');
|
||||
});
|
||||
|
||||
it('解析带 flag 前缀的子命令', () => {
|
||||
const result = parseCommandSimple('rm -rf node_modules');
|
||||
|
||||
expect(result.name).toBe('rm');
|
||||
// -rf 以 - 开头,所以 node_modules 是第一个非 flag 参数
|
||||
expect(result.subcommand).toBe('node_modules');
|
||||
expect(result.args).toContain('-rf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag 和参数分离', () => {
|
||||
it('短 flag 放入 args', () => {
|
||||
const result = parseCommandSimple('ls -la');
|
||||
|
||||
expect(result.name).toBe('ls');
|
||||
expect(result.subcommand).toBeUndefined();
|
||||
expect(result.args).toContain('-la');
|
||||
});
|
||||
|
||||
it('长 flag 放入 args', () => {
|
||||
const result = parseCommandSimple('npm install --save-dev');
|
||||
|
||||
expect(result.name).toBe('npm');
|
||||
expect(result.subcommand).toBe('install');
|
||||
expect(result.args).toContain('--save-dev');
|
||||
});
|
||||
|
||||
it('混合 flag 和参数', () => {
|
||||
const result = parseCommandSimple('git checkout -b feature-branch');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('checkout');
|
||||
expect(result.args).toContain('-b');
|
||||
expect(result.args).toContain('feature-branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('空白处理', () => {
|
||||
it('处理前后空格', () => {
|
||||
const result = parseCommandSimple(' git status ');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('status');
|
||||
});
|
||||
|
||||
it('处理多个空格分隔', () => {
|
||||
const result = parseCommandSimple('git status -v');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('status');
|
||||
expect(result.args).toContain('-v');
|
||||
});
|
||||
|
||||
it('空字符串返回空命令', () => {
|
||||
const result = parseCommandSimple('');
|
||||
|
||||
expect(result.name).toBe('');
|
||||
expect(result.subcommand).toBeUndefined();
|
||||
expect(result.args).toEqual([]);
|
||||
});
|
||||
|
||||
it('只有空格返回空命令', () => {
|
||||
const result = parseCommandSimple(' ');
|
||||
|
||||
expect(result.name).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('复杂命令', () => {
|
||||
it('解析 git push 命令', () => {
|
||||
const result = parseCommandSimple('git push origin main --force');
|
||||
|
||||
expect(result.name).toBe('git');
|
||||
expect(result.subcommand).toBe('push');
|
||||
expect(result.args).toContain('origin');
|
||||
expect(result.args).toContain('main');
|
||||
expect(result.args).toContain('--force');
|
||||
});
|
||||
|
||||
it('解析 npm 命令', () => {
|
||||
const result = parseCommandSimple('npm run build -- --watch');
|
||||
|
||||
expect(result.name).toBe('npm');
|
||||
expect(result.subcommand).toBe('run');
|
||||
expect(result.args).toContain('build');
|
||||
});
|
||||
|
||||
it('解析 docker 命令', () => {
|
||||
const result = parseCommandSimple('docker run -d -p 8080:80 nginx');
|
||||
|
||||
expect(result.name).toBe('docker');
|
||||
expect(result.subcommand).toBe('run');
|
||||
expect(result.args).toContain('-d');
|
||||
expect(result.args).toContain('-p');
|
||||
});
|
||||
|
||||
it('解析管道前的命令', () => {
|
||||
// 简单解析不处理管道,只作为普通参数
|
||||
const result = parseCommandSimple('cat file.txt');
|
||||
|
||||
expect(result.name).toBe('cat');
|
||||
expect(result.subcommand).toBe('file.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('保留原始文本', () => {
|
||||
it('text 字段保留原始命令', () => {
|
||||
const original = 'git commit -m "fix bug"';
|
||||
const result = parseCommandSimple(original);
|
||||
|
||||
expect(result.text).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParsedCommand 结构', () => {
|
||||
it('包含所有必要字段', () => {
|
||||
const result = parseCommandSimple('git push');
|
||||
|
||||
expect(result).toHaveProperty('name');
|
||||
expect(result).toHaveProperty('subcommand');
|
||||
expect(result).toHaveProperty('args');
|
||||
expect(result).toHaveProperty('text');
|
||||
});
|
||||
|
||||
it('args 始终是数组', () => {
|
||||
const result = parseCommandSimple('ls');
|
||||
|
||||
expect(Array.isArray(result.args)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FilePermissionChecker } from '../../../src/permission/checkers/file.js';
|
||||
import type { PermissionDecision, FilePermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock fs 以避免实际文件操作
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock file-prompt
|
||||
vi.mock('../../../src/permission/file-prompt.js', () => ({
|
||||
promptFilePermission: vi.fn().mockResolvedValue({ allow: true, remember: false }),
|
||||
}));
|
||||
|
||||
describe('FilePermissionChecker - 文件权限检查器', () => {
|
||||
let checker: FilePermissionChecker;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
checker = new FilePermissionChecker(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('默认配置', () => {
|
||||
it('加载默认配置', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations).toBeDefined();
|
||||
expect(config.sensitivePaths).toBeDefined();
|
||||
expect(config.externalDirectory).toBe('ask');
|
||||
});
|
||||
|
||||
it('读操作默认允许', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations.read).toBe('allow');
|
||||
expect(config.operations.list).toBe('allow');
|
||||
expect(config.operations.search).toBe('allow');
|
||||
expect(config.operations.grep).toBe('allow');
|
||||
expect(config.operations.info).toBe('allow');
|
||||
});
|
||||
|
||||
it('写操作默认需要确认', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations.write).toBe('ask');
|
||||
expect(config.operations.edit).toBe('ask');
|
||||
expect(config.operations.move).toBe('ask');
|
||||
expect(config.operations.copy).toBe('ask');
|
||||
expect(config.operations.delete).toBe('ask');
|
||||
expect(config.operations.mkdir).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('读操作权限', () => {
|
||||
const readOperations: FilePermissionContext['operation'][] = [
|
||||
'read',
|
||||
'list',
|
||||
'search',
|
||||
'grep',
|
||||
'info',
|
||||
];
|
||||
|
||||
for (const operation of readOperations) {
|
||||
it(`${operation} 操作在项目内默认允许`, async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation,
|
||||
path: './src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('写操作权限', () => {
|
||||
const writeOperations: FilePermissionContext['operation'][] = [
|
||||
'write',
|
||||
'edit',
|
||||
'move',
|
||||
'copy',
|
||||
'delete',
|
||||
'mkdir',
|
||||
];
|
||||
|
||||
for (const operation of writeOperations) {
|
||||
it(`${operation} 操作无回调时需要确认`, async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation,
|
||||
path: './src/new-file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('敏感路径检查', () => {
|
||||
it('系统路径拒绝访问', async () => {
|
||||
const sensitivePaths = [
|
||||
'/etc/passwd',
|
||||
'/usr/bin/node',
|
||||
'/bin/sh',
|
||||
'/var/log/syslog',
|
||||
];
|
||||
|
||||
for (const testPath of sensitivePaths) {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: testPath,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
}
|
||||
});
|
||||
|
||||
it('用户敏感文件需要确认', async () => {
|
||||
const sensitivePaths = [
|
||||
'~/.ssh/id_rsa',
|
||||
'~/.aws/credentials',
|
||||
'./.env',
|
||||
];
|
||||
|
||||
for (const testPath of sensitivePaths) {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: testPath,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 敏感路径会触发 ask
|
||||
expect(result.action === 'ask' || result.action === 'deny').toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('外部目录访问', () => {
|
||||
it('项目外路径需要确认', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: '/home/other/file.txt',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 外部目录会触发 ask 或 deny
|
||||
expect(result.action === 'ask' || result.action === 'deny').toBe(true);
|
||||
});
|
||||
|
||||
it('项目内路径正常检查', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: './src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('波浪号展开', () => {
|
||||
it('展开 ~ 为 home 目录', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: '~/projects/file.txt',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 外部路径会触发 ask
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('回调处理', () => {
|
||||
it('用户允许时返回允许', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/new-file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('用户拒绝时返回拒绝', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('记住允许决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 第二次调用同一操作和路径
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
// 第二次不应该调用回调
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('记住拒绝决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/important.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 第二次调用
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/important.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('清除会话权限后重新询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 清除权限
|
||||
checker.clearSessionPermissions();
|
||||
|
||||
// 再次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 应该调用两次
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check 接口(兼容 PermissionChecker)', () => {
|
||||
it('解析 read 操作', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'read ./src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('解析 write 操作', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'write ./src/new.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
promptFileWrite,
|
||||
promptFileEdit,
|
||||
promptFilePermission,
|
||||
} from '../../../src/permission/file-prompt.js';
|
||||
import type { FilePermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock diff utils
|
||||
vi.mock('../../../src/utils/diff.js', () => ({
|
||||
computeDiff: vi.fn(() => ({
|
||||
isNew: false,
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
hunks: [],
|
||||
})),
|
||||
formatDiff: vi.fn(() => 'diff output'),
|
||||
countChanges: vi.fn(() => ({ additions: 5, deletions: 3 })),
|
||||
formatEditDiff: vi.fn(() => 'edit diff output'),
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('File Prompt - 文件操作提示', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('promptFileWrite - 文件写入提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('内容相同时直接允许', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('new content');
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('新文件显示新增行数', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: true,
|
||||
oldContent: null,
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 10, deletions: 0 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('新文件');
|
||||
expect(calls).toContain('+10 行');
|
||||
});
|
||||
|
||||
it('修改文件显示增删行数', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: false,
|
||||
oldContent: 'old content',
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 5, deletions: 3 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('+5 行');
|
||||
expect(calls).toContain('-3 行');
|
||||
});
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认拒绝', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('超长 diff 被截断', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
// 模拟超过 50 行的 diff
|
||||
const longDiff = Array(100).fill('line').join('\n');
|
||||
const { formatDiff } = await import('../../../src/utils/diff.js');
|
||||
vi.mocked(formatDiff).mockReturnValue(longDiff);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('省略');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFileEdit - 文件编辑提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old text',
|
||||
newContent: 'new text',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
oldContent: undefined,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('显示编辑 diff', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileEdit(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
expect(calls).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('用户确认后返回决定', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFilePermission - 统一入口', () => {
|
||||
it('write 操作调用 promptFileWrite', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'same content',
|
||||
};
|
||||
|
||||
const result = await promptFilePermission(ctx);
|
||||
|
||||
// 内容相同直接允许
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('edit 操作调用 promptFileEdit', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
});
|
||||
|
||||
it('其他操作使用简单确认', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'delete',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'delete_file',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件操作确认');
|
||||
});
|
||||
});
|
||||
|
||||
describe('确认选项显示', () => {
|
||||
it('显示所有选项', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('[y]');
|
||||
expect(calls).toContain('[Y]');
|
||||
expect(calls).toContain('[n]');
|
||||
expect(calls).toContain('[N]');
|
||||
expect(calls).toContain('确认执行');
|
||||
expect(calls).toContain('拒绝执行');
|
||||
expect(calls).toContain('记住');
|
||||
});
|
||||
});
|
||||
|
||||
describe('输入处理', () => {
|
||||
it('输入被 trim', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { GitPermissionChecker } from '../../../src/permission/checkers/git.js';
|
||||
import type { GitPermissionContext, PermissionDecision } from '../../../src/permission/types.js';
|
||||
|
||||
describe('GitPermissionChecker - Git 权限检查器', () => {
|
||||
let checker: GitPermissionChecker;
|
||||
|
||||
beforeEach(() => {
|
||||
checker = new GitPermissionChecker();
|
||||
});
|
||||
|
||||
describe('读操作(默认允许)', () => {
|
||||
const readOperations: GitPermissionContext['operation'][] = [
|
||||
'status',
|
||||
'diff',
|
||||
'log',
|
||||
'branch_list',
|
||||
'show',
|
||||
];
|
||||
|
||||
for (const operation of readOperations) {
|
||||
it(`${operation} 默认允许`, async () => {
|
||||
const result = await checker.checkGitPermission({ operation });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('写操作(默认询问)', () => {
|
||||
const writeOperations: GitPermissionContext['operation'][] = [
|
||||
'add',
|
||||
'commit',
|
||||
'push',
|
||||
'pull',
|
||||
'checkout',
|
||||
'branch_create',
|
||||
'branch_delete',
|
||||
'stash',
|
||||
'stash_pop',
|
||||
'merge',
|
||||
'rebase',
|
||||
];
|
||||
|
||||
for (const operation of writeOperations) {
|
||||
it(`${operation} 无回调时需要确认`, async () => {
|
||||
const result = await checker.checkGitPermission({ operation });
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('危险操作', () => {
|
||||
it('reset 总是危险操作', async () => {
|
||||
const result = await checker.checkGitPermission({ operation: 'reset' });
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
|
||||
it('push --force 是危险操作', async () => {
|
||||
const result = await checker.checkGitPermission({
|
||||
operation: 'push',
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
|
||||
it('checkout --force 是危险操作', async () => {
|
||||
const result = await checker.checkGitPermission({
|
||||
operation: 'checkout',
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
|
||||
it('rebase --force 是危险操作', async () => {
|
||||
const result = await checker.checkGitPermission({
|
||||
operation: 'rebase',
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('回调处理', () => {
|
||||
it('用户允许时返回允许', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户拒绝时返回拒绝', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkGitPermission({ operation: 'push' });
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
|
||||
it('remember=true 时记住决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
// 第二次调用不应该再询问
|
||||
const secondResult = await checker.checkGitPermission({ operation: 'push' });
|
||||
|
||||
expect(secondResult.allowed).toBe(true);
|
||||
// 第二次不需要回调(因为记住了写操作的权限)
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('清除会话权限后重新询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用并记住
|
||||
await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
// 清除会话权限
|
||||
checker.clearSessionPermissions();
|
||||
|
||||
// 再次调用应该重新询问
|
||||
await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('拒绝决定也被记住', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次拒绝并记住
|
||||
await checker.checkGitPermission({ operation: 'push' });
|
||||
|
||||
// 第二次直接拒绝
|
||||
const result = await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('配置管理', () => {
|
||||
it('获取默认配置', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.readOperations).toBe('allow');
|
||||
expect(config.writeOperations).toBe('ask');
|
||||
expect(config.dangerousOperations).toBe('ask');
|
||||
});
|
||||
|
||||
it('更新配置', () => {
|
||||
checker.setConfig({ writeOperations: 'allow' });
|
||||
|
||||
const config = checker.getConfig();
|
||||
expect(config.writeOperations).toBe('allow');
|
||||
});
|
||||
|
||||
it('配置更改后影响权限检查', async () => {
|
||||
checker.setConfig({ writeOperations: 'allow' });
|
||||
|
||||
const result = await checker.checkGitPermission({ operation: 'commit' });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('配置危险操作为拒绝', async () => {
|
||||
checker.setConfig({ dangerousOperations: 'deny' });
|
||||
|
||||
const result = await checker.checkGitPermission({ operation: 'reset' });
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('操作描述生成', () => {
|
||||
it('带 target 的操作描述', async () => {
|
||||
checker.setAskCallback(vi.fn().mockResolvedValue({ allow: true, remember: false }));
|
||||
|
||||
await checker.checkGitPermission({
|
||||
operation: 'checkout',
|
||||
target: 'feature-branch',
|
||||
});
|
||||
|
||||
// 验证回调收到正确的描述
|
||||
const callArg = vi.mocked(checker['askCallback']!).mock.calls[0][0];
|
||||
expect(callArg.command).toContain('feature-branch');
|
||||
});
|
||||
|
||||
it('带 remote 的操作描述', async () => {
|
||||
checker.setAskCallback(vi.fn().mockResolvedValue({ allow: true, remember: false }));
|
||||
|
||||
await checker.checkGitPermission({
|
||||
operation: 'push',
|
||||
remote: 'origin',
|
||||
});
|
||||
|
||||
const callArg = vi.mocked(checker['askCallback']!).mock.calls[0][0];
|
||||
expect(callArg.command).toContain('origin');
|
||||
});
|
||||
|
||||
it('带 commit message 的操作描述(截断)', async () => {
|
||||
checker.setAskCallback(vi.fn().mockResolvedValue({ allow: true, remember: false }));
|
||||
|
||||
const longMessage = 'a'.repeat(100);
|
||||
await checker.checkGitPermission({
|
||||
operation: 'commit',
|
||||
message: longMessage,
|
||||
});
|
||||
|
||||
const callArg = vi.mocked(checker['askCallback']!).mock.calls[0][0];
|
||||
expect(callArg.command).toContain('...');
|
||||
expect(callArg.command.length).toBeLessThan(longMessage.length + 50);
|
||||
});
|
||||
|
||||
it('--force 显示在描述中', async () => {
|
||||
checker.setAskCallback(vi.fn().mockResolvedValue({ allow: true, remember: false }));
|
||||
|
||||
await checker.checkGitPermission({
|
||||
operation: 'push',
|
||||
force: true,
|
||||
});
|
||||
|
||||
const callArg = vi.mocked(checker['askCallback']!).mock.calls[0][0];
|
||||
expect(callArg.command).toContain('--force');
|
||||
});
|
||||
});
|
||||
|
||||
describe('check 接口(从命令解析)', () => {
|
||||
it('解析 git status', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'git status',
|
||||
workdir: '/test',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('解析 git_commit', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'git_commit',
|
||||
workdir: '/test',
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
|
||||
it('无法解析的命令返回拒绝', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'invalid command',
|
||||
workdir: '/test',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('无法解析');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
PermissionManager,
|
||||
getPermissionManager,
|
||||
resetPermissionManager,
|
||||
} from '../../../src/permission/manager.js';
|
||||
import type { PermissionDecision, PermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock 检查器以避免文件系统操作
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/permission/file-prompt.js', () => ({
|
||||
promptFilePermission: vi.fn().mockResolvedValue({ allow: true, remember: false }),
|
||||
}));
|
||||
|
||||
describe('PermissionManager - 权限管理器', () => {
|
||||
let manager: PermissionManager;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new PermissionManager(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('创建时注册默认检查器', () => {
|
||||
// 应该包含 bash, file, web, git 检查器
|
||||
expect(manager.getChecker('bash')).toBeDefined();
|
||||
expect(manager.getChecker('file')).toBeDefined();
|
||||
expect(manager.getChecker('web')).toBeDefined();
|
||||
expect(manager.getChecker('git')).toBeDefined();
|
||||
});
|
||||
|
||||
it('未注册的检查器返回 undefined', () => {
|
||||
expect(manager.getChecker('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerChecker', () => {
|
||||
it('注册自定义检查器', () => {
|
||||
const customChecker = {
|
||||
name: 'custom',
|
||||
check: vi.fn().mockResolvedValue({ allowed: true, action: 'allow' }),
|
||||
clearSessionPermissions: vi.fn(),
|
||||
};
|
||||
|
||||
manager.registerChecker(customChecker);
|
||||
|
||||
expect(manager.getChecker('custom')).toBe(customChecker);
|
||||
});
|
||||
|
||||
it('覆盖已有检查器', () => {
|
||||
const newBashChecker = {
|
||||
name: 'bash',
|
||||
check: vi.fn().mockResolvedValue({ allowed: false, action: 'deny' }),
|
||||
clearSessionPermissions: vi.fn(),
|
||||
};
|
||||
|
||||
manager.registerChecker(newBashChecker);
|
||||
|
||||
expect(manager.getChecker('bash')).toBe(newBashChecker);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAskCallback', () => {
|
||||
it('设置回调传递给所有支持回调的检查器', () => {
|
||||
const callback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
manager.setAskCallback(callback);
|
||||
|
||||
// 验证回调被设置(通过间接方式)
|
||||
expect(callback).not.toHaveBeenCalled(); // 设置时不调用
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission', () => {
|
||||
it('使用指定检查器检查权限', async () => {
|
||||
const result = await manager.checkPermission('bash', {
|
||||
command: 'ls -la',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('未注册的检查器返回 ask', async () => {
|
||||
const result = await manager.checkPermission('non-existent', {
|
||||
command: 'some command',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
expect(result.reason).toContain('未找到检查器');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBashPermission - 便捷方法', () => {
|
||||
it('检查安全命令', async () => {
|
||||
const result = await manager.checkBashPermission({
|
||||
command: 'git status',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('检查危险命令', async () => {
|
||||
const result = await manager.checkBashPermission({
|
||||
command: 'rm -rf /',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkFilePermission - 便捷方法', () => {
|
||||
it('检查读操作', async () => {
|
||||
const result = await manager.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: './src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('检查写操作需要确认', async () => {
|
||||
const result = await manager.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/new-file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkGitPermission - 便捷方法', () => {
|
||||
it('检查读操作', async () => {
|
||||
const result = await manager.checkGitPermission({
|
||||
operation: 'status',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('检查写操作需要确认', async () => {
|
||||
const result = await manager.checkGitPermission({
|
||||
operation: 'push',
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkWebPermission - 便捷方法', () => {
|
||||
it('检查网页访问', async () => {
|
||||
const result = await manager.checkWebPermission({
|
||||
operation: 'fetch',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
// Web 检查器的默认行为
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('clearAllSessionPermissions 清除所有检查器的权限', () => {
|
||||
// 调用方法不应该抛出错误
|
||||
expect(() => manager.clearAllSessionPermissions()).not.toThrow();
|
||||
});
|
||||
|
||||
it('clearSessionPermissions 清除指定检查器的权限', () => {
|
||||
expect(() => manager.clearSessionPermissions('bash')).not.toThrow();
|
||||
expect(() => manager.clearSessionPermissions('file')).not.toThrow();
|
||||
expect(() => manager.clearSessionPermissions('git')).not.toThrow();
|
||||
});
|
||||
|
||||
it('清除不存在的检查器权限不报错', () => {
|
||||
expect(() => manager.clearSessionPermissions('non-existent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('全局实例管理', () => {
|
||||
beforeEach(() => {
|
||||
resetPermissionManager();
|
||||
});
|
||||
|
||||
it('getPermissionManager 返回单例', () => {
|
||||
const manager1 = getPermissionManager('/test/project');
|
||||
const manager2 = getPermissionManager();
|
||||
|
||||
expect(manager1).toBe(manager2);
|
||||
});
|
||||
|
||||
it('resetPermissionManager 重置单例', () => {
|
||||
const manager1 = getPermissionManager('/test/project');
|
||||
resetPermissionManager();
|
||||
const manager2 = getPermissionManager('/test/project');
|
||||
|
||||
expect(manager1).not.toBe(manager2);
|
||||
});
|
||||
|
||||
it('首次创建使用指定的 projectRoot', () => {
|
||||
const manager = getPermissionManager('/custom/root');
|
||||
|
||||
// 验证管理器已创建
|
||||
expect(manager).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promptPermission, showPermissionDenied, showPermissionAllowed } from '../../../src/permission/prompt.js';
|
||||
import type { PermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
|
||||
describe('Permission Prompt - 权限提示模块', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('showPermissionDenied - 显示权限被拒绝', () => {
|
||||
it('显示命令和原因', () => {
|
||||
showPermissionDenied('rm -rf /', '危险命令');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('权限被拒绝'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('rm -rf /'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('危险命令'));
|
||||
});
|
||||
|
||||
it('输出包含空行', () => {
|
||||
showPermissionDenied('test', 'reason');
|
||||
|
||||
// 第一个和最后一个调用是空行
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showPermissionAllowed - 显示权限允许', () => {
|
||||
it('显示执行的命令', () => {
|
||||
showPermissionAllowed('npm install');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('执行'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('npm install'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptPermission - 交互式权限提示', () => {
|
||||
const mockContext: PermissionContext = {
|
||||
command: 'git push',
|
||||
workdir: '/project',
|
||||
toolName: 'bash',
|
||||
};
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
expect(mockRl.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('空输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('带空格的输入会被 trim', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('显示命令和工作目录', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(mockContext);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('git push'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/project'));
|
||||
});
|
||||
|
||||
it('显示外部路径警告', async () => {
|
||||
const contextWithExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: ['/etc/passwd', '/root/.ssh'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithExternal);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('项目目录外的路径'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/etc/passwd'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/root/.ssh'));
|
||||
});
|
||||
|
||||
it('显示匹配模式', async () => {
|
||||
const contextWithPatterns: PermissionContext = {
|
||||
...mockContext,
|
||||
patterns: ['*.js', '*.ts'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithPatterns);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.js'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.ts'));
|
||||
});
|
||||
|
||||
it('不显示空的外部路径', async () => {
|
||||
const contextEmptyExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: [],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextEmptyExternal);
|
||||
|
||||
// 不应该显示外部路径相关的警告
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).not.toContain('项目目录外的路径');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { WebPermissionChecker } from '../../../src/permission/checkers/web.js';
|
||||
|
||||
describe('WebPermissionChecker - Web 权限检查器', () => {
|
||||
let checker: WebPermissionChecker;
|
||||
|
||||
beforeEach(() => {
|
||||
checker = new WebPermissionChecker();
|
||||
});
|
||||
|
||||
describe('构造和基本属性', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(checker.name).toBe('web');
|
||||
});
|
||||
|
||||
it('默认配置为 ask', () => {
|
||||
const config = checker.getConfig();
|
||||
expect(config.default).toBe('ask');
|
||||
});
|
||||
|
||||
it('默认允许高级搜索', () => {
|
||||
const config = checker.getConfig();
|
||||
expect(config.allowAdvancedSearch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkWebPermission - 检查 Web 权限', () => {
|
||||
it('默认策略为 allow 时允许', async () => {
|
||||
checker.setConfig({ default: 'allow' });
|
||||
|
||||
const result = await checker.checkWebPermission({ query: 'test search' });
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('默认策略为 deny 时拒绝', async () => {
|
||||
checker.setConfig({ default: 'deny' });
|
||||
|
||||
const result = await checker.checkWebPermission({ query: 'test search' });
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
|
||||
it('默认策略为 ask 时需要确认', async () => {
|
||||
checker.setConfig({ default: 'ask' });
|
||||
|
||||
const result = await checker.checkWebPermission({ query: 'test search' });
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
|
||||
it('不允许高级搜索时拒绝', async () => {
|
||||
checker.setConfig({ allowAdvancedSearch: false });
|
||||
|
||||
const result = await checker.checkWebPermission({
|
||||
query: 'test search',
|
||||
searchDepth: 'advanced',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('不允许深度搜索');
|
||||
});
|
||||
|
||||
it('主题不在允许列表时拒绝', async () => {
|
||||
checker.setConfig({ allowedTopics: ['general', 'news'] });
|
||||
|
||||
const result = await checker.checkWebPermission({
|
||||
query: 'test search',
|
||||
topic: 'finance',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('不允许搜索主题');
|
||||
});
|
||||
|
||||
it('主题在允许列表时通过', async () => {
|
||||
checker.setConfig({ default: 'allow', allowedTopics: ['general', 'news'] });
|
||||
|
||||
const result = await checker.checkWebPermission({
|
||||
query: 'test search',
|
||||
topic: 'news',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('空主题列表允许所有主题', async () => {
|
||||
checker.setConfig({ default: 'allow', allowedTopics: [] });
|
||||
|
||||
const result = await checker.checkWebPermission({
|
||||
query: 'test search',
|
||||
topic: 'any-topic',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('会话允许后不再询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ allow: true, remember: true });
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用,触发回调
|
||||
const result1 = await checker.checkWebPermission({ query: 'test1' });
|
||||
expect(result1.allowed).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 第二次调用,使用会话权限
|
||||
const result2 = await checker.checkWebPermission({ query: 'test2' });
|
||||
expect(result2.allowed).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1); // 不再调用
|
||||
});
|
||||
|
||||
it('会话拒绝后不再询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ allow: false, remember: true });
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
const result1 = await checker.checkWebPermission({ query: 'test1' });
|
||||
expect(result1.allowed).toBe(false);
|
||||
|
||||
// 第二次调用
|
||||
const result2 = await checker.checkWebPermission({ query: 'test2' });
|
||||
expect(result2.allowed).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('不记住权限时每次询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ allow: true, remember: false });
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
await checker.checkWebPermission({ query: 'test1' });
|
||||
await checker.checkWebPermission({ query: 'test2' });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('清除会话权限后重新询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ allow: true, remember: true });
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
await checker.checkWebPermission({ query: 'test1' });
|
||||
checker.clearSessionPermissions();
|
||||
await checker.checkWebPermission({ query: 'test2' });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check - 通用接口', () => {
|
||||
it('从 command 中提取查询', async () => {
|
||||
checker.setConfig({ default: 'allow' });
|
||||
|
||||
const result = await checker.check({
|
||||
command: 'web_search: test query',
|
||||
workdir: '/test',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('配置管理', () => {
|
||||
it('getConfig 返回配置副本', () => {
|
||||
const config1 = checker.getConfig();
|
||||
config1.default = 'deny';
|
||||
|
||||
const config2 = checker.getConfig();
|
||||
expect(config2.default).toBe('ask'); // 原配置不变
|
||||
});
|
||||
|
||||
it('setConfig 部分更新配置', () => {
|
||||
checker.setConfig({ allowAdvancedSearch: false });
|
||||
|
||||
const config = checker.getConfig();
|
||||
expect(config.allowAdvancedSearch).toBe(false);
|
||||
expect(config.default).toBe('ask'); // 其他配置不变
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchPattern, matchRules, parseCommand, generateAskPattern } from '../../../src/permission/wildcard.js';
|
||||
|
||||
describe('matchPattern - 通配符模式匹配', () => {
|
||||
describe('* 通配符', () => {
|
||||
it('匹配任意字符', () => {
|
||||
expect(matchPattern('git diff', 'git diff*')).toBe(true);
|
||||
expect(matchPattern('git diff --staged', 'git diff*')).toBe(true);
|
||||
expect(matchPattern('git diff HEAD~1', 'git diff*')).toBe(true);
|
||||
});
|
||||
|
||||
it('不匹配不同前缀', () => {
|
||||
expect(matchPattern('git status', 'git diff*')).toBe(false);
|
||||
expect(matchPattern('git pull', 'git diff*')).toBe(false);
|
||||
});
|
||||
|
||||
it('匹配危险命令模式 rm -rf*', () => {
|
||||
expect(matchPattern('rm -rf /', 'rm -rf*')).toBe(true);
|
||||
expect(matchPattern('rm -rf /home', 'rm -rf*')).toBe(true);
|
||||
expect(matchPattern('rm -rf .', 'rm -rf*')).toBe(true);
|
||||
});
|
||||
|
||||
it('rm 普通命令不匹配 rm -rf*', () => {
|
||||
expect(matchPattern('rm file.txt', 'rm -rf*')).toBe(false);
|
||||
expect(matchPattern('rm -r dir', 'rm -rf*')).toBe(false);
|
||||
});
|
||||
|
||||
it('中间位置的通配符', () => {
|
||||
expect(matchPattern('git push origin main', 'git push * main')).toBe(true);
|
||||
expect(matchPattern('git push upstream main', 'git push * main')).toBe(true);
|
||||
});
|
||||
|
||||
it('多个通配符', () => {
|
||||
expect(matchPattern('git push origin main', 'git * origin *')).toBe(true);
|
||||
expect(matchPattern('git pull origin main', 'git * origin *')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('? 通配符', () => {
|
||||
it('匹配单个字符', () => {
|
||||
expect(matchPattern('ls -a', 'ls -?')).toBe(true);
|
||||
expect(matchPattern('ls -l', 'ls -?')).toBe(true);
|
||||
});
|
||||
|
||||
it('不匹配多个字符', () => {
|
||||
expect(matchPattern('ls -la', 'ls -?')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('精确匹配', () => {
|
||||
it('完全相同的字符串', () => {
|
||||
expect(matchPattern('ls', 'ls')).toBe(true);
|
||||
expect(matchPattern('pwd', 'pwd')).toBe(true);
|
||||
});
|
||||
|
||||
it('不匹配带参数的命令', () => {
|
||||
expect(matchPattern('ls -la', 'ls')).toBe(false);
|
||||
expect(matchPattern('pwd /home', 'pwd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('大小写不敏感', () => {
|
||||
it('匹配不同大小写', () => {
|
||||
expect(matchPattern('GIT DIFF', 'git diff*')).toBe(true);
|
||||
expect(matchPattern('Git Diff', 'git diff*')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('特殊字符转义', () => {
|
||||
it('正确处理点号', () => {
|
||||
expect(matchPattern('file.txt', 'file.txt')).toBe(true);
|
||||
expect(matchPattern('fileatxt', 'file.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('正确处理括号', () => {
|
||||
expect(matchPattern('echo (test)', 'echo (test)')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchRules - 规则匹配', () => {
|
||||
const rules = [
|
||||
{ pattern: 'ls *', action: 'allow' as const },
|
||||
{ pattern: 'rm -rf*', action: 'deny' as const },
|
||||
{ pattern: 'git *', action: 'ask' as const },
|
||||
];
|
||||
|
||||
it('匹配 allow 规则', () => {
|
||||
const result = matchRules('ls -la', rules, 'ask');
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('匹配 deny 规则', () => {
|
||||
const result = matchRules('rm -rf /', rules, 'ask');
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
|
||||
it('匹配 ask 规则', () => {
|
||||
const result = matchRules('git push', rules, 'deny');
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
|
||||
it('无匹配时返回默认动作', () => {
|
||||
const result = matchRules('npm install', rules, 'ask');
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
|
||||
it('返回匹配的模式', () => {
|
||||
const result = matchRules('rm -rf /home', rules, 'ask');
|
||||
expect(result.matchedPattern).toBe('rm -rf*');
|
||||
});
|
||||
|
||||
it('空规则列表返回默认动作', () => {
|
||||
const result = matchRules('any command', [], 'allow');
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCommand - 命令解析', () => {
|
||||
it('解析简单命令', () => {
|
||||
const result = parseCommand('ls');
|
||||
expect(result.head).toBe('ls');
|
||||
expect(result.sub).toBeUndefined();
|
||||
});
|
||||
|
||||
it('解析带子命令的命令', () => {
|
||||
const result = parseCommand('git push');
|
||||
expect(result.head).toBe('git');
|
||||
expect(result.sub).toBe('push');
|
||||
});
|
||||
|
||||
it('解析带参数的命令', () => {
|
||||
const result = parseCommand('git push origin main');
|
||||
expect(result.head).toBe('git');
|
||||
expect(result.sub).toBe('push');
|
||||
expect(result.args).toContain('origin');
|
||||
expect(result.args).toContain('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAskPattern - 生成询问模式', () => {
|
||||
it('简单命令生成 cmd *', () => {
|
||||
const pattern = generateAskPattern('ls -la');
|
||||
expect(pattern).toBe('ls *');
|
||||
});
|
||||
|
||||
it('带子命令生成 cmd sub *', () => {
|
||||
const pattern = generateAskPattern('git push origin');
|
||||
expect(pattern).toBe('git push *');
|
||||
});
|
||||
|
||||
it('npm install 生成 npm install *', () => {
|
||||
const pattern = generateAskPattern('npm install lodash');
|
||||
expect(pattern).toBe('npm install *');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user