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
This commit is contained in:
@@ -11,6 +11,9 @@ vi.mock('@/services/request', () => ({
|
||||
clearRequestCache: vi.fn(),
|
||||
markLoggingOut: vi.fn(),
|
||||
clearLoggingOut: vi.fn(),
|
||||
getCachedPatientId: vi.fn(() => ''),
|
||||
setCachedPatientId: vi.fn(),
|
||||
resetForTesting: vi.fn(),
|
||||
}));
|
||||
|
||||
/** 创建一个成功的 API 响应 */
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('@/utils/secure-storage', () => ({
|
||||
}));
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api, clearRequestCache, resetForTesting } from '@/services/request';
|
||||
import { api, clearRequestCache, resetForTesting, setCachedPatientId } from '@/services/request';
|
||||
|
||||
describe('request module', () => {
|
||||
beforeEach(() => {
|
||||
@@ -148,4 +148,61 @@ describe('request module', () => {
|
||||
await expect(api.get('/bad-params')).rejects.toThrow('参数错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseCache', () => {
|
||||
it('should cache GET responses and return cached on second call', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
|
||||
|
||||
await api.get('/cached-test');
|
||||
await api.get('/cached-test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache POST requests', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
|
||||
|
||||
await api.post('/no-cache', { a: 1 });
|
||||
await api.post('/no-cache', { a: 1 });
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clearRequestCache should clear cached entries', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
|
||||
|
||||
await api.get('/clear-test');
|
||||
clearRequestCache();
|
||||
await api.get('/clear-test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCachedPatientId', () => {
|
||||
it('should isolate cache entries by patient ID', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
|
||||
setCachedPatientId('patient-A');
|
||||
await api.get('/health/data');
|
||||
|
||||
setCachedPatientId('patient-B');
|
||||
await api.get('/health/data');
|
||||
|
||||
// 不同 patient ID 应各自发请求(缓存隔离)
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUnlimited', () => {
|
||||
it('should bypass concurrency limiter', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: 'ok' } } as any);
|
||||
|
||||
const { requestUnlimited } = await import('@/services/request');
|
||||
await requestUnlimited('GET', '/health/test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
252
apps/miniprogram/__tests__/utils/secure-storage.test.ts
Normal file
252
apps/miniprogram/__tests__/utils/secure-storage.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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: | ||||