/** * 配置分层测试(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:'); }); });