/** * secure-storage AES-256-GCM 测试 * 覆盖:加解密对称性、空值删除、明文兼容、迁移、Base64 边界 */ import { vi, describe, it, expect, beforeEach } from 'vitest'; // --- crypto.getRandomValues polyfill --- if (!globalThis.crypto?.getRandomValues) { globalThis.crypto = { getRandomValues: (arr: any) => { for (let i = 0; i < arr.length; i++) arr[i] = (Math.random() * 256) | 0; return arr; }, } as any; } // --- Mock @tarojs/taro (覆盖 setup.ts 中的默认 mock,添加 base64 方法) --- const storage = new Map(); vi.mock('@tarojs/taro', () => ({ default: { getStorageSync: vi.fn((key: string) => storage.get(key) ?? ''), setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }), removeStorageSync: vi.fn((key: string) => { storage.delete(key); }), arrayBufferToBase64: vi.fn((buf: ArrayBuffer) => { const bytes = new Uint8Array(buf); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); }), base64ToArrayBuffer: vi.fn((b64: string) => { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; }), }, })); // --- Mock 加密密钥 --- process.env.TARO_APP_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; process.env.NODE_ENV = 'development'; // --- 导入被测模块(在 mock 之后) --- import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage'; // --- 辅助:直接访问 mock 函数 --- import Taro from '@tarojs/taro'; const mockGet = Taro.getStorageSync as ReturnType; const mockSet = Taro.setStorageSync as ReturnType; const mockRemove = Taro.removeStorageSync as ReturnType; describe('secure-storage AES-256-GCM', () => { beforeEach(() => { storage.clear(); vi.clearAllMocks(); }); // ================================================================ // 1. AES 加解密对称性 // ================================================================ describe('AES 加解密对称性', () => { const cases: Array<[string, string]> = [ ['英文', 'hello world'], ['中文', '你好世界'], ['emoji', '\u{1F600}\u{1F680}\u{1F4A9}'], ['特殊字符', '&"\''], ['JSON', '{"name":"张三","age":30,"nested":{"key":"val"}}'], ['超长字符串', 'A'.repeat(10000)], ['混合内容', 'Hello 你好 \u{1F600} !@#$%^&*()'], ['空格和换行', ' line1\nline2\ttabbed '], ['Unicode 补充', '\u{1F1E8}\u{1F1F3}\u{1F1FA}\u{1F1F8}'], ]; cases.forEach(([label, value]) => { it(`roundtrip: ${label}`, () => { secureSet('test_key', value); const result = secureGet('test_key'); expect(result).toBe(value); }); }); it('多次写入同一 key 产生不同密文(nonce 随机)', () => { secureSet('dup', 'same-value'); const first = storage.get('_es_dup')!; secureSet('dup', 'same-value'); const second = storage.get('_es_dup')!; // 密文应该不同(nonce 不同),但都能解密 expect(first).not.toBe(second); expect(secureGet('dup')).toBe('same-value'); }); it('不同 key 互不干扰', () => { secureSet('key_a', 'value_a'); secureSet('key_b', 'value_b'); expect(secureGet('key_a')).toBe('value_a'); expect(secureGet('key_b')).toBe('value_b'); }); }); // ================================================================ // 2. 空 value 触发 remove // ================================================================ describe('空 value 触发 remove', () => { it('空字符串触发 removeStorageSync', () => { secureSet('empty', ''); expect(mockRemove).toHaveBeenCalledWith('_es_empty'); expect(secureGet('empty')).toBe(''); }); it('先写入再清空应删除存储', () => { secureSet('temp', 'some-data'); expect(secureGet('temp')).toBe('some-data'); secureSet('temp', ''); expect(secureGet('temp')).toBe(''); }); }); // ================================================================ // 3. 明文 fallback 读取兼容 // ================================================================ describe('明文 fallback 兼容', () => { it('直接读取无前缀的明文存储', () => { storage.set('access_token', 'plain-token-123'); expect(secureGet('access_token')).toBe('plain-token-123'); }); it('加密存储优先于明文存储', () => { storage.set('access_token', 'plain-token'); secureSet('access_token', 'encrypted-token'); expect(secureGet('access_token')).toBe('encrypted-token'); }); it('存储值非字符串时回退到明文', () => { // getStorageSync mock 返回 ''(默认值),prefixed key 不存在 // 明文 key 也返回 '' expect(secureGet('nonexistent')).toBe(''); }); }); // ================================================================ // 4. migrateLegacyStorage 迁移逻辑 // ================================================================ describe('migrateLegacyStorage', () => { it('将明文数据迁移到加密存储并删除原始 key', () => { storage.set('access_token', 'legacy-token'); storage.set('refresh_token', 'legacy-refresh'); storage.set('user_data', '{"id":"123"}'); migrateLegacyStorage(); // 明文 key 应被删除 expect(storage.has('access_token')).toBe(false); expect(storage.has('refresh_token')).toBe(false); expect(storage.has('user_data')).toBe(false); // 加密 key 存在且可解密 expect(secureGet('access_token')).toBe('legacy-token'); expect(secureGet('refresh_token')).toBe('legacy-refresh'); expect(secureGet('user_data')).toBe('{"id":"123"}'); }); it('已加密的 key 不重复迁移', () => { secureSet('access_token', 'already-encrypted'); const spy = vi.spyOn(storage, 'set'); migrateLegacyStorage(); // 不应产生新的 set 调用(除了可能内部的 secureSet 已有的) // 关键:值不变 expect(secureGet('access_token')).toBe('already-encrypted'); }); it('非字符串的明文数据不迁移', () => { // 我们的 mock getStorageSync 对不存在的 key 返回 '' // 模拟: 存一个空字符串的明文值(不迁移) storage.set('tenant_id', ''); migrateLegacyStorage(); // 空字符串不被视为有效数据,不做迁移 expect(storage.has('_es_tenant_id')).toBe(false); }); it('MIGRATION_KEYS 中未列出的 key 不受影响', () => { storage.set('custom_key', 'custom-value'); migrateLegacyStorage(); expect(storage.get('custom_key')).toBe('custom-value'); expect(storage.has('_es_custom_key')).toBe(false); }); it('prefixed key 存在但非 aes: 前缀时重新加密为 AES', () => { // 直接放一个非 aes: 前缀的值到 prefixed key // migrateLegacyStorage 发现 prefixed key 存在且不以 aes: 开头, // 会调用 secureGet → 明文 fallback 返回该值,然后 secureSet 重新加密 storage.set('_es_access_token', 'legacy-ciphertext-or-plain'); migrateLegacyStorage(); // 应被重新加密为 AES 格式 const stored = storage.get('_es_access_token')!; expect(stored.startsWith('aes:')).toBe(true); }); }); // ================================================================ // 5. secureRemove // ================================================================ describe('secureRemove', () => { it('删除加密存储', () => { secureSet('remove_test', 'to-be-removed'); expect(secureGet('remove_test')).toBe('to-be-removed'); secureRemove('remove_test'); expect(secureGet('remove_test')).toBe(''); }); it('删除不存在的 key 不报错', () => { expect(() => secureRemove('nonexistent')).not.toThrow(); }); }); // ================================================================ // 6. Base64 边界 // ================================================================ describe('Base64 边界', () => { it('存储值可正确通过 base64 编解码', () => { const value = 'Test with special chars: '; secureSet('b64_test', value); const result = secureGet('b64_test'); expect(result).toBe(value); }); it('二进制丰富内容加解密', () => { // 构造包含各种 Unicode 范围的字符串 const chars = []; for (let i = 0x20; i < 0x7f; i++) chars.push(String.fromCharCode(i)); // CJK 基本区 for (let i = 0x4e00; i < 0x4e10; i++) chars.push(String.fromCharCode(i)); // emoji chars.push('\u{1F600}', '\u{1F680}', '\u{1F970}'); const value = chars.join(''); secureSet('b64_binary', value); expect(secureGet('b64_binary')).toBe(value); }); it('损坏的 AES 密文返回 null 后走明文 fallback', () => { storage.set('_es_corrupt', 'aes:INVALID_BASE64!!!'); // aesDecrypt 失败返回 null,hasEncryptionKey=true 所以不走 dev plaintext // 最终返回 raw 值 expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!'); }); }); // ================================================================ // 7. 空密钥 dev 模式 — 明文存储兼容 // ================================================================ describe('空密钥 dev 模式', () => { const originalKey = process.env.TARO_APP_ENCRYPTION_KEY; const originalEnv = process.env.NODE_ENV; beforeEach(() => { storage.clear(); vi.clearAllMocks(); }); afterEach(() => { process.env.TARO_APP_ENCRYPTION_KEY = originalKey; process.env.NODE_ENV = originalEnv; }); it('dev 模式空密钥:secureSet 存明文,secureGet 读取成功', () => { process.env.TARO_APP_ENCRYPTION_KEY = ''; process.env.NODE_ENV = 'development'; secureSet('dev_plain', 'hello-dev'); // 应以明文存储 expect(storage.get('_es_dev_plain')).toBe('hello-dev'); expect(secureGet('dev_plain')).toBe('hello-dev'); }); it('dev 模式空密钥:读取 MCP 注入的明文成功', () => { process.env.TARO_APP_ENCRYPTION_KEY = ''; process.env.NODE_ENV = 'development'; storage.set('access_token', 'mcp-injected-token'); expect(secureGet('access_token')).toBe('mcp-injected-token'); }); }); // ================================================================ // 8. production 模式空密钥 — 拒绝加解密 // ================================================================ describe('production 模式空密钥', () => { const originalKey = process.env.TARO_APP_ENCRYPTION_KEY; const originalEnv = process.env.NODE_ENV; beforeEach(() => { storage.clear(); vi.clearAllMocks(); }); afterEach(() => { process.env.TARO_APP_ENCRYPTION_KEY = originalKey; process.env.NODE_ENV = originalEnv; }); it('production 空密钥:secureSet 存明文(无加密可用),secureGet 返回空', () => { process.env.TARO_APP_ENCRYPTION_KEY = ''; process.env.NODE_ENV = 'production'; secureSet('prod_test', 'sensitive-data'); // 应以明文存储(无 key 时 aesEncrypt 返回 null) expect(storage.get('_es_prod_test')).toBe('sensitive-data'); // secureGet: prefixed key 有值但非 aes: 前缀 + hasEncryptionKey=false → 返回 raw expect(secureGet('prod_test')).toBe('sensitive-data'); }); it('production 空密钥:AES 解密失败返回空字符串', () => { process.env.TARO_APP_ENCRYPTION_KEY = ''; process.env.NODE_ENV = 'production'; // 模拟存在一个 aes: 前缀的旧数据 storage.set('_es_old_data', 'aes:INVALID_CORRUPT_DATA'); expect(secureGet('old_data')).toBe(''); }); }); });