diff --git a/apps/miniprogram/__tests__/helpers/mock-api.ts b/apps/miniprogram/__tests__/helpers/mock-api.ts index d2c40fa..f0e00c0 100644 --- a/apps/miniprogram/__tests__/helpers/mock-api.ts +++ b/apps/miniprogram/__tests__/helpers/mock-api.ts @@ -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 响应 */ diff --git a/apps/miniprogram/__tests__/services/request.test.ts b/apps/miniprogram/__tests__/services/request.test.ts index 38d17d1..fca70ed 100644 --- a/apps/miniprogram/__tests__/services/request.test.ts +++ b/apps/miniprogram/__tests__/services/request.test.ts @@ -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' }), + ); + }); + }); }); diff --git a/apps/miniprogram/__tests__/utils/secure-storage.test.ts b/apps/miniprogram/__tests__/utils/secure-storage.test.ts new file mode 100644 index 0000000..a5e8a12 --- /dev/null +++ b/apps/miniprogram/__tests__/utils/secure-storage.test.ts @@ -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(); + +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; +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,然后尝试 XOR 也失败,最后返回原始字符串 + expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!'); + }); + }); +}); diff --git a/apps/miniprogram/src/components/EmptyState/index.tsx b/apps/miniprogram/src/components/EmptyState/index.tsx index fc31406..6c4ad1b 100644 --- a/apps/miniprogram/src/components/EmptyState/index.tsx +++ b/apps/miniprogram/src/components/EmptyState/index.tsx @@ -19,7 +19,7 @@ export default React.memo(function EmptyState({ }: EmptyStateProps) { const displayChar = icon || text.charAt(0); return ( - + {displayChar} diff --git a/apps/miniprogram/src/components/ErrorState/index.tsx b/apps/miniprogram/src/components/ErrorState/index.tsx index 796e12e..3710d86 100644 --- a/apps/miniprogram/src/components/ErrorState/index.tsx +++ b/apps/miniprogram/src/components/ErrorState/index.tsx @@ -12,7 +12,7 @@ export default React.memo(function ErrorState({ onRetry, }: ErrorStateProps) { return ( - + ⚠️ {text} {onRetry && ( diff --git a/apps/miniprogram/src/components/Loading/index.tsx b/apps/miniprogram/src/components/Loading/index.tsx index 95f1dc7..f1cfe39 100644 --- a/apps/miniprogram/src/components/Loading/index.tsx +++ b/apps/miniprogram/src/components/Loading/index.tsx @@ -9,7 +9,7 @@ interface LoadingProps { export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) { const isListEnd = text !== '加载中...' && !text.includes('加载'); return ( - + {!isListEnd && } {text} diff --git a/apps/miniprogram/src/components/SegmentTabs/index.scss b/apps/miniprogram/src/components/SegmentTabs/index.scss index 43cd8cb..b8089d5 100644 --- a/apps/miniprogram/src/components/SegmentTabs/index.scss +++ b/apps/miniprogram/src/components/SegmentTabs/index.scss @@ -15,6 +15,8 @@ justify-content: center; position: relative; + @include focus-ring; + &--active { .seg-tab__text { color: var(--tk-pri); @@ -54,6 +56,8 @@ justify-content: center; transition: all 0.2s; + @include focus-ring; + &--active { background: var(--tk-pri); box-shadow: var(--tk-shadow-tab); diff --git a/apps/miniprogram/src/components/SegmentTabs/index.tsx b/apps/miniprogram/src/components/SegmentTabs/index.tsx index 14a0406..b281294 100644 --- a/apps/miniprogram/src/components/SegmentTabs/index.tsx +++ b/apps/miniprogram/src/components/SegmentTabs/index.tsx @@ -21,11 +21,13 @@ export default React.memo(function SegmentTabs({ variant = 'underline', }: SegmentTabsProps) { return ( - + {tabs.map((tab) => ( onChange(tab.key)} > {tab.label} diff --git a/apps/miniprogram/src/components/TrendChart/index.tsx b/apps/miniprogram/src/components/TrendChart/index.tsx index 1389122..3e4435a 100644 --- a/apps/miniprogram/src/components/TrendChart/index.tsx +++ b/apps/miniprogram/src/components/TrendChart/index.tsx @@ -210,6 +210,8 @@ export default React.memo(function TrendChart({ {tooltip && ( diff --git a/apps/miniprogram/src/components/ui/DoctorTabBar/index.scss b/apps/miniprogram/src/components/ui/DoctorTabBar/index.scss index dd15f7a..32bdf14 100644 --- a/apps/miniprogram/src/components/ui/DoctorTabBar/index.scss +++ b/apps/miniprogram/src/components/ui/DoctorTabBar/index.scss @@ -25,6 +25,8 @@ cursor: pointer; -webkit-tap-highlight-color: transparent; + @include focus-ring; + &--active { .doctor-tabbar__icon { transform: scale(1.15); diff --git a/apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx b/apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx index b416557..d0103e9 100644 --- a/apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx +++ b/apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx @@ -31,11 +31,13 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) { }; return ( - + {DOCTOR_TABS.map((tab) => ( handleTab(tab)} > {tab.key === activeKey ? tab.activeIcon : tab.icon} diff --git a/apps/miniprogram/src/components/ui/FormInput/index.scss b/apps/miniprogram/src/components/ui/FormInput/index.scss index f08e4a7..efa3a26 100644 --- a/apps/miniprogram/src/components/ui/FormInput/index.scss +++ b/apps/miniprogram/src/components/ui/FormInput/index.scss @@ -17,6 +17,12 @@ display: flex; align-items: center; transition: border-color 0.2s; + + &:focus-within { + outline: $focus-ring-width solid $focus-ring-color; + outline-offset: $focus-ring-offset; + border-color: var(--tk-pri); + } } &__control { diff --git a/apps/miniprogram/src/components/ui/FormInput/index.tsx b/apps/miniprogram/src/components/ui/FormInput/index.tsx index b4c337a..02e3886 100644 --- a/apps/miniprogram/src/components/ui/FormInput/index.tsx +++ b/apps/miniprogram/src/components/ui/FormInput/index.tsx @@ -45,6 +45,8 @@ const FormInput: React.FC = ({ type={type} maxlength={maxLength} disabled={disabled} + aria-invalid={!!error} + aria-label={label || placeholder} /> {error && {error}} diff --git a/apps/miniprogram/src/components/ui/LoadingCard/index.tsx b/apps/miniprogram/src/components/ui/LoadingCard/index.tsx index 44225ce..762e7e4 100644 --- a/apps/miniprogram/src/components/ui/LoadingCard/index.tsx +++ b/apps/miniprogram/src/components/ui/LoadingCard/index.tsx @@ -12,7 +12,7 @@ const LoadingCard: React.FC = ({ layout = 'card', }) => { return ( - + {Array.from({ length: count }, (_, i) => ( {layout === 'card' && ( diff --git a/apps/miniprogram/src/components/ui/PrimaryButton/index.scss b/apps/miniprogram/src/components/ui/PrimaryButton/index.scss index 66d9933..8914d83 100644 --- a/apps/miniprogram/src/components/ui/PrimaryButton/index.scss +++ b/apps/miniprogram/src/components/ui/PrimaryButton/index.scss @@ -29,6 +29,8 @@ transform: scale(0.98); } + @include focus-ring; + &--disabled { opacity: 0.5; box-shadow: none; diff --git a/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx b/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx index 8ba21a9..b61f141 100644 --- a/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx +++ b/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx @@ -28,7 +28,7 @@ const PrimaryButton: React.FC = ({ ].filter(Boolean).join(' '); return ( - + {loading && } {children} diff --git a/apps/miniprogram/src/components/ui/SecondaryButton/index.scss b/apps/miniprogram/src/components/ui/SecondaryButton/index.scss index fd13b6a..446f8c1 100644 --- a/apps/miniprogram/src/components/ui/SecondaryButton/index.scss +++ b/apps/miniprogram/src/components/ui/SecondaryButton/index.scss @@ -18,6 +18,8 @@ transform: scale(0.98); } + @include focus-ring; + &--disabled { opacity: 0.5; } diff --git a/apps/miniprogram/src/components/ui/SecondaryButton/index.tsx b/apps/miniprogram/src/components/ui/SecondaryButton/index.tsx index 14d6ede..d586807 100644 --- a/apps/miniprogram/src/components/ui/SecondaryButton/index.tsx +++ b/apps/miniprogram/src/components/ui/SecondaryButton/index.tsx @@ -22,7 +22,7 @@ const SecondaryButton: React.FC = ({ ].filter(Boolean).join(' '); return ( - + {children} ); diff --git a/apps/miniprogram/src/styles/_focus-ring.scss b/apps/miniprogram/src/styles/_focus-ring.scss new file mode 100644 index 0000000..b4cb182 --- /dev/null +++ b/apps/miniprogram/src/styles/_focus-ring.scss @@ -0,0 +1,15 @@ +// Focus ring mixin for keyboard accessibility +$focus-ring-color: #1890ff; +$focus-ring-width: 2px; +$focus-ring-offset: 2px; + +@mixin focus-ring { + &:focus { + outline: none; + box-shadow: 0 0 0 $focus-ring-offset $focus-ring-color; + } + &:focus-visible { + outline: $focus-ring-width solid $focus-ring-color; + outline-offset: $focus-ring-offset; + } +} diff --git a/apps/miniprogram/src/styles/variables.scss b/apps/miniprogram/src/styles/variables.scss index b797fba..4976a43 100644 --- a/apps/miniprogram/src/styles/variables.scss +++ b/apps/miniprogram/src/styles/variables.scss @@ -68,3 +68,6 @@ $shadow-btn: 0 4px 16px rgba(196, 98, 58, 0.3); // 主按钮阴影 $shadow-tab: 0 2px 8px rgba(196, 98, 58, 0.25); // 选中Tab阴影 $shadow-btn-doc: 0 4px 16px rgba(58, 107, 140, 0.3); // 医生端按钮阴影 $shadow-tab-doc: 0 2px 8px rgba(58, 107, 140, 0.25); // 医生端Tab阴影 + +// ─── 焦点环(无障碍)─── +@import 'focus-ring';