/** * Secure Config 测试(v0.7.2 覆盖补齐 —— 此前零测试) * * 锁定敏感配置加密存储契约(P0-1 的回归防线): * 1. 敏感 key 判定模式(apikey/token/secret/password/auth_key) * 2. safeStorage 可用 → 加密前缀格式 + 解密还原 * 3. safeStorage 不可用 → 明文降级(可用性优先)+ WARN * 4. 解密失败(跨机器/重装)→ 返回空串(引导重录而非崩溃) * 5. 加密幂等(已加密值不二次加密)与非字符串透传 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // 可控的 safeStorage 桩:可逆 fake 加密(enc: 前缀),可用性开关可编程 const mockState = vi.hoisted(() => ({ encryptionAvailable: true, failDecrypt: false, })); vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => mockState.encryptionAvailable, encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'), decryptString: (buffer: Buffer) => { const raw = buffer.toString('utf-8'); if (mockState.failDecrypt || !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 { isSensitiveConfigKey, isEncryptedValue, encryptConfigValue, decryptConfigValue, } from '../secure-config'; beforeEach(() => { mockState.encryptionAvailable = true; mockState.failDecrypt = false; }); describe('isSensitiveConfigKey — 敏感 key 判定', () => { it.each([ ['llm.apiKey', true], ['llm.api_key', true], ['searxng.auth_key', true], // v0.7.4 P2-5: 驼峰/连字符/点分隔的 authKey 类命名全部命中(去分隔符归一化) ['searxng.authKey', true], ['searxng.auth-key', true], ['mcp.server.authToken', true], ['db.api-key', true], ['llm.fallbackApiKey', true], ['proxy.token', true], ['db.secret', true], ['GITHUB_PASSWORD', true], ['ui.theme', false], ['llm.model', false], ['agent.maxIterations', false], ['network.proxyUrl', false], ['searxng.language', false], ])('%s → %j', (key, expected) => { expect(isSensitiveConfigKey(key)).toBe(expected); }); }); describe('encrypt/decrypt — 加密回环', () => { it('加密值带版本化前缀(metona-enc:v1:),解密还原原文', () => { const encrypted = encryptConfigValue('sk-my-secret-key'); expect(typeof encrypted).toBe('string'); expect(isEncryptedValue(encrypted)).toBe(true); expect(String(encrypted)).toMatch(/^metona-enc:v1:/); const decrypted = decryptConfigValue(encrypted); expect(decrypted).toBe('sk-my-secret-key'); }); it('非字符串值原样透传(number/boolean/null 不加密)', () => { expect(encryptConfigValue(42)).toBe(42); expect(encryptConfigValue(true)).toBe(true); expect(encryptConfigValue(null)).toBe(null); expect(decryptConfigValue(42)).toBe(42); }); it('空字符串不加密(避免无意义前缀包裹)', () => { expect(encryptConfigValue('')).toBe(''); }); it('已加密值幂等 —— 二次加密不再包裹前缀', () => { const once = encryptConfigValue('sk-key') as string; const twice = encryptConfigValue(once); expect(twice).toBe(once); expect(decryptConfigValue(twice)).toBe('sk-key'); }); it('非加密格式的值解密时原样返回(历史明文平滑兼容)', () => { expect(decryptConfigValue('plain-old-key')).toBe('plain-old-key'); }); }); describe('加密降级与失败语义', () => { it('safeStorage 不可用 → 明文存储(可用性优先 + WARN)', () => { mockState.encryptionAvailable = false; const value = encryptConfigValue('sk-plaintext-fallback'); expect(value).toBe('sk-plaintext-fallback'); expect(isEncryptedValue(value)).toBe(false); }); it('加密过程抛错 → 回退明文存储(不阻断配置保存)', () => { // decryptString 抛错不影响 encrypt;此处验证 decrypt 失败语义 mockState.failDecrypt = true; const encrypted = encryptConfigValue('sk-x') as string; expect(decryptConfigValue(encrypted)).toBe(''); // 失败 → 空串(createAdapter 判定未配置,引导重录) }); it('safeStorage 不可用时已加密值仍可识别且不被二次"加密"', () => { const once = encryptConfigValue('sk-again') as string; mockState.encryptionAvailable = false; // 已加密值幂等:不重新走明文降级 expect(encryptConfigValue(once)).toBe(once); }); it('前缀残缺的加密值(metona-enc:v1: 后无 base64)解密返回空串', () => { expect(decryptConfigValue('metona-enc:v1:')).toBe(''); }); it('非 base64 内容的加密值解密失败返回空串(不抛错)', () => { expect(decryptConfigValue('metona-enc:v1:@@@not-base64@@@')).toBe(''); }); it('不同版本前缀(metona-enc:v2:)不被识别为加密值(原样返回)', () => { expect(isEncryptedValue('metona-enc:v2:abc')).toBe(false); expect(decryptConfigValue('metona-enc:v2:abc')).toBe('metona-enc:v2:abc'); }); it('含非敏感子串的 key 不加密(大写化归一后仍不命中)', () => { expect(isSensitiveConfigKey('ui.theme')).toBe(false); expect(isSensitiveConfigKey('network.proxyUrl')).toBe(false); expect(isSensitiveConfigKey('logging.traceEnabled')).toBe(false); }); it('敏感 key 判定覆盖 token 类与 secret 类更多形态', () => { expect(isSensitiveConfigKey('mcp.githubToken')).toBe(true); expect(isSensitiveConfigKey('db.clientSecret')).toBe(true); expect(isSensitiveConfigKey('registry.authToken')).toBe(true); expect(isSensitiveConfigKey('SMTP_PASSWORD')).toBe(true); expect(isSensitiveConfigKey('sshPrivateKey')).toBe(false); // 无分隔符且不含模式 expect(isSensitiveConfigKey('keyboard.layout')).toBe(false); }); it('encrypt/decrypt 往返多种敏感字符串(含特殊字符/中文/emoji)', () => { const samples = [ 'sk-hello world', '含中文密钥值', 'with,comma"quotes"', 'emoji-🔑-key', 'a'.repeat(5000), ]; for (const s of samples) { const encrypted = encryptConfigValue(s) as string; expect(isEncryptedValue(encrypted)).toBe(true); expect(decryptConfigValue(encrypted)).toBe(s); } }); it('decrypt 对 undefined/null 原样透传', () => { expect(decryptConfigValue(undefined)).toBeUndefined(); expect(decryptConfigValue(null)).toBeNull(); }); it('encrypt 对 0/空串/false 等 falsy 非字符串原样透传', () => { expect(encryptConfigValue(0)).toBe(0); expect(encryptConfigValue('')).toBe(''); expect(encryptConfigValue(false)).toBe(false); }); });