Files
Notes/tests/unit/secret-store.test.js
2026-10-16 16:34:15 +08:00

261 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// main/secret-store.js —— aiApiKey safeStorage 加密层单测(audit R3 P0-1)
//
// 覆盖:
// - encrypt / decrypt 的对称与幂等
// - safeStorage 不可用(典型 Linux 无 keyring)降级 + warn-once
// - decrypt 遇损坏包络 / keyring 不可用 → 返回 ''(不抛)
// - 旧版明文 passthrough(懒迁移关键:旧 config.json 仍能解密出 Key)
// - isAvailable 处理 safeStorage 自身抛错 / 未注册的情况
//
// 注入模式:模块自暴露的 _setSafeStorage(mock)。原因同 main/config-store.js
// —— vi.mock('electron') 拦不住 CJS require。mock 用闭包记录 isEncryptionAvailable
// 状态和调用计数,单测间通过 _reset() 重置。
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// 强制每次 beforeEach 拿一份新模块实例(_reset 也会清 _warnedUnavailable 标志)
const loadModule = async () => {
// 走 ESM dynamic import;secret-store.js 是 CJS,但 Node 处理 CJS↔ESM 互操作
// 给一个 .default 包含 module.exports 的默认导出(如果用 require() 则没有)
// —— secret-store.js 用 module.exports = {...},ESM 默认 import 拿到的就是
// module.exports 本身,不需要 .default 访问。
const mod = await import('../../main/secret-store.js');
return mod;
};
describe('secret-store:encrypt / decrypt 对称', () => {
let mod;
let encryptCalls;
let decryptCalls;
beforeEach(async () => {
mod = await loadModule();
encryptCalls = [];
decryptCalls = [];
// mock safeStorage:明文 ↔ base64('ENC:<plaintext>') 做对称回路
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (plain) => {
encryptCalls.push(plain);
return Buffer.from(`ENC:${plain}`, 'utf-8');
},
decryptString: (buf) => {
const s = buf.toString('utf-8');
decryptCalls.push(s);
if (!s.startsWith('ENC:')) throw new Error('mock: bad envelope');
return s.slice('ENC:'.length);
},
});
});
afterEach(() => {
mod._reset();
});
it('encrypt(\'\') → \'\'', () => {
expect(mod.encrypt('')).toBe('');
});
it('encrypt(null) → \'\'', () => {
expect(mod.encrypt(null)).toBe('');
});
it('encrypt(undefined) → \'\'', () => {
expect(mod.encrypt(undefined)).toBe('');
});
it('encrypt(\'sk-test\') → 包络(enc:v1: 前缀 + base64)', () => {
const out = mod.encrypt('sk-test');
expect(out.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
// base64 decode 应该回到我们 mock 用的 `ENC:sk-test`
const b64 = out.slice(mod.ENVELOPE_PREFIX.length);
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
expect(decoded).toBe('ENC:sk-test');
});
it('encrypt → decrypt 往返:明文完整恢复', () => {
const original = 'sk-中文-emoji-🔑-whitespace \t';
const enc = mod.encrypt(original);
expect(enc.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
expect(mod.decrypt(enc)).toBe(original);
});
it('encrypt 已经是包络的字符串 → 原样返回(幂等,防止套娃)', () => {
const once = mod.encrypt('sk-x');
const twice = mod.encrypt(once);
expect(twice).toBe(once);
// 验证 mock encryptString 只被调一次
expect(encryptCalls).toEqual(['sk-x']);
});
it('decrypt(\'\') → \'\'', () => {
expect(mod.decrypt('')).toBe('');
});
it('decrypt(null) / decrypt(undefined) → \'\'', () => {
expect(mod.decrypt(null)).toBe('');
expect(mod.decrypt(undefined)).toBe('');
});
it('decrypt 旧版明文(无前缀)→ 原样返回(懒迁移关键)', () => {
// 模拟升级前 config.json 里的 plaintext key
expect(mod.decrypt('sk-old-plaintext')).toBe('sk-old-plaintext');
});
it('decrypt 任意不含前缀的字符串 → 原样返回(防御性)', () => {
expect(mod.decrypt('xxx')).toBe('xxx');
expect(mod.decrypt('not-an-envelope-but-still-string')).toBe('not-an-envelope-but-still-string');
});
});
describe('secret-store:safeStorage 不可用降级', () => {
let warnSpy;
let mod;
beforeEach(async () => {
mod = await loadModule();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
mod._reset();
warnSpy.mockRestore();
});
it('isEncryptionAvailable() === false → encrypt 返回明文 + warn 一次', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('SHOULD-NOT-BE-USED'),
decryptString: () => 'SHOULD-NOT-BE-USED',
});
expect(mod.encrypt('sk-fallback')).toBe('sk-fallback');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/safeStorage 不可用/);
});
it('多次 encrypt 不可用 → 只 warn 一次(不重复打扰用户)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('a');
mod.encrypt('b');
mod.encrypt('c');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('decrypt 遇到包络但 keyring 不可用 → 返回 \'\'(不抛)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
// 模拟磁盘上有包络(base64 随便编)
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('whatever', 'utf-8').toString('base64');
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('decrypt 遇到损坏包络(decryptString 抛错)→ 返回 \'\'(不抛)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from(`ENC:${s}`),
decryptString: () => {
throw new Error('bad ciphertext');
},
});
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('corrupt', 'utf-8').toString('base64');
expect(() => mod.decrypt(fakeEnvelope)).not.toThrow();
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('isEncryptionAvailable 自身抛错 → isAvailable() 返回 false', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => {
throw new Error('keyring daemon crashed');
},
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
expect(mod.isAvailable()).toBe(false);
});
});
describe('secret-store:safeStorage 完全缺失', () => {
let warnSpy;
let mod;
beforeEach(async () => {
mod = await loadModule();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
mod._reset();
warnSpy.mockRestore();
});
it('注入 null safeStorage(模拟 require(\'electron\') 无 safeStorage)→ 降级明文', () => {
mod._setSafeStorage(null);
// encrypt 走降级路径
expect(mod.encrypt('sk-x')).toBe('sk-x');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('注入 null safeStorage + decrypt 包络 → 返回 \'\'', () => {
mod._setSafeStorage(null);
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('x', 'utf-8').toString('base64');
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('isAvailable 在无 safeStorage 时 → false', () => {
mod._setSafeStorage(null);
expect(mod.isAvailable()).toBe(false);
});
});
describe('secret-store:_reset 状态隔离', () => {
let mod;
beforeEach(async () => {
mod = await loadModule();
});
it('_reset 后 _setSafeStorage 重新注入 + warn 标志清零', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('a');
mod.encrypt('b');
expect(warnSpy).toHaveBeenCalledTimes(1);
mod._reset();
// 重新注入可用 safeStorage
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from(`E:${s}`),
decryptString: (b) => b.toString('utf-8').slice(2),
});
// warn 标志清零,再次不可用时重新 warn
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('c');
expect(warnSpy).toHaveBeenCalledTimes(2);
} finally {
warnSpy.mockRestore();
mod._reset();
}
});
});