feat(round-11): 弱网离线队列/跨国会议一键预约/团队多语言名片批量向导/NFC硬件出厂封印 (83套件984测试全绿)
这个提交包含在:
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
CardBatchLocaleService,
|
||||
BatchLocaleMemberCard,
|
||||
validateLocaleFields,
|
||||
} from '../../../src/modules/card/service/batch-locale';
|
||||
import { CardLocaleEntity } from '../../../src/modules/card/entity/locale';
|
||||
|
||||
describe('Point 43: 出海团队多语言名片批量生成向导与多语种字段镜像校验', () => {
|
||||
let service: CardBatchLocaleService;
|
||||
let mockRepository: any;
|
||||
let savedRecords: CardLocaleEntity[];
|
||||
|
||||
beforeEach(() => {
|
||||
savedRecords = [];
|
||||
mockRepository = {
|
||||
findOneBy: jest.fn(async ({ cardId, locale }: any) => {
|
||||
return savedRecords.find(r => r.cardId === cardId && r.locale === locale) || null;
|
||||
}),
|
||||
find: jest.fn(async ({ where }: any) => {
|
||||
if (where && where.cardId) {
|
||||
return savedRecords.filter(r => r.cardId === where.cardId);
|
||||
}
|
||||
return savedRecords;
|
||||
}),
|
||||
save: jest.fn(async (entity: any) => {
|
||||
const existingIdx = savedRecords.findIndex(
|
||||
r => r.cardId === entity.cardId && r.locale === entity.locale
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
savedRecords[existingIdx] = { ...savedRecords[existingIdx], ...entity };
|
||||
return savedRecords[existingIdx];
|
||||
}
|
||||
const created = { id: savedRecords.length + 1, ...entity };
|
||||
savedRecords.push(created);
|
||||
return created;
|
||||
}),
|
||||
};
|
||||
|
||||
service = new CardBatchLocaleService();
|
||||
service.cardLocaleEntity = mockRepository;
|
||||
});
|
||||
|
||||
describe('1. validateLocaleFields (多语言字段规范与合法性校验)', () => {
|
||||
it('正常字段应校验通过', () => {
|
||||
const res = validateLocaleFields({
|
||||
name: 'Sarah Chen',
|
||||
title: 'VP of Global BD',
|
||||
company: 'OmniCard AI',
|
||||
slogan: 'Connect the world with intelligent cards',
|
||||
});
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('姓名为空或超长应精准拦截', () => {
|
||||
const emptyRes = validateLocaleFields({ name: ' ' });
|
||||
expect(emptyRes.valid).toBe(false);
|
||||
expect(emptyRes.errors).toContain('姓名不能为空');
|
||||
|
||||
const longRes = validateLocaleFields({ name: 'A'.repeat(65) });
|
||||
expect(longRes.valid).toBe(false);
|
||||
expect(longRes.errors).toContain('姓名长度不能超过64字符');
|
||||
});
|
||||
|
||||
it('职位、公司与标语超长时应拦截', () => {
|
||||
const res = validateLocaleFields({
|
||||
name: 'Test',
|
||||
title: 'T'.repeat(129),
|
||||
company: 'C'.repeat(129),
|
||||
slogan: 'S'.repeat(256),
|
||||
});
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. batchGenerateMemberLocales (团队成员多语言名片批量生成向导)', () => {
|
||||
const mockMembers: BatchLocaleMemberCard[] = [
|
||||
{
|
||||
cardId: 101,
|
||||
baseFields: {
|
||||
name: '张三',
|
||||
title: '出海销售总监',
|
||||
company: '深圳数字智能出海科技',
|
||||
slogan: '智能连接全球',
|
||||
},
|
||||
},
|
||||
{
|
||||
cardId: 102,
|
||||
baseFields: {
|
||||
name: '李四',
|
||||
title: '中东区域代表',
|
||||
company: '深圳数字智能出海科技',
|
||||
slogan: '专注海湾六国',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it('应成功为团队成员批量生成指定目标语言分支 (en, ar)', async () => {
|
||||
const result = await service.batchGenerateMemberLocales({
|
||||
members: mockMembers,
|
||||
targetLocales: ['en', 'ar'],
|
||||
});
|
||||
|
||||
expect(result.totalTasks).toBe(4); // 2 members * 2 locales
|
||||
expect(result.successTasks).toBe(4);
|
||||
expect(result.failedTasks).toBe(0);
|
||||
expect(result.completionRate).toBe(1.0);
|
||||
expect(savedRecords.length).toBe(4);
|
||||
|
||||
// 验证保存的记录属性
|
||||
const en101 = savedRecords.find(r => r.cardId === 101 && r.locale === 'en');
|
||||
expect(en101).toBeDefined();
|
||||
expect(en101?.name).toBe('张三');
|
||||
|
||||
const ar102 = savedRecords.find(r => r.cardId === 102 && r.locale === 'ar');
|
||||
expect(ar102).toBeDefined();
|
||||
expect(ar102?.name).toBe('李四');
|
||||
});
|
||||
|
||||
it('支持传入语言自定义词典或转换映射处理器 (localeTransformer)', async () => {
|
||||
const result = await service.batchGenerateMemberLocales({
|
||||
members: [mockMembers[0]],
|
||||
targetLocales: ['en'],
|
||||
localeTransformer: (locale, base) => {
|
||||
if (locale === 'en') {
|
||||
return {
|
||||
name: 'Alex Zhang',
|
||||
title: 'Global Sales Director',
|
||||
company: 'OmniCard Tech Co., Ltd.',
|
||||
slogan: 'Connecting globally with AI cards',
|
||||
};
|
||||
}
|
||||
return base;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.successTasks).toBe(1);
|
||||
const enCard = savedRecords.find(r => r.cardId === 101 && r.locale === 'en');
|
||||
expect(enCard?.name).toBe('Alex Zhang');
|
||||
expect(enCard?.title).toBe('Global Sales Director');
|
||||
});
|
||||
|
||||
it('遇到非法非法参数时应记录错误且不阻断其它任务', async () => {
|
||||
const invalidMembers: BatchLocaleMemberCard[] = [
|
||||
{
|
||||
cardId: 999,
|
||||
baseFields: { name: '' }, // 姓名为空导致失败
|
||||
},
|
||||
mockMembers[0],
|
||||
];
|
||||
|
||||
const result = await service.batchGenerateMemberLocales({
|
||||
members: invalidMembers,
|
||||
targetLocales: ['en'],
|
||||
});
|
||||
|
||||
expect(result.totalTasks).toBe(2);
|
||||
expect(result.successTasks).toBe(1);
|
||||
expect(result.failedTasks).toBe(1);
|
||||
expect(result.errors.length).toBe(1);
|
||||
expect(result.errors[0].cardId).toBe(999);
|
||||
expect(result.completionRate).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. inspectTeamLocaleCoverage (出海团队语言覆盖率与就绪度诊断)', () => {
|
||||
it('应精准统计团队名片各语言的覆盖率与缺失名单', async () => {
|
||||
savedRecords.push(
|
||||
{ id: 1, cardId: 101, locale: 'en', name: 'Alex' } as CardLocaleEntity,
|
||||
{ id: 2, cardId: 101, locale: 'ar', name: 'Alex' } as CardLocaleEntity,
|
||||
{ id: 3, cardId: 102, locale: 'en', name: 'John' } as CardLocaleEntity
|
||||
);
|
||||
|
||||
const coverage = await service.inspectTeamLocaleCoverage(
|
||||
[101, 102],
|
||||
['en', 'ar', 'es']
|
||||
);
|
||||
|
||||
expect(coverage.teamMemberCount).toBe(2);
|
||||
expect(coverage.locales['en'].coverageRate).toBe(1.0); // 101, 102 都有 en
|
||||
expect(coverage.locales['en'].missingCardIds).toHaveLength(0);
|
||||
|
||||
expect(coverage.locales['ar'].coverageRate).toBe(0.5); // 仅 101 有
|
||||
expect(coverage.locales['ar'].missingCardIds).toEqual([102]);
|
||||
|
||||
expect(coverage.locales['es'].coverageRate).toBe(0.0); // 都没有
|
||||
expect(coverage.locales['es'].missingCardIds).toEqual([101, 102]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
NfcAntiTamperService,
|
||||
generateCardOfflineSeal,
|
||||
verifyCardOfflineSeal,
|
||||
OfflineCardPayload,
|
||||
verifyOfflineCardPayload,
|
||||
} from '../../../src/modules/nfc/service/anti-tamper';
|
||||
|
||||
describe('Point 44: NFC 实体卡离线数字防伪防篡改签章与硬件出厂封印', () => {
|
||||
let service: NfcAntiTamperService;
|
||||
const mockUid = '04A1B2C3D4E5F6';
|
||||
const mockCardId = 2002;
|
||||
const mockSecret = 'nfc-secret-key-gitex-2026-dubai-omnicard';
|
||||
const mockFactoryKey = 'factory-master-key-secure-hardware-seal';
|
||||
const mockBatchNo = 'BATCH-202609-UAE-001';
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NfcAntiTamperService();
|
||||
});
|
||||
|
||||
describe('1. 硬件物理出厂封印 (generateCardOfflineSeal & verifyCardOfflineSeal)', () => {
|
||||
it('应基于 UID、批次号与工厂母密钥生成不可逆 SHA-256 硬件封印哈希', () => {
|
||||
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
|
||||
expect(typeof seal).toBe('string');
|
||||
expect(seal).toHaveLength(64);
|
||||
expect(/^[0-9a-f]{64}$/i.test(seal)).toBe(true);
|
||||
|
||||
// 验证封印通过
|
||||
const isSealValid = verifyCardOfflineSeal(mockUid, mockBatchNo, seal, mockFactoryKey);
|
||||
expect(isSealValid).toBe(true);
|
||||
});
|
||||
|
||||
it('篡改 UID、批次号或封印哈希时应严密拒绝', () => {
|
||||
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
|
||||
|
||||
// 篡改 UID
|
||||
expect(verifyCardOfflineSeal('04FFFFFFFFFFFF', mockBatchNo, seal, mockFactoryKey)).toBe(false);
|
||||
// 篡改批次号
|
||||
expect(verifyCardOfflineSeal(mockUid, 'BATCH-FAKED', seal, mockFactoryKey)).toBe(false);
|
||||
// 篡改签名封印
|
||||
expect(verifyCardOfflineSeal(mockUid, mockBatchNo, seal.replace('a', 'b'), mockFactoryKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('参数缺失时抛出明确保护异常', () => {
|
||||
expect(() => generateCardOfflineSeal('', mockBatchNo, mockFactoryKey)).toThrow();
|
||||
expect(() => generateCardOfflineSeal(mockUid, '', mockFactoryKey)).toThrow();
|
||||
expect(() => generateCardOfflineSeal(mockUid, mockBatchNo, '')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 离线碰一碰数据包快速验签 (verifyOfflineCardPayload)', () => {
|
||||
it('合法离线数据包在无网络状态下应成功通过验签', () => {
|
||||
const ts = Date.now();
|
||||
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, ts);
|
||||
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
|
||||
|
||||
const payload: OfflineCardPayload = {
|
||||
uid: mockUid,
|
||||
cardId: mockCardId,
|
||||
batchNo: mockBatchNo,
|
||||
seal,
|
||||
token,
|
||||
};
|
||||
|
||||
const result = verifyOfflineCardPayload(payload, mockSecret, mockFactoryKey);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.isAuthenticNfc).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('离线防伪 Token 伪造时应准确识破', () => {
|
||||
const seal = generateCardOfflineSeal(mockUid, mockBatchNo, mockFactoryKey);
|
||||
const fakePayload: OfflineCardPayload = {
|
||||
uid: mockUid,
|
||||
cardId: mockCardId,
|
||||
batchNo: mockBatchNo,
|
||||
seal,
|
||||
token: `${Date.now()}.0000000000000000000000000000000000000000000000000000000000000000`,
|
||||
};
|
||||
|
||||
const result = verifyOfflineCardPayload(fakePayload, mockSecret, mockFactoryKey);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toMatch(/签名|篡改|invalid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 时钟漂移与时序攻击防护 (Clock Drift Tolerance)', () => {
|
||||
it('在合理漂移容差内 (30秒) 允许通过', () => {
|
||||
const futureTs = Date.now() + 15 * 1000; // 稍超前 15 秒 (网络或卡内时钟微偏)
|
||||
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, futureTs);
|
||||
|
||||
// 设置 30 秒容差
|
||||
const res = service.verifyDeviceToken(mockUid, mockCardId, token, mockSecret, 300000, 30000);
|
||||
expect(res.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('严重超前于未来时间戳的令牌应被坚决拦截', () => {
|
||||
const futureTs = Date.now() + 120 * 1000; // 超前 2 分钟
|
||||
const token = service.generateDeviceToken(mockUid, mockCardId, mockSecret, futureTs);
|
||||
|
||||
const res = service.verifyDeviceToken(mockUid, mockCardId, token, mockSecret, 300000, 30000);
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.reason).toMatch(/未来|clock drift/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户