测试: - 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
253 lines
9.3 KiB
TypeScript
253 lines
9.3 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';
|
||
|
||
// --- 导入被测模块(在 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: |