5e32375f0e
架构变更: - 采用 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 - 配置管理
323 lines
8.6 KiB
TypeScript
323 lines
8.6 KiB
TypeScript
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');
|
||
});
|
||
});
|
||
});
|