P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
559 lines
23 KiB
TypeScript
559 lines
23 KiB
TypeScript
/**
|
||
* 配置分层测试(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:');
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('isUnconfiguredGlobalKey — 边界值矩阵', () => {
|
||
it.each([
|
||
// [key, value, expected]
|
||
['llm.apiKey', '', true],
|
||
['llm.apiKey', null, true],
|
||
['llm.apiKey', undefined, true],
|
||
['llm.apiKey', 0, false], // 数字 0 非空串非默认值
|
||
['llm.model', '', true],
|
||
// 默认值 → 未配置
|
||
['agent.maxIterations', 20, true],
|
||
['agent.totalTimeoutMs', 600000, true],
|
||
['agent.enableThinking', true, true],
|
||
['llm.maxTokens', 63488, true],
|
||
['onboarding.completed', false, true],
|
||
['ui.theme', 'auto', true],
|
||
// 非默认值 → 已配置
|
||
['agent.maxIterations', 21, false],
|
||
['agent.totalTimeoutMs', 1, false],
|
||
['agent.enableThinking', false, false], // 用户主动关闭
|
||
['llm.maxTokens', 1000, false],
|
||
['onboarding.completed', true, false], // 用户已完成引导
|
||
['ui.theme', 'dark', false],
|
||
// 非全局 key 恒未配置判定(不被 SEED_DEFAULTS 覆盖)
|
||
['searxng.enabled', 'anything', false],
|
||
['tools.write_file.enabled', false, false],
|
||
])('isUnconfiguredGlobalKey(%s, %j) → %j', (key, value, expected) => {
|
||
expect(isUnconfiguredGlobalKey(key, value)).toBe(expected);
|
||
});
|
||
|
||
it('isGlobalKey 边界:完整前缀匹配而非子串(deepseek.contextWindow 是全局 key)', () => {
|
||
expect(isGlobalKey('deepseek.contextWindow')).toBe(true);
|
||
expect(isGlobalKey('deepseek.apiKey')).toBe(true);
|
||
expect(isGlobalKey('memory.consolidationEnabled')).toBe(true);
|
||
expect(isGlobalKey('memory.custom')).toBe(true);
|
||
// 非全局前缀
|
||
expect(isGlobalKey('workspace.path')).toBe(false);
|
||
expect(isGlobalKey('searxng.apiKey')).toBe(false);
|
||
expect(isGlobalKey('mcp.autoReconnect')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('ConfigService.setBatch — 批量写入', () => {
|
||
it('setBatch 多 key 一次写入 DB 与全局层(全局 key 双写)', () => {
|
||
configService.setBatch([
|
||
{ key: 'llm.model', value: 'mimo-v3' },
|
||
{ key: 'agent.maxIterations', value: 40 },
|
||
{ key: 'ui.theme', value: 'light' },
|
||
]);
|
||
expect(configService.get<string>('llm.model')).toBe('mimo-v3');
|
||
expect(configService.get<number>('agent.maxIterations')).toBe(40);
|
||
expect(configService.get<string>('ui.theme')).toBe('light');
|
||
// 全局层同步
|
||
expect(globalConfig.get<string>('llm.model')).toBe('mimo-v3');
|
||
expect(globalConfig.get<number>('agent.maxIterations')).toBe(40);
|
||
});
|
||
|
||
it('setBatch 敏感 key 加密落盘、读取解密', () => {
|
||
configService.setBatch([{ key: 'llm.apiKey', value: 'sk-batch-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:');
|
||
expect(configService.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-batch-secret');
|
||
});
|
||
|
||
it('setBatch 保留已有 category(与 set 语义一致)', () => {
|
||
db!
|
||
.prepare(
|
||
"INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '\"blue\"', 'ui')",
|
||
)
|
||
.run();
|
||
configService.setBatch([{ key: 'ui.theme', value: 'dark' }]);
|
||
const row = db!.prepare("SELECT category FROM app_config WHERE key = 'ui.theme'").get() as {
|
||
category: string;
|
||
};
|
||
expect(row.category).toBe('ui');
|
||
});
|
||
|
||
it('setBatch 非全局 key 不写全局层', () => {
|
||
configService.setBatch([{ key: 'workspace.localOnly', value: 'x' }]);
|
||
expect(globalConfig.get<string>('workspace.localOnly')).toBeNull();
|
||
});
|
||
|
||
it('setBatch 空数组不抛错且无副作用', () => {
|
||
expect(() => configService.setBatch([])).not.toThrow();
|
||
expect(globalConfig.getAll()).toEqual({});
|
||
});
|
||
|
||
it('setBatch 部分失败(JSON.stringify 抛错)→ DB 事务整体回滚,不产生部分写入', () => {
|
||
const circular: Record<string, unknown> = {};
|
||
circular.self = circular; // 无法 JSON 序列化
|
||
expect(() =>
|
||
configService.setBatch([
|
||
{ key: 'ui.theme', value: 'dark' },
|
||
{ key: 'agent.maxIterations', value: circular },
|
||
]),
|
||
).toThrow();
|
||
// 事务回滚:第一个 key 也不应残留
|
||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'ui.theme'").get();
|
||
expect(row).toBeUndefined();
|
||
// 全局层不被触碰(事务异常中断在 DB 阶段)
|
||
expect(globalConfig.get<string>('ui.theme')).toBeNull();
|
||
});
|
||
|
||
it('setBatch 更新 updated_at 时间戳', () => {
|
||
configService.setBatch([{ key: 'llm.model', value: 'a' }]);
|
||
const row = db!.prepare("SELECT updated_at FROM app_config WHERE key = 'llm.model'").get() as {
|
||
updated_at: number;
|
||
};
|
||
expect(row.updated_at).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('ConfigService — getByCategory / delete / 值类型', () => {
|
||
it('getByCategory 只返回该分类下的 key', () => {
|
||
// 直接按真实库的 category 灌入(set 默认 category=general,需先有分类)
|
||
configService.set('ui.theme', 'dark');
|
||
configService.set('llm.provider', 'deepseek');
|
||
configService.set('ui.fontSize', 14);
|
||
db!
|
||
.prepare("UPDATE app_config SET category = 'ui' WHERE key IN ('ui.theme', 'ui.fontSize')")
|
||
.run();
|
||
|
||
const ui = configService.getByCategory('ui');
|
||
expect(Object.keys(ui).sort()).toEqual(['ui.fontSize', 'ui.theme']);
|
||
expect(ui['ui.theme']).toBe('dark');
|
||
expect(ui['ui.fontSize']).toBe(14);
|
||
expect('llm.provider' in ui).toBe(false);
|
||
});
|
||
|
||
it('getByCategory 不存在的分类返回空对象', () => {
|
||
expect(configService.getByCategory('nonexistent')).toEqual({});
|
||
});
|
||
|
||
it('getByCategory 返回的敏感 key 值为密文(getByCategory 未做解密 —— 实际行为契约)', () => {
|
||
db!
|
||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"\"', 'llm')")
|
||
.run();
|
||
configService.set('llm.apiKey', 'sk-cat-secret');
|
||
const llm = configService.getByCategory('llm');
|
||
expect(llm['llm.apiKey']).toContain('metona-enc:v1:');
|
||
expect(llm['llm.apiKey']).not.toContain('sk-cat-secret');
|
||
// 对照:get() 仍解密
|
||
expect(configService.get<string>('llm.apiKey')).toBe('sk-cat-secret');
|
||
});
|
||
|
||
it('getByCategory 容忍损坏 JSON(原样字符串返回)', () => {
|
||
db!
|
||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.raw', '{bad-json', 'ui')")
|
||
.run();
|
||
const ui = configService.getByCategory('ui');
|
||
expect(ui['ui.raw']).toBe('{bad-json');
|
||
});
|
||
|
||
it('delete 非全局 key:删除后 get 返回 null(无全局回退)', () => {
|
||
configService.set('workspace.localFlag', 'x');
|
||
expect(configService.delete('workspace.localFlag')).toBe(true);
|
||
expect(configService.get('workspace.localFlag')).toBeNull();
|
||
expect(configService.delete('workspace.localFlag')).toBe(false);
|
||
expect(configService.delete('never-existed')).toBe(false);
|
||
});
|
||
|
||
it('delete 全局 key:DB 行删除但 get 经全局层回退仍可读(双写不联动删除)', () => {
|
||
configService.set('llm.model', 'mimo');
|
||
expect(configService.delete('llm.model')).toBe(true);
|
||
const row = db!.prepare("SELECT value FROM app_config WHERE key = 'llm.model'").get();
|
||
expect(row).toBeUndefined();
|
||
// 全局层保留 → get 回退命中
|
||
expect(globalConfig.get<string>('llm.model')).toBe('mimo');
|
||
expect(configService.get<string>('llm.model')).toBe('mimo');
|
||
});
|
||
|
||
it('set/get 支持对象与数组值(JSON 序列化语义)', () => {
|
||
configService.set('ui.pinnedSessions', ['a', 'b']);
|
||
configService.set('agent.metadata', { enabled: true, max: 3 });
|
||
expect(configService.get<Array<string>>('ui.pinnedSessions')).toEqual(['a', 'b']);
|
||
expect(configService.get<{ enabled: boolean; max: number }>('agent.metadata')).toEqual({
|
||
enabled: true,
|
||
max: 3,
|
||
});
|
||
});
|
||
|
||
it('set 布尔与 null 值可回读', () => {
|
||
configService.set('ui.flag', false);
|
||
configService.set('ollama.numCtx', null);
|
||
expect(configService.get<boolean>('ui.flag')).toBe(false);
|
||
expect(configService.get<null>('ollama.numCtx')).toBeNull();
|
||
});
|
||
|
||
it('历史明文敏感 key 平滑兼容:DB 存明文时 get 原样返回', () => {
|
||
// 模拟旧版本未加密落盘的 apiKey
|
||
db!
|
||
.prepare(
|
||
"INSERT INTO app_config (key, value, category) VALUES ('llm.apiKey', '\"sk-legacy-plain\"', 'llm')",
|
||
)
|
||
.run();
|
||
expect(configService.get<string>('llm.apiKey')).toBe('sk-legacy-plain');
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('migrateFromWorkspaceDB — 边界矩阵', () => {
|
||
it('false 值且默认值非 false → 迁移(用户主动关闭)', () => {
|
||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': false });
|
||
expect(migrated).toBe(1);
|
||
expect(globalConfig.get<boolean>('agent.enableThinking')).toBe(false);
|
||
});
|
||
|
||
it('false 值且默认值即 false → 跳过(seedDefaults 灌入)', () => {
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'onboarding.completed': false })).toBe(0);
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': false })).toBe(0);
|
||
});
|
||
|
||
it('true 值且默认值 false → 迁移(用户主动开启)', () => {
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.multimodalEnabled': true })).toBe(1);
|
||
expect(globalConfig.get<boolean>('llm.multimodalEnabled')).toBe(true);
|
||
});
|
||
|
||
it('true 值且默认值 true → 跳过', () => {
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'security.promptInjectionDefense': true })).toBe(
|
||
0,
|
||
);
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'agent.enableThinking': true })).toBe(0);
|
||
});
|
||
|
||
it('0 与空对象等非空非默认值 → 迁移', () => {
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0 })).toBe(0); // 默认 0 → 跳过
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'llm.temperature': 0.5 })).toBe(1);
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'ui.customFlag': 'on' })).toBe(1);
|
||
});
|
||
|
||
it('敏感 key 迁移:全局 JSON 中为密文(无明文泄漏)', () => {
|
||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.apiKey': 'sk-mig-secret' });
|
||
expect(migrated).toBe(1);
|
||
expect(globalConfig.get<string>('llm.apiKey')).toBe('sk-mig-secret');
|
||
const rawFile = readFileSync(join(mockState.userDataDir, 'global-config.json'), 'utf-8');
|
||
expect(rawFile).not.toContain('sk-mig-secret');
|
||
expect(rawFile).toContain('metona-enc:v1:');
|
||
});
|
||
|
||
it('数值边界:默认值 float 精确相等判定', () => {
|
||
// deepseek.contextWindow 默认 1000000
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 1000000 })).toBe(0);
|
||
expect(globalConfig.migrateFromWorkspaceDB({ 'deepseek.contextWindow': 64000 })).toBe(1);
|
||
});
|
||
|
||
it('迁移后新 ConfigService 读取(跨空间共享生效)', () => {
|
||
const migrated = globalConfig.migrateFromWorkspaceDB({ 'llm.provider': 'ollama' });
|
||
expect(migrated).toBe(1);
|
||
const db2 = makeDb();
|
||
const freshConfig = new ConfigService(() => db2);
|
||
freshConfig.setGlobalConfig(globalConfig);
|
||
expect(freshConfig.get<string>('llm.provider')).toBe('ollama');
|
||
db2.close();
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!dbAvailable)('配置分层 — 空值回退细节', () => {
|
||
it('DB 值等于 seed 默认值(0)时视为未配置 → 回退全局层真实值', () => {
|
||
// llm.temperature seed 默认 0 —— DB 写入 0 与默认值不可区分 → 回退全局层
|
||
db!
|
||
.prepare(
|
||
"INSERT INTO app_config (key, value, category) VALUES ('llm.temperature', '0', 'llm')",
|
||
)
|
||
.run();
|
||
globalConfig.set('llm.temperature', 1);
|
||
expect(configService.get<number>('llm.temperature')).toBe(1);
|
||
});
|
||
|
||
it('DB 值为非默认数字 0(ui.theme 默认是字符串)→ 视为已配置不回退', () => {
|
||
db!
|
||
.prepare("INSERT INTO app_config (key, value, category) VALUES ('ui.theme', '0', 'ui')")
|
||
.run();
|
||
globalConfig.set('ui.theme', 'dark');
|
||
expect(configService.get<number>('ui.theme')).toBe(0);
|
||
});
|
||
|
||
it('DB 有非空字符串但全局层为 null → 不覆盖为 null(返回 DB 值)', () => {
|
||
configService.set('llm.model', 'local-model');
|
||
// 全局层无该值
|
||
expect(configService.get<string>('llm.model')).toBe('local-model');
|
||
});
|
||
|
||
it('DB miss 且全局层为 null → get 返回 null', () => {
|
||
expect(configService.get<string>('llm.provider')).toBeNull();
|
||
});
|
||
|
||
it('getAll 合并时全局层未配置的 key 不进结果', () => {
|
||
const all = configService.getAll();
|
||
expect('llm.provider' in all).toBe(false);
|
||
});
|
||
|
||
it('getAll 中 DB 未配置但全局层已配置 → 全局值补齐', () => {
|
||
globalConfig.set('llm.provider', 'openai');
|
||
configService.set('ui.theme', 'dark');
|
||
const all = configService.getAll();
|
||
expect(all['llm.provider']).toBe('openai');
|
||
expect(all['ui.theme']).toBe('dark');
|
||
});
|
||
});
|