补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
331 行
12 KiB
TypeScript
331 行
12 KiB
TypeScript
/**
|
||
* 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<unknown>;
|
||
unfreeze: (params: { id: number; uid?: string }) => Promise<unknown>;
|
||
}
|
||
|
||
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<NfcSecurityDevice>;
|
||
unfreezeDevice: (
|
||
state: SecurityState,
|
||
id: number,
|
||
api?: SecurityApi
|
||
) => Promise<NfcSecurityDevice>;
|
||
resolveSecurityText: (key: string, locale?: string) => string;
|
||
}
|
||
|
||
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<string, unknown> }).uni = {
|
||
getLocale: () => 'zh-CN',
|
||
showToast: jest.fn(),
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete (global as { uni?: Record<string, unknown> }).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(/<button[^>]*class=["'][^"']*primary-cta-btn/g) || []).length;
|
||
expect(primaryBtnCount).toBe(1);
|
||
|
||
// 设计 Token 引用检查
|
||
expect(content).toContain('--oc-primary');
|
||
expect(content).toContain('--oc-border');
|
||
expect(content).toContain('--oc-error');
|
||
expect(content).toContain('--oc-surface');
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('4.3 挂失主按钮用 error token,禁止 #D92D20 与纯白硬编码底', () => {
|
||
const content = fs.readFileSync(SECURITY_PAGE, 'utf-8');
|
||
|
||
// 禁止旧危险色硬编码
|
||
expect(content).not.toContain('#D92D20');
|
||
expect(content).not.toMatch(/#d92d20/i);
|
||
|
||
// 挂失 danger CTA 必须引用 CSS 变量 --oc-error
|
||
expect(content).toMatch(/\.cta-btn-danger\s*\{[^}]*--oc-error/s);
|
||
|
||
// pageTheme 注入 error token
|
||
expect(content).toContain("'--oc-error': utsLightColors.error");
|
||
|
||
// 禁止裸写纯白底(允许 var(--oc-surface, #FFFFFF) 回退)
|
||
expect(content).not.toMatch(/background-color:\s*#FFFFFF\b/i);
|
||
expect(content).not.toMatch(/background:\s*#FFFFFF\b/i);
|
||
});
|
||
|
||
it('4.4 「处理中」禁止写死在页面,须走 resolveSecurityText 且中英齐全', () => {
|
||
const pageContent = fs.readFileSync(SECURITY_PAGE, 'utf-8');
|
||
const utsContent = fs.readFileSync(SECURITY_UTS, 'utf-8');
|
||
|
||
expect(pageContent).not.toContain('处理中');
|
||
expect(pageContent).toContain("t('submitting')");
|
||
expect(utsContent).toContain("case 'submitting'");
|
||
|
||
const { resolveSecurityText } = loadUseSecurity();
|
||
expect(resolveSecurityText('submitting', 'zh-CN')).toBe('处理中...');
|
||
expect(resolveSecurityText('submitting', 'en-US')).toBe('Processing...');
|
||
});
|
||
});
|
||
});
|