Phase 0 安全修复: - 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback - production 模式空密钥时拒绝加解密(返回空/不加密) - dev 模式保留明文兼容(warn 日志提醒) - .env/.env.h5 注入随机加密密钥 - secureGet 明文 fallback 按环境分级处理 - 新增 8 个测试覆盖空密钥 dev/production 行为 Phase 1 健康Tab重构: - health/index.tsx 从体征录入页改为健康总览Dashboard - 新增今日体征摘要卡片(2x2 网格 + 状态标签) - 新增快捷入口(录入体征/趋势/报告/用药) - 新增告警提示卡片(待处理告警数量) - 体征录入移至 pkg-health/input/index(已有页面) - useHealthData → useHealthOverview(新增 alertCount) 首页增强: - useHomeData 新增告警计数查询(listPatientAlerts) - 首页新增告警提示卡片入口 - "记录体征"按钮改为跳转录入页而非健康Tab
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
||
* 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<string, string>();
|
||
|
||
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<typeof vi.fn>;
|
||
const mockSet = Taro.setStorageSync as ReturnType<typeof vi.fn>;
|
||
const mockRemove = Taro.removeStorageSync as ReturnType<typeof vi.fn>;
|
||
|
||
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}'],
|
||
['特殊字符', '<script>alert("xss")</script>&"\''],
|
||
['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: |