Files
hms/apps/miniprogram/__tests__/utils/secure-storage.test.ts
iven 898e22c715 feat(mp): Phase 1 测试覆盖 + UX 无障碍 — 106 tests PASS + ARIA + focus ring
测试:
- secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界)
- request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited)
- mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败

UX 无障碍 (10 组件):
- SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected
- PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy
- Loading/LoadingCard: role=status + aria-live=polite
- EmptyState: role=status + aria-live=polite
- ErrorState: role=alert + aria-live=assertive
- TrendChart tooltip: role=tooltip + aria-live=polite
- FormInput: aria-invalid + aria-label

焦点管理:
- 新增 _focus-ring.scss mixin (focus + focus-visible)
- 5 组件 SCSS 应用 focus-ring
2026-05-22 00:24:06 +08:00

253 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 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';
// --- 导入被测模块(在 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: ';
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然后尝试 XOR 也失败,最后返回原始字符串
expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!');
});
});
});