/** * OmniCard AI (CRMmp) - 移动端 NFC 安全与远程挂失测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 规范与 Point 13 需求编写: * 1. 管理 NFC 硬件列表与在线/挂失冻结状态 ('active' / 'frozen') * 2. freezeDevice(id) 与 unfreezeDevice(id) 状态流转与异常保护 * 3. 防伪 UID 校验与安全哈希查验 * 4. 极简瑞士国际主义风格规范(零 emoji、单一主 CTA、触控区 >= 44x44) */ import * as fs from 'fs'; import * as path from 'path'; const SECURITY_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/owner/nfc/security.uvue' ); const SECURITY_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/owner/nfc/useSecurity.uts' ); const PAGES_JSON = path.resolve(__dirname, '../../../mpuniapp/pages.json'); export type SecurityDeviceStatus = 'active' | 'frozen'; export interface NfcSecurityDevice { id: number; uid: string; cardId?: number | null; status: SecurityDeviceStatus; securityHash?: string; batchNo?: string; material?: string; lastScannedAt?: string | null; } export interface SecurityState { devices: NfcSecurityDevice[]; selectedId: number | null; submitting: boolean; errorMessage: string; } export interface SecurityApi { freeze: (params: { id: number; uid?: string }) => Promise; unfreeze: (params: { id: number; uid?: string }) => Promise; } export interface UseSecurityModule { normalizeUid: (raw: string | null | undefined) => string; validateNfcUid: (raw: string | null | undefined) => { valid: boolean; normalizedUid: string; error?: string; }; computeSecurityHash: (uid: string, salt?: string) => string; verifySecurityHash: (uid: string, hash: string, salt?: string) => boolean; createSecurityState: (initialDevices?: NfcSecurityDevice[]) => SecurityState; selectDevice: (state: SecurityState, id: number) => void; freezeDevice: ( state: SecurityState, id: number, api?: SecurityApi ) => Promise; unfreezeDevice: ( state: SecurityState, id: number, api?: SecurityApi ) => Promise; } function loadUseSecurity(): UseSecurityModule { return require(SECURITY_UTS) as UseSecurityModule; } const EMOJI_REGEX = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; describe('Point 13: 移动端 NFC 安全与远程挂失测试套件', () => { beforeEach(() => { jest.resetModules(); (global as { uni?: Record }).uni = { getLocale: () => 'zh-CN', showToast: jest.fn(), }; }); afterEach(() => { delete (global as { uni?: Record }).uni; }); describe('1. 防伪 UID 校验 (validateNfcUid & normalizeUid)', () => { it('1.1 规范化 UID,过滤冒号、空格、连字符并转大写', () => { const { normalizeUid } = loadUseSecurity(); expect(normalizeUid('04:a3:2b:1c:8e:5f:90')).toBe('04A32B1C8E5F90'); expect(normalizeUid(' 04-a3-2b-1c-8e-5f-90 ')).toBe('04A32B1C8E5F90'); expect(normalizeUid('e1 2a 3b 4c')).toBe('E12A3B4C'); expect(normalizeUid('')).toBe(''); expect(normalizeUid(null)).toBe(''); }); it('1.2 正确识别合法的 7 字节 (14 字符) 与 4 字节 (8 字符) 标准 NFC UID', () => { const { validateNfcUid } = loadUseSecurity(); const res7Byte = validateNfcUid('04:A3:2B:1C:8E:5F:90'); expect(res7Byte.valid).toBe(true); expect(res7Byte.normalizedUid).toBe('04A32B1C8E5F90'); const res4Byte = validateNfcUid('E12A3B4C'); expect(res4Byte.valid).toBe(true); expect(res4Byte.normalizedUid).toBe('E12A3B4C'); }); it('1.3 拒绝非法长度、非 Hex 字符或空值', () => { const { validateNfcUid } = loadUseSecurity(); expect(validateNfcUid('').valid).toBe(false); expect(validateNfcUid(' ').valid).toBe(false); expect(validateNfcUid(null).valid).toBe(false); // 包含非 Hex 字符 expect(validateNfcUid('04A32B1C8E5F9Z').valid).toBe(false); // 长度不合规 (例如 10 位) expect(validateNfcUid('04A32B1C8E').valid).toBe(false); // 奇数长度 expect(validateNfcUid('04A32B1C8E5F9').valid).toBe(false); }); }); describe('2. 防伪安全哈希查验 (computeSecurityHash & verifySecurityHash)', () => { it('2.1 能为合法的 UID 计算防伪安全哈希', () => { const { computeSecurityHash } = loadUseSecurity(); const hash1 = computeSecurityHash('04A32B1C8E5F90'); const hash2 = computeSecurityHash('04A32B1C8E5F90'); expect(typeof hash1).toBe('string'); expect(hash1.length).toBeGreaterThanOrEqual(16); expect(hash1).toBe(hash2); // 确定性 }); it('2.2 正确校验防伪哈希,阻断篡改与伪造', () => { const { computeSecurityHash, verifySecurityHash } = loadUseSecurity(); const uid = '04A32B1C8E5F90'; const hash = computeSecurityHash(uid, 'OMNICARD_SALT'); // 验签通过 expect(verifySecurityHash(uid, hash, 'OMNICARD_SALT')).toBe(true); // 篡改 UID 校验失败 expect(verifySecurityHash('04A32B1C8E5F91', hash, 'OMNICARD_SALT')).toBe(false); // 篡改 hash 校验失败 expect(verifySecurityHash(uid, hash + 'X', 'OMNICARD_SALT')).toBe(false); // 伪造 salt 校验失败 expect(verifySecurityHash(uid, hash, 'WRONG_SALT')).toBe(false); }); it('2.3 空 UID 或空 Hash 拒绝校验', () => { const { verifySecurityHash } = loadUseSecurity(); expect(verifySecurityHash('', 'anyhash')).toBe(false); expect(verifySecurityHash('04A32B1C8E5F90', '')).toBe(false); }); }); describe('3. 设备列表与挂失/解挂状态流转 (freezeDevice & unfreezeDevice)', () => { function getMockDevices(): NfcSecurityDevice[] { return [ { id: 101, uid: '04A32B1C8E5F90', cardId: 1, status: 'active', batchNo: 'B2026-01', }, { id: 102, uid: '04B32B1C8E5F91', cardId: 2, status: 'frozen', batchNo: 'B2026-02', }, ]; } it('3.1 初始化状态与设备切换', () => { const { createSecurityState, selectDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); expect(state.devices).toHaveLength(2); expect(state.selectedId).toBe(101); // 默认选中首个设备 selectDevice(state, 102); expect(state.selectedId).toBe(102); }); it('3.2 freezeDevice 正常冻结状态流转并调用 API', async () => { const { createSecurityState, freezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); const freezeApiMock = jest.fn().mockResolvedValue({ success: true }); const api: SecurityApi = { freeze: freezeApiMock, unfreeze: jest.fn(), }; const updated = await freezeDevice(state, 101, api); expect(freezeApiMock).toHaveBeenCalledWith({ id: 101, uid: '04A32B1C8E5F90' }); expect(updated.status).toBe('frozen'); expect(state.devices.find((d) => d.id === 101)?.status).toBe('frozen'); }); it('3.3 freezeDevice 重复冻结应防护抛出异常', async () => { const { createSecurityState, freezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); // 设备 102 初始即为 frozen await expect(freezeDevice(state, 102)).rejects.toThrow(/已处于挂失冻结状态/); }); it('3.4 unfreezeDevice 正常解冻恢复状态流转并调用 API', async () => { const { createSecurityState, unfreezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); const unfreezeApiMock = jest.fn().mockResolvedValue({ success: true }); const api: SecurityApi = { freeze: jest.fn(), unfreeze: unfreezeApiMock, }; const updated = await unfreezeDevice(state, 102, api); expect(unfreezeApiMock).toHaveBeenCalledWith({ id: 102, uid: '04B32B1C8E5F91' }); expect(updated.status).toBe('active'); expect(state.devices.find((d) => d.id === 102)?.status).toBe('active'); }); it('3.5 unfreezeDevice 对非冻结设备解冻应抛出异常', async () => { const { createSecurityState, unfreezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); // 设备 101 初始为 active await expect(unfreezeDevice(state, 101)).rejects.toThrow(/未处于挂失冻结状态/); }); it('3.6 操作不存在的设备应抛出异常', async () => { const { createSecurityState, freezeDevice, unfreezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); await expect(freezeDevice(state, 9999)).rejects.toThrow(/设备不存在/); await expect(unfreezeDevice(state, 9999)).rejects.toThrow(/设备不存在/); }); it('3.7 API 调用失败时应捕获错误并保留原始状态', async () => { const { createSecurityState, freezeDevice } = loadUseSecurity(); const state = createSecurityState(getMockDevices()); const failingApi: SecurityApi = { freeze: jest.fn().mockRejectedValue(new Error('网络超时')), unfreeze: jest.fn(), }; await expect(freezeDevice(state, 101, failingApi)).rejects.toThrow(/网络超时/); // 状态未被篡改成 frozen expect(state.devices.find((d) => d.id === 101)?.status).toBe('active'); }); }); describe('4. 界面与设计规范 (security.uvue & pages.json)', () => { it('4.1 页面文件 security.uvue 存在且符合极简规范', () => { expect(fs.existsSync(SECURITY_PAGE)).toBe(true); const content = fs.readFileSync(SECURITY_PAGE, 'utf-8'); // 严格零 emoji 检查 expect(EMOJI_REGEX.test(content)).toBe(false); // 单一主 CTA 检查 (单屏只允许 1 个主 CTA 按钮) const primaryBtnCount = (content.match(/]*class=["'][^"']*primary-cta-btn/g) || []).length; expect(primaryBtnCount).toBe(1); // 设计 Token 引用检查 expect(content).toContain('--oc-primary'); expect(content).toContain('--oc-border'); }); it('4.2 pages.json 中已正确注册 pages/owner/nfc/security 页面', () => { expect(fs.existsSync(PAGES_JSON)).toBe(true); const pagesContent = fs.readFileSync(PAGES_JSON, 'utf-8'); const cleanJson = pagesContent.replace(/\/\/[^\n\r]*/g, ''); const pagesConfig = JSON.parse(cleanJson); const registered = pagesConfig.pages.some( (p: { path: string }) => p.path === 'pages/owner/nfc/security' ); expect(registered).toBe(true); }); }); });