feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照, IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭, 与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀 按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身 (tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门 (file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断); 单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口) P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播, getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers 全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项 校验+设置页 JSON 输入, 远程 MCP 鉴权头可用) P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性 前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码 激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见); IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留) P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层, 外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1 文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2 测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/ orchestrator/workspace.service/session-recorder/config-layering/secure-config/ network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。 测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK), 固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过; Electron ABI 全量 737/737 零跳过
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 配置分层测试(v0.7.2 覆盖补齐 —— config.service / global-config.service 此前零测试)
|
||||
*
|
||||
* 锁定跨工作空间配置层契约(v0.3.17 引入的分层机制的回归防线):
|
||||
* 1. 读取顺序:工作空间 DB → 全局 JSON(仅全局 key)
|
||||
* 2. 空值回退:DB 被 seedDefaults 灌入空值/默认值时回退全局层真实值
|
||||
* 3. 双写:set 同步写 DB 与全局 JSON;敏感 key 双侧密文
|
||||
* 4. getAll 合并语义与 migrateFromWorkspaceDB 幂等迁移
|
||||
* 5. 敏感 key 加密回环(P0-1)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
// better-sqlite3 的 ABI 可用性在模块顶层探测(describe.skipIf 在注册期求值)
|
||||
let dbAvailable = false;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const DatabaseProbe = require('better-sqlite3');
|
||||
new DatabaseProbe(':memory:').close();
|
||||
dbAvailable = true;
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
|
||||
// 可控 safeStorage(可逆 fake:enc: 前缀)+ 可定位的 userData 目录。
|
||||
// 注意:mock 工厂内不允许 require(仓库 lint 禁令),os/path/fs 经参数注入。
|
||||
const mockState = vi.hoisted(() => ({
|
||||
encryptionAvailable: true,
|
||||
userDataDir: '',
|
||||
}));
|
||||
vi.mock('electron', async () => {
|
||||
const { mkdtempSync } = await import('fs');
|
||||
const { join } = await import('path');
|
||||
const { tmpdir } = await import('os');
|
||||
mockState.userDataDir = mkdtempSync(join(tmpdir(), 'metona-gcfg-'));
|
||||
return {
|
||||
app: { getPath: () => mockState.userDataDir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => mockState.encryptionAvailable,
|
||||
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
|
||||
decryptString: (buffer: Buffer) => {
|
||||
const raw = buffer.toString('utf-8');
|
||||
if (!raw.startsWith('enc:')) throw new Error('decryption failed');
|
||||
return raw.slice(4);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { ConfigService } from '../config.service';
|
||||
import {
|
||||
GlobalConfigService,
|
||||
isGlobalKey,
|
||||
isUnconfiguredGlobalKey,
|
||||
} from '../global-config.service';
|
||||
|
||||
// better-sqlite3 的默认导出同时是构造函数与命名空间 —— 实例类型经
|
||||
// InstanceType<typeof Database> 获取,避免与命名空间冲突
|
||||
type SqliteDatabase = InstanceType<typeof Database>;
|
||||
|
||||
let db: SqliteDatabase | undefined;
|
||||
let globalConfig: GlobalConfigService;
|
||||
let configService: ConfigService;
|
||||
let tempRoots: string[] = [];
|
||||
|
||||
function makeDb(): SqliteDatabase {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (!dbAvailable) return;
|
||||
// 测试隔离:清除上一个用例落盘的全局 JSON(GLOBAL_CONFIG_FILE 在模块导入期
|
||||
// 固化为单一路径,文件内状态会跨用例泄漏 —— 迁移用例会误判"已有 key")
|
||||
rmSync(join(mockState.userDataDir, 'global-config.json'), { force: true });
|
||||
db = makeDb();
|
||||
globalConfig = new GlobalConfigService();
|
||||
globalConfig.initialize();
|
||||
configService = new ConfigService(() => db!);
|
||||
configService.setGlobalConfig(globalConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (db) db.close();
|
||||
for (const dir of tempRoots) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
tempRoots = [];
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('isGlobalKey / isUnconfiguredGlobalKey — 全局 key 判定', () => {
|
||||
it('llm./agent./ui./security./onboarding. 前缀为全局 key;tasks/searxng 不属于', () => {
|
||||
expect(isGlobalKey('llm.provider')).toBe(true);
|
||||
expect(isGlobalKey('agent.maxIterations')).toBe(true);
|
||||
expect(isGlobalKey('onboarding.completed')).toBe(true);
|
||||
expect(isGlobalKey('searxng.enabled')).toBe(false);
|
||||
expect(isGlobalKey('tools.write_file.enabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('空串/null/默认值视为未配置(触发全局回退);非默认值视为已配置', () => {
|
||||
expect(isUnconfiguredGlobalKey('llm.apiKey', '')).toBe(true);
|
||||
expect(isUnconfiguredGlobalKey('llm.apiKey', null)).toBe(true);
|
||||
expect(isUnconfiguredGlobalKey('agent.maxIterations', 20)).toBe(true); // seedDefaults 默认值
|
||||
expect(isUnconfiguredGlobalKey('agent.maxIterations', 50)).toBe(false); // 用户主动设置
|
||||
expect(isUnconfiguredGlobalKey('llm.apiKey', 'sk-real')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('ConfigService — 基本读写与敏感加密', () => {
|
||||
it('set/get 回环(JSON 序列化语义)', () => {
|
||||
configService.set('llm.model', 'deepseek-v4-pro');
|
||||
expect(configService.get<string>('llm.model')).toBe('deepseek-v4-pro');
|
||||
|
||||
configService.set('agent.maxIterations', 33);
|
||||
expect(configService.get<number>('agent.maxIterations')).toBe(33);
|
||||
});
|
||||
|
||||
it('敏感 key 落盘为密文、读取自动解密(P0-1)', () => {
|
||||
configService.set('llm.apiKey', 'sk-plain-secret');
|
||||
|
||||
const raw = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.apiKey'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect(raw.value).toContain('metona-enc:v1:'); // DB 内是密文
|
||||
expect(raw.value).not.toContain('sk-plain-secret');
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-plain-secret'); // 读取解密
|
||||
});
|
||||
|
||||
it('get 未配置 key 返回 null(无全局回退对象时)', () => {
|
||||
expect(configService.get<string>('nonexistent.key')).toBeNull();
|
||||
});
|
||||
|
||||
it('set 保留已有 category(不回退默认值)', () => {
|
||||
db!
|
||||
.prepare(
|
||||
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"dark\"', 'ui')",
|
||||
)
|
||||
.run();
|
||||
configService.set('ui.theme', 'light');
|
||||
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
|
||||
category: string;
|
||||
};
|
||||
expect(row.category).toBe('ui');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('配置分层 — DB 与全局 JSON 回退/双写', () => {
|
||||
it('DB miss + 全局层有值 → 回退全局层', () => {
|
||||
globalConfig.set('llm.provider', 'deepseek');
|
||||
expect(configService.get<string>('llm.provider')).toBe('deepseek');
|
||||
});
|
||||
|
||||
it('DB 有真实值 → 不回退(DB 优先)', () => {
|
||||
globalConfig.set('llm.provider', 'deepseek');
|
||||
configService.set('llm.provider', 'ollama');
|
||||
expect(configService.get<string>('llm.provider')).toBe('ollama');
|
||||
});
|
||||
|
||||
it('DB 有 seedDefaults 空值 + 全局层有真实值 → 回退全局层(新工作空间场景)', () => {
|
||||
// 模拟新工作空间 DB 被 seedDefaults 灌入空 apiKey
|
||||
db!
|
||||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
|
||||
.run();
|
||||
globalConfig.set('llm.apiKey', 'sk-global-real');
|
||||
expect(configService.get<string>('llm.apiKey')).toBe('sk-global-real');
|
||||
});
|
||||
|
||||
it('set 全局 key 双写:DB + 全局 JSON(跨工作空间共享)', () => {
|
||||
configService.set('llm.model', 'mimo-v2.5');
|
||||
|
||||
// DB 有值
|
||||
expect(configService.get<string>('llm.model')).toBe('mimo-v2.5');
|
||||
// 全局 JSON 有值(新 ConfigService 实例 + 空 DB 可读到)
|
||||
const db2 = makeDb();
|
||||
const freshConfig = new ConfigService(() => db2);
|
||||
freshConfig.setGlobalConfig(globalConfig);
|
||||
expect(freshConfig.get<string>('llm.model')).toBe('mimo-v2.5');
|
||||
db2.close();
|
||||
});
|
||||
|
||||
it('非全局 key 不写全局层(工作空间级数据不跨空间泄漏)', () => {
|
||||
configService.set('workspace.path', '/tmp/private');
|
||||
expect(globalConfig.get<string>('workspace.path')).toBeNull();
|
||||
});
|
||||
|
||||
it('getAll 合并:DB 为主,miss 的全局 key 用全局层补齐', () => {
|
||||
configService.set('ui.theme', 'dark');
|
||||
globalConfig.set('llm.provider', 'openai');
|
||||
const all = configService.getAll();
|
||||
expect(all['ui.theme']).toBe('dark');
|
||||
expect(all['llm.provider']).toBe('openai');
|
||||
});
|
||||
|
||||
it('getAll 中敏感 key 解密返回明文(配合导出层脱敏)', () => {
|
||||
configService.set('llm.apiKey', 'sk-export-test');
|
||||
expect(configService.getAll()['llm.apiKey']).toBe('sk-export-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 幂等迁移', () => {
|
||||
it('仅迁移已配置的全局 key;空值/默认值/既有 key 跳过', () => {
|
||||
// 构造工作空间 DB 配置快照
|
||||
const workspaceConfig: Record<string, unknown> = {
|
||||
'llm.provider': 'deepseek', // 已配置 → 迁移
|
||||
'llm.apiKey': '', // 空值 → 跳过
|
||||
'agent.maxIterations': 20, // seedDefaults 默认值 → 跳过
|
||||
'agent.confirmationTimeoutMs': 300000, // 用户设置的非默认值 → 迁移
|
||||
'searxng.enabled': true, // 非全局 key → 跳过
|
||||
};
|
||||
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB(workspaceConfig);
|
||||
expect(migrated).toBe(2);
|
||||
expect(globalConfig.get('llm.provider')).toBe('deepseek');
|
||||
expect(globalConfig.get('agent.confirmationTimeoutMs')).toBe(300000);
|
||||
expect(globalConfig.get('llm.apiKey')).toBeNull();
|
||||
|
||||
// 幂等:第二次迁移 0 条(既有 key 不覆盖)
|
||||
expect(globalConfig.migrateFromWorkspaceDB(workspaceConfig)).toBe(0);
|
||||
});
|
||||
|
||||
it('全局层已有真实值时不被迁移覆盖', () => {
|
||||
globalConfig.set('llm.provider', 'anthropic');
|
||||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'deepseek' });
|
||||
expect(migrated).toBe(0);
|
||||
expect(globalConfig.get('llm.provider')).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!dbAvailable)('敏感 key — 全局层密文落盘', () => {
|
||||
it('全局 JSON 文件中敏感值为密文(无明文泄漏)', () => {
|
||||
configService.set('llm.apiKey', 'sk-raw-in-global');
|
||||
|
||||
// GlobalConfigService flush 后,磁盘上的 global-config.json 不应包含明文
|
||||
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
|
||||
expect(rawFile).not.toContain('sk-raw-in-global');
|
||||
expect(rawFile).toContain('metona-enc:v1:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user