/** * 配置分层测试(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 获取,避免与命名空间冲突 type SqliteDatabase = InstanceType; 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('llm.model')).toBe('deepseek-v4-pro'); configService.set('agent.maxIterations', 33); expect(configService.get('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('llm.apiKey')).toBe('sk-plain-secret'); // 读取解密 }); it('get 未配置 key 返回 null(无全局回退对象时)', () => { expect(configService.get('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('llm.provider')).toBe('deepseek'); }); it('DB 有真实值 → 不回退(DB 优先)', () => { globalConfig.set('llm.provider', 'deepseek'); configService.set('llm.provider', 'ollama'); expect(configService.get('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('llm.apiKey')).toBe('sk-global-real'); }); it('set 全局 key 双写:DB + 全局 JSON(跨工作空间共享)', () => { configService.set('llm.model', 'mimo-v2.5'); // DB 有值 expect(configService.get('llm.model')).toBe('mimo-v2.5'); // 全局 JSON 有值(新 ConfigService 实例 + 空 DB 可读到) const db2 = makeDb(); const freshConfig = new ConfigService(() => db2); freshConfig.setGlobalConfig(globalConfig); expect(freshConfig.get('llm.model')).toBe('mimo-v2.5'); db2.close(); }); it('非全局 key 不写全局层(工作空间级数据不跨空间泄漏)', () => { configService.set('workspace.path', '/tmp/private'); expect(globalConfig.get('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 = { '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('llm.model')).toBe('mimo-v3'); expect(configService.get('agent.maxIterations')).toBe(40); expect(configService.get('ui.theme')).toBe('light'); // 全局层同步 expect(globalConfig.get('llm.model')).toBe('mimo-v3'); expect(globalConfig.get('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('llm.apiKey')).toBe('sk-batch-secret'); expect(globalConfig.get('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('workspace.localOnly')).toBeNull(); }); it('setBatch 空数组不抛错且无副作用', () => { expect(() => configService.setBatch([])).not.toThrow(); expect(globalConfig.getAll()).toEqual({}); }); it('setBatch 部分失败(JSON.stringify 抛错)→ DB 事务整体回滚,不产生部分写入', () => { const circular: Record = {}; 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('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('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('llm.model')).toBe('mimo'); expect(configService.get('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>('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('ui.flag')).toBe(false); expect(configService.get('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('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('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('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('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('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('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('ui.theme')).toBe(0); }); it('DB 有非空字符串但全局层为 null → 不覆盖为 null(返回 DB 值)', () => { configService.set('llm.model', 'local-model'); // 全局层无该值 expect(configService.get('llm.model')).toBe('local-model'); }); it('DB miss 且全局层为 null → get 返回 null', () => { expect(configService.get('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'); }); });