feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop
这个提交包含在:
@@ -12,6 +12,7 @@ describe('PC总后台管理端核心业务与闭环能力自动化测试 (Post M
|
||||
const adminFinanceWithdrawVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/finance/withdraw/index.vue');
|
||||
const adminPlatformConfigVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue');
|
||||
const adminCommunityRouterPath = path.join(rootDir, 'admin/arco-design-pro-vite/src/router/routes/modules/community.ts');
|
||||
const adminUserListVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/user/list/index.vue');
|
||||
|
||||
describe('1. 友圈广场动态管理与置顶/状态流转/级联删除闭环验证', () => {
|
||||
it('数据库层必须扩展 posts 表结构并包含 is_top 与 status 字段', () => {
|
||||
@@ -118,14 +119,91 @@ describe('PC总后台管理端核心业务与闭环能力自动化测试 (Post M
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 全流程无 Emoji 门禁合规校验', () => {
|
||||
describe('4. 全平台用户列表删除与批量删除闭环校验', () => {
|
||||
it('数据仓储层 MysqlRepository 必须实现 deleteUser 与 deleteUsers 方法', () => {
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(repoCode).toContain('async deleteUser(id: number): Promise<boolean>');
|
||||
expect(repoCode).toContain('async deleteUsers(ids: number[]): Promise<number>');
|
||||
expect(repoCode).toContain('DELETE FROM users WHERE id = ?');
|
||||
expect(repoCode).toContain('DELETE FROM users WHERE id IN');
|
||||
});
|
||||
|
||||
it('后端服务 index.ts 必须注册单个删除与批量删除 API 路由', () => {
|
||||
const serverCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(serverCode).toContain("app.delete('/api/users/:id'");
|
||||
expect(serverCode).toContain("app.post('/api/users/batch-delete'");
|
||||
expect(serverCode).toContain('repo.deleteUser(');
|
||||
expect(serverCode).toContain('repo.deleteUsers(');
|
||||
});
|
||||
|
||||
it('用户列表前端视图 user/list/index.vue 必须包含行级删除与批量删除按钮及确认弹窗', () => {
|
||||
const userListCode = fs.readFileSync(adminUserListVuePath, 'utf-8');
|
||||
expect(userListCode).toContain('handleDeleteUser');
|
||||
expect(userListCode).toContain('handleBatchDelete');
|
||||
expect(userListCode).toContain('selectedRowKeys');
|
||||
expect(userListCode).toContain('icon-delete');
|
||||
expect(userListCode).toContain('a-popconfirm');
|
||||
expect(userListCode).toContain('/api/users/batch-delete');
|
||||
expect(userListCode).toContain('/api/users/${record.id}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 大神认证资质解除与接单状态持久化闭环校验', () => {
|
||||
const adminCompanionListVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/list/index.vue');
|
||||
const adminCompanionAuditVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/audit/index.vue');
|
||||
const uniApiJsPath = path.join(rootDir, 'pwuniapp/common/api.js');
|
||||
|
||||
it('仓储层 auditCompanion 必须支持 revoke 解除认证动作并联动回退用户角色与双身份标识', () => {
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(repoCode).toContain("action: 'approve' | 'reject' | 'revoke'");
|
||||
expect(repoCode).toContain('已解除大神认证资质');
|
||||
expect(repoCode).toContain('const isCertExpert = companionRow ? certStatus === 2 : (certStatus === 2 || roleHasExpert)');
|
||||
});
|
||||
|
||||
it('管理后台大神档案库必须支持解除认证弹窗与接单状态真实持久化', () => {
|
||||
const listCode = fs.readFileSync(adminCompanionListVuePath, 'utf-8');
|
||||
expect(listCode).toContain('解除认证');
|
||||
expect(listCode).toContain('openRevokeModal');
|
||||
expect(listCode).toContain('handleRevokeSubmit');
|
||||
expect(listCode).toContain("action: 'revoke'");
|
||||
expect(listCode).toContain('axios.put(`/api/companions/${record.id}`');
|
||||
});
|
||||
|
||||
it('管理后台入驻资质审核页必须支持对已通过大神解除认证及对已驳回记录恢复认证', () => {
|
||||
const auditCode = fs.readFileSync(adminCompanionAuditVuePath, 'utf-8');
|
||||
expect(auditCode).toContain('解除认证');
|
||||
expect(auditCode).toContain('恢复认证');
|
||||
expect(auditCode).toContain("openRejectModal(record, 'revoke')");
|
||||
});
|
||||
|
||||
it('小程序端获取同城大神列表必须过滤未通过或已解除认证的记录', () => {
|
||||
const apiCode = fs.readFileSync(uniApiJsPath, 'utf-8');
|
||||
expect(apiCode).toContain("c.auditStatus === '已通过'");
|
||||
});
|
||||
|
||||
it('微信支付配置必须支持微信支付平台公钥上传与商户证书序列号自动提取', () => {
|
||||
const configViewCode = fs.readFileSync(adminPlatformConfigVuePath, 'utf-8');
|
||||
const serverCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(configViewCode).toContain('platformPublicKeyId');
|
||||
expect(configViewCode).toContain('platformPublicKey');
|
||||
expect(configViewCode).toContain("triggerPemSelect('platformPublicKey')");
|
||||
expect(configViewCode).toContain('extractCertSerialNo');
|
||||
expect(serverCode).toContain("app.post('/api/admin/pay/wechat/parse-cert'");
|
||||
expect(serverCode).toContain('extractSerialNoFromCertPem');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 全流程无 Emoji 门禁合规校验', () => {
|
||||
it('新增的前后端代码中严禁包含任何系统 Emoji 图标字符', () => {
|
||||
const filesToCheck = [
|
||||
adminCommunityRouterPath,
|
||||
adminPostManageVuePath,
|
||||
adminFinanceRecordsVuePath,
|
||||
adminFinanceWithdrawVuePath,
|
||||
adminPlatformConfigVuePath
|
||||
adminPlatformConfigVuePath,
|
||||
adminUserListVuePath,
|
||||
path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/list/index.vue'),
|
||||
path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/audit/index.vue')
|
||||
];
|
||||
|
||||
const emojiRegex = /[\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u26FF\u2700-\u27BF]/;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第八批回归闸门:后台"静默兜底"数据盲区
|
||||
* 接口失败时不得再以伪造/默认/归零数据伪装成功,必须显式暴露失败状态。
|
||||
*/
|
||||
describe('第八批后台可观测性闸门 (静默兜底数据盲区)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const adminSrc = path.join(rootDir, 'admin/arco-design-pro-vite/src/views');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(adminSrc, rel), 'utf-8');
|
||||
|
||||
const financeConfig = read('finance/config/index.vue');
|
||||
const dataPanel = read('dashboard/workplace/components/data-panel.vue');
|
||||
const actionHub = read('dashboard/workplace/components/action-hub.vue');
|
||||
const quickTodo = read('dashboard/workplace/components/quick-todo-list.vue');
|
||||
const quickVerifier = read('dashboard/workplace/components/quick-verifier.vue');
|
||||
|
||||
describe('1. 财务/微信支付配置:读取失败不得用默认值覆盖线上', () => {
|
||||
it('维护 configLoaded / wxPayLoaded 加载状态', () => {
|
||||
expect(financeConfig).toContain('const configLoaded = ref(false)');
|
||||
expect(financeConfig).toContain('const wxPayLoaded = ref(false)');
|
||||
});
|
||||
|
||||
it('读取失败必须锁定保存按钮', () => {
|
||||
expect(financeConfig).toContain(':disabled="!configLoaded"');
|
||||
expect(financeConfig).toContain(':disabled="!wxPayLoaded"');
|
||||
});
|
||||
|
||||
it('读取失败必须提示并阻止保存覆盖', () => {
|
||||
expect(financeConfig).toContain('避免以默认值覆盖线上配置');
|
||||
expect(financeConfig).toContain('避免清空线上商户参数');
|
||||
expect(financeConfig).not.toContain('// 保持兜底');
|
||||
expect(financeConfig).not.toContain('// 保持默认');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 经营指标:不得展示伪造数字', () => {
|
||||
it('移除硬编码伪造经营数据并暴露失败状态', () => {
|
||||
expect(dataPanel).toContain('const loadFailed = ref(false)');
|
||||
expect(dataPanel).not.toContain('58920');
|
||||
expect(dataPanel).not.toContain('1250');
|
||||
expect(dataPanel).toContain('核心经营指标加载失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 待办中心:不得静默归零', () => {
|
||||
it('实时待办暴露失败状态', () => {
|
||||
expect(actionHub).toContain('const loadFailed = ref(false)');
|
||||
expect(actionHub).not.toContain('pendingAuditsCount: 2');
|
||||
expect(actionHub).toContain('待办数据加载失败');
|
||||
});
|
||||
|
||||
it('重点待办列表暴露失败状态', () => {
|
||||
expect(quickTodo).toContain('const loadFailed = ref(false)');
|
||||
expect(quickTodo).toContain('重点待办加载失败');
|
||||
expect(quickTodo).not.toContain('// 保持兜底');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 现场核销:活动列表失败必须提示', () => {
|
||||
it('核销活动列表暴露失败状态', () => {
|
||||
expect(quickVerifier).toContain('const activitiesFailed = ref(false)');
|
||||
expect(quickVerifier).toContain('组局活动列表加载失败');
|
||||
expect(quickVerifier).not.toContain('// 保持兜底');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,24 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
||||
expect(content).toContain('signature');
|
||||
expect(content).toContain('contentSecurityService.checkText');
|
||||
});
|
||||
|
||||
it('后端 index.ts POST /api/users/:id/profile 必须校验手机号与微信号格式并拦截非法输入', () => {
|
||||
const indexPath = path.join(serverSrcDir, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('1[3-9]');
|
||||
expect(content).toContain('a-zA-Z');
|
||||
expect(content).toContain('联系方式格式不正确');
|
||||
});
|
||||
|
||||
it('前端 user/index.vue 必须集成联系方式格式校验与非法标点过滤', () => {
|
||||
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
|
||||
const content = fs.readFileSync(userVuePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('validateContact');
|
||||
expect(content).toContain('onPhoneInput');
|
||||
expect(content).toContain('field-label-row');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 前端核心页面规范与结构完整性校验', () => {
|
||||
@@ -488,8 +506,26 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
||||
const indexContent = fs.readFileSync(indexVuePath, 'utf-8');
|
||||
expect(indexContent).toContain('PENDING_INVITER_CODE');
|
||||
});
|
||||
|
||||
it('3.4 已封禁用户核心业务防线:后端仓储与路由必须全面拦截发单、报名、下单与动态评论', () => {
|
||||
const serverIndexPath = path.join(serverSrcDir, 'index.ts');
|
||||
const indexContent = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(indexContent).toContain('assertUserNotBanned');
|
||||
expect(indexContent).toContain('您的账号已被管理员封禁');
|
||||
|
||||
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||
const repoContent = fs.readFileSync(repoPath, 'utf-8');
|
||||
expect(repoContent).toContain("WHERE (u.status IS NULL OR u.status != '已封禁')");
|
||||
expect(repoContent).toContain("UPDATE activities SET status = 'CANCELLED' WHERE creator_id = ? AND status = 'RECRUITING'");
|
||||
|
||||
const rolePath = path.join(rootDir, 'pwuniapp', 'common', 'role.js');
|
||||
const roleContent = fs.readFileSync(rolePath, 'utf-8');
|
||||
expect(roleContent).toContain('isUserBanned');
|
||||
expect(roleContent).toContain("uInfo.status === '已封禁'");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(Array.isArray(config.pages)).toBe(true);
|
||||
expect(config.pages.length).toBe(22);
|
||||
expect(config.pages.length).toBe(25);
|
||||
|
||||
for (const pageItem of config.pages) {
|
||||
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
|
||||
@@ -1119,6 +1119,120 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
expect(emojiRegex.test(fs.readFileSync(apiPath, 'utf-8'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('24. 独立专属钱包充值页面规格验证 (pages/user/recharge.vue)', () => {
|
||||
const rechargePath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'recharge.vue');
|
||||
const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json');
|
||||
const userIndexPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue');
|
||||
|
||||
it('pages.json 必须成功注册 pages/user/recharge 且物理文件必须存在', () => {
|
||||
expect(fs.existsSync(rechargePath)).toBe(true);
|
||||
const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, 'utf-8'));
|
||||
const hasPage = pagesJson.pages.some((p: any) => p.path === 'pages/user/recharge');
|
||||
expect(hasPage).toBe(true);
|
||||
});
|
||||
|
||||
it('充值页面必须包含沉浸式导航栏、当前余额看板、档位网格、自定义金额、微信支付通道与提交栏', () => {
|
||||
const content = fs.readFileSync(rechargePath, 'utf-8');
|
||||
expect(content).toContain('custom-nav-bar');
|
||||
expect(content).toContain('钱包充值');
|
||||
expect(content).toContain('handleBack');
|
||||
expect(content).toContain('balance-card');
|
||||
expect(content).toContain('tier-grid');
|
||||
expect(content).toContain('custom-amount');
|
||||
expect(content).toContain('channel-card');
|
||||
expect(content).toContain('微信支付');
|
||||
expect(content).toContain('fixed-submit-bar');
|
||||
expect(content).toContain('rechargeBalance');
|
||||
});
|
||||
|
||||
it('个人中心页面 (pages/user/index.vue) 充值入口必须原生跳转至 /pages/user/recharge 且杜绝弹窗', () => {
|
||||
const content = fs.readFileSync(userIndexPath, 'utf-8');
|
||||
expect(content).toContain("url: '/pages/user/recharge'");
|
||||
const rechargeFuncMatch = content.match(/const handleRecharge\s*=\s*(?:async\s*)?\(\)\s*=>\s*\{[\s\S]*?\n\};/);
|
||||
expect(rechargeFuncMatch).not.toBeNull();
|
||||
expect(rechargeFuncMatch![0]).toContain("url: '/pages/user/recharge'");
|
||||
expect(rechargeFuncMatch![0]).not.toContain('showActionSheet');
|
||||
});
|
||||
|
||||
it('充值页面与测试代码严禁包含任何系统 Emoji 与 showModal 4 字门禁违规', () => {
|
||||
const rechargeContent = fs.readFileSync(rechargePath, 'utf-8');
|
||||
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
|
||||
expect(emojiRegex.test(rechargeContent)).toBe(false);
|
||||
|
||||
const modalMatches = rechargeContent.match(/(?:confirmText|cancelText)\s*:\s*['"]([^'"]+)['"]/g) || [];
|
||||
for (const m of modalMatches) {
|
||||
const text = m.split(':')[1].replace(/['"\s]/g, '');
|
||||
expect(text.length).toBeLessThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('25. 接单大厅大神本人信息与状态动态联动验证 (pages/expert/hall.vue)', () => {
|
||||
const hallPath = path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'hall.vue');
|
||||
|
||||
it('hall.vue 必须物理存在且支持从 Storage 动态加载当前登录大神的真实信息', () => {
|
||||
expect(fs.existsSync(hallPath)).toBe(true);
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain("uni.getStorageSync('userId')");
|
||||
expect(content).toContain("getUserProfile");
|
||||
expect(content).toContain("getExpertStats");
|
||||
});
|
||||
|
||||
it('hall.vue 必须在 onShow 中刷新当前登录大神的资料与接单在线状态', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain("EXPERT_RECEIVING_STATUS");
|
||||
expect(content).toContain("onShow");
|
||||
expect(content).toContain("loadHallData");
|
||||
});
|
||||
|
||||
it('立即接单 handleGrabOrder 必须动态绑定当前大神的真实 ID、昵称与头像', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain("expert.value.id");
|
||||
expect(content).toContain("expert.value.nickname");
|
||||
expect(content).toContain("expert.value.avatar");
|
||||
expect(content).not.toContain("companionName: '我 (接单大神)'");
|
||||
});
|
||||
|
||||
it('hall.vue 页面代码与测试严禁包含任何系统 Emoji 与 showModal 4 字门禁违规', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
|
||||
expect(emojiRegex.test(content)).toBe(false);
|
||||
|
||||
const modalMatches = content.match(/(?:confirmText|cancelText)\s*:\s*['"]([^'"]+)['"]/g) || [];
|
||||
for (const m of modalMatches) {
|
||||
const text = m.split(':')[1].replace(/['"\s]/g, '');
|
||||
expect(text.length).toBeLessThanOrEqual(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('派单卡片必须支持点击查看详情面板,且详情面板严禁使用 @click.self 必须 stopPropagation', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain('openOrderDetailModal');
|
||||
expect(content).toContain('showDetailModal');
|
||||
expect(content).toContain('dispatch-detail-mask');
|
||||
expect(content).toContain('dispatch-detail-modal');
|
||||
expect(content).toContain('closeOrderDetailModal');
|
||||
expect(content).toContain('stopPropagation');
|
||||
expect(content).not.toContain('@click.self');
|
||||
});
|
||||
|
||||
it('派单详情弹窗酬金展示必须使用徽章卡片结构并实现货币符号与数字基线横排对齐', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain('reward-badge-card');
|
||||
expect(content).toContain('badge-label');
|
||||
expect(content).toContain('amount-row');
|
||||
expect(content).toContain('curr-mark');
|
||||
expect(content).toContain('reward-num');
|
||||
});
|
||||
|
||||
it('派单详情弹窗单号必须支持脱敏简化展示并支持点击一键复制完整流水号', () => {
|
||||
const content = fs.readFileSync(hallPath, 'utf-8');
|
||||
expect(content).toContain('formatShortOrderNo');
|
||||
expect(content).toContain('copyOrderNo');
|
||||
expect(content).toContain('uni.setClipboardData');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第九批回归闸门:后台经营看板真实数据化
|
||||
* 禁止服务端返回硬编码假经营数据,前后端均不得再以假走势兜底。
|
||||
*/
|
||||
describe('第九批看板真实数据闸门 (禁止硬编码经营指标)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8');
|
||||
const chartCode = fs.readFileSync(
|
||||
path.join(rootDir, 'admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const statsStart = repoCode.indexOf('async getDashboardStats(');
|
||||
const statsBlock = repoCode.slice(statsStart, statsStart + 3600);
|
||||
|
||||
describe('1. 服务端 getDashboardStats 必须来自真实聚合', () => {
|
||||
it('不得再返回硬编码假指标', () => {
|
||||
expect(statsStart).toBeGreaterThan(-1);
|
||||
expect(statsBlock).not.toContain('todayGmv: 58920');
|
||||
expect(statsBlock).not.toContain('activeUsers: 1250');
|
||||
expect(statsBlock).not.toContain("date: '09-22'");
|
||||
expect(statsBlock).not.toContain('{ name: \'同城陪吃\', value: 42 }');
|
||||
});
|
||||
|
||||
it('必须基于订单表实时聚合今日 GMV / 走势 / 品类', () => {
|
||||
expect(statsBlock).toContain('DATE(created_at) = CURDATE()');
|
||||
expect(statsBlock).toContain('GROUP BY track');
|
||||
expect(statsBlock).toContain('DATE_SUB(CURDATE(), INTERVAL 6 DAY)');
|
||||
expect(statsBlock).toContain('recentGmvTrend');
|
||||
});
|
||||
|
||||
it('接单中大神数量来自真实计数', () => {
|
||||
expect(statsBlock).toContain('FROM companions WHERE status = "接单中"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 前端走势图不得以假数据兜底', () => {
|
||||
it('移除硬编码走势并暴露失败', () => {
|
||||
expect(chartCode).toContain('const loadFailed = ref(false)');
|
||||
expect(chartCode).not.toContain('09-22');
|
||||
expect(chartCode).not.toContain('58920');
|
||||
expect(chartCode).not.toContain('// fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第六批回归闸门:评价下架鉴权 / 大神资料越权 / 剩余写接口身份绑定
|
||||
*/
|
||||
describe('第六批安全加固闸门 (评价下架鉴权 / 大神资料越权 / 身份绑定)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const apiPath = path.join(rootDir, 'pwuniapp/common/api.js');
|
||||
|
||||
const indexCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
const apiCode = fs.readFileSync(apiPath, 'utf-8');
|
||||
|
||||
describe('1. 评价违规下架仅管理员可执行', () => {
|
||||
it('DELETE /api/reviews/:id 必须挂载 requireAdmin', () => {
|
||||
expect(indexCode).toContain("app.delete('/api/reviews/:id', requireAdmin");
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 大神资料/接单状态越权修改', () => {
|
||||
it('仓储层必须提供 getCompanionOwnership 解析归属', () => {
|
||||
expect(repoCode).toContain('async getCompanionOwnership(');
|
||||
const start = repoCode.indexOf('async getCompanionOwnership(');
|
||||
const block = repoCode.slice(start, start + 600);
|
||||
expect(block).toContain('ownerUserId');
|
||||
expect(block).toContain('comp.user_id');
|
||||
});
|
||||
|
||||
it('服务端必须提供 assertCompanionActor 且生产环境要求已绑定 user_id', () => {
|
||||
const start = indexCode.indexOf('function assertCompanionActor(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 1000);
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain('IS_PRODUCTION');
|
||||
expect(block).toContain('ownerUserId');
|
||||
expect(block).toContain('无权');
|
||||
});
|
||||
|
||||
it('两个大神资料更新路由都必须先做归属校验', () => {
|
||||
for (const marker of ["app.put('/api/companions/:id',", "app.put('/api/companions/:id/profile',"]) {
|
||||
const start = indexCode.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 600);
|
||||
expect(block).toContain('repo.getCompanionOwnership(companionId)');
|
||||
expect(block).toContain("assertCompanionActor(req, own, '修改大神资料')");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 剩余写接口身份以服务端会话为准', () => {
|
||||
it('点赞/评论/举报/收藏/分销/入驻均必须经 resolveSecureUserId 绑定身份', () => {
|
||||
const expected = [
|
||||
"'点赞动态'",
|
||||
"'发表评论'",
|
||||
"'提交举报'",
|
||||
"'收藏大神'",
|
||||
"'取消收藏'",
|
||||
"'绑定邀请码'",
|
||||
"'申请大神入驻'",
|
||||
];
|
||||
for (const tag of expected) {
|
||||
expect(indexCode).toContain(`resolveSecureUserId(req,`);
|
||||
expect(indexCode).toContain(tag);
|
||||
}
|
||||
// 旧的可伪造缺省默认值必须被移除
|
||||
expect(indexCode).not.toContain('addFavorite(Number(userId || 1001)');
|
||||
expect(indexCode).not.toContain('bindInviteCode(Number(userId || 1001)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 小程序端写接口携带真实身份', () => {
|
||||
it('评论/举报/入驻/大神资料/上车处置均注入当前用户身份', () => {
|
||||
expect(apiCode).toContain('userId: normalizeUserId(commentData.userId)');
|
||||
expect(apiCode).toContain('reporterId: normalizeUserId(payload.reporterId || payload.userId)');
|
||||
expect(apiCode).toContain('userId: normalizeUserId(data && data.userId)');
|
||||
expect(apiCode).toContain('userId: normalizeUserId(profileData.userId)');
|
||||
expect(apiCode).toContain('{ status, userId: normalizeUserId() }');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { signUserToken, verifyUserToken } from '../src/core/security/user-token';
|
||||
|
||||
/**
|
||||
* 第四批高危项回归闸门:客户端伪造身份的资金写接口收口 / 订单支付状态机收口 / 静默吞错可观测化
|
||||
* 以「源码契约断言 + 行为断言」锁定边界,防止后续重构悄然后退。
|
||||
*/
|
||||
describe('第四批安全加固闸门 (用户会话身份 / 订单支付 / 上车申请越权 / 静默吞错)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const userTokenPath = path.join(rootDir, 'server/src/core/security/user-token.ts');
|
||||
const requestPath = path.join(rootDir, 'pwuniapp/common/request.js');
|
||||
const loginPath = path.join(rootDir, 'pwuniapp/pages/auth/login.vue');
|
||||
|
||||
const indexCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
const tokenCode = fs.readFileSync(userTokenPath, 'utf-8');
|
||||
|
||||
describe('1. 用户会话 token 基础设施 (HMAC 签名,防止伪造他人 userId)', () => {
|
||||
it('必须提供签名/验签与安全取用用户身份的能力', () => {
|
||||
expect(indexCode).toContain('const USER_TOKEN_SECRET');
|
||||
expect(indexCode).toContain('function signUserToken(');
|
||||
expect(indexCode).toContain('function verifyUserToken(');
|
||||
expect(indexCode).toContain('function resolveSecureUserId(');
|
||||
expect(indexCode).toContain('function resolveRequestUserId(');
|
||||
});
|
||||
|
||||
it('token 验签必须使用恒定时间比较且校验有效期', () => {
|
||||
expect(tokenCode).toContain('crypto.timingSafeEqual');
|
||||
expect(tokenCode).toContain('ttlMs');
|
||||
expect(indexCode).toContain('USER_TOKEN_TTL_MS');
|
||||
});
|
||||
|
||||
it('签名 token 可被正确验签并解析出用户ID', () => {
|
||||
const SECRET = 'unit-test-secret';
|
||||
const TTL = 7 * 24 * 3600 * 1000;
|
||||
const token = signUserToken(8888, SECRET, TTL);
|
||||
expect(verifyUserToken(token, SECRET, TTL)).toBe(8888);
|
||||
});
|
||||
|
||||
it('篡改 payload/签名或使用错误密钥的 token 必须验签失败', () => {
|
||||
const SECRET = 'unit-test-secret';
|
||||
const TTL = 7 * 24 * 3600 * 1000;
|
||||
const token = signUserToken(1001, SECRET, TTL);
|
||||
const [payload, sig] = token.split('.');
|
||||
|
||||
const forgedPayload = Buffer.from(JSON.stringify({ uid: 1, ts: Date.now() })).toString('base64url');
|
||||
expect(verifyUserToken(`${forgedPayload}.${sig}`, SECRET, TTL)).toBeNull();
|
||||
expect(verifyUserToken(`${payload}.deadbeef`, SECRET, TTL)).toBeNull();
|
||||
expect(verifyUserToken(token, 'wrong-secret', TTL)).toBeNull();
|
||||
expect(verifyUserToken('not-a-token', SECRET, TTL)).toBeNull();
|
||||
});
|
||||
|
||||
it('过期 token 必须验签失败', () => {
|
||||
const SECRET = 'unit-test-secret';
|
||||
const TTL = 1000;
|
||||
const issuedAt = Date.now() - 10_000;
|
||||
const token = signUserToken(2002, SECRET, TTL, issuedAt);
|
||||
expect(verifyUserToken(token, SECRET, TTL, issuedAt + 500)).toBe(2002);
|
||||
expect(verifyUserToken(token, SECRET, TTL, issuedAt + 10_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('生产环境缺失有效 token 时必须拒绝敏感操作 (fail closed)', () => {
|
||||
const start = indexCode.indexOf('function resolveSecureUserId(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 900);
|
||||
expect(block).toContain('IS_PRODUCTION');
|
||||
expect(block).toContain('401');
|
||||
expect(block).toContain('无权以他人身份');
|
||||
});
|
||||
|
||||
it('微信手机号登录必须签发真实签名 token,杜绝假 token', () => {
|
||||
expect(indexCode).toContain('token: signUserToken(user.id)');
|
||||
expect(indexCode).toContain('userToken: signUserToken(user.id)');
|
||||
expect(indexCode).not.toContain('token: `token_${user.id}_${Date.now()}`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 订单支付状态机收口', () => {
|
||||
it('非 LIVE 沙箱入账必须受 ALLOW_SANDBOX_PAY 生产闸门约束', () => {
|
||||
const start = indexCode.indexOf("app.post('/api/orders/:id/pay'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 1400);
|
||||
expect(block).toContain('if (!ALLOW_SANDBOX_PAY)');
|
||||
expect(block).toContain('禁止直接变更订单支付状态');
|
||||
});
|
||||
|
||||
it('沙箱仅允许推进到合法已支付状态,严禁客户端任意指定终态', () => {
|
||||
const start = indexCode.indexOf("app.post('/api/orders/:id/pay'");
|
||||
const block = indexCode.slice(start, start + 1400);
|
||||
expect(block).toContain("const allowedSandboxStatuses = ['履约中', '待接单']");
|
||||
expect(block).toContain('allowedSandboxStatuses.includes(requested)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 提现接口以服务端会话身份为准', () => {
|
||||
it('提现用户身份必须经 resolveSecureUserId 校验,禁止直接信任路径参数', () => {
|
||||
const start = indexCode.indexOf("app.post('/api/users/:id/withdraw'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 900);
|
||||
expect(block).toContain("resolveSecureUserId(req, pathUserId, '申请提现')");
|
||||
expect(block).not.toContain('const userId = Number(req.params.id);');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 上车申请仅动态发布者本人可处置', () => {
|
||||
it('处置路由必须校验申请归属并区分管理员', () => {
|
||||
const start = indexCode.indexOf("app.post('/api/post-applies/:id/handle'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 1500);
|
||||
expect(block).toContain('repo.getPostApplyById(applyId)');
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain('仅动态发布者本人可处置该上车申请');
|
||||
expect(block).toContain('SELECT user_id FROM posts WHERE id = ?');
|
||||
});
|
||||
|
||||
it('handlePostApply 必须仅允许待处理申请被处置一次', () => {
|
||||
const start = repoCode.indexOf('async handlePostApply(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = repoCode.slice(start, start + 900);
|
||||
expect(block).toContain("WHERE id = ? AND status = 'PENDING'");
|
||||
expect(block).toContain('已处置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 资金/结算静默吞错可观测化', () => {
|
||||
it('服务端必须提供统一错误日志并用于支付与结算关键路径', () => {
|
||||
expect(indexCode).toContain('function logServerError(');
|
||||
expect(indexCode).toContain("logServerError('confirmWechatPayment.query'");
|
||||
expect(indexCode).toContain("logServerError('resolveWechatPayRuntimeConfig'");
|
||||
expect(repoCode).toContain('function logServerError(');
|
||||
expect(repoCode).toContain("logServerError('handleArbitration.settle'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 小程序端会话 token 存储与透传', () => {
|
||||
it('统一请求层必须携带 Authorization Bearer 用户会话 token', () => {
|
||||
const code = fs.readFileSync(requestPath, 'utf-8');
|
||||
expect(code).toContain("uni.getStorageSync('userToken')");
|
||||
expect(code).toContain('Authorization: `Bearer ${sessionToken}`');
|
||||
});
|
||||
|
||||
it('微信手机号登录成功后必须落地 userToken', () => {
|
||||
const code = fs.readFileSync(loginPath, 'utf-8');
|
||||
expect(code).toContain("res.userToken || res.token");
|
||||
expect(code).toContain("uni.setStorageSync('userToken', sessionToken)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第二批高危项回归闸门:报名超卖 / 抢单竞态 / 评价绕过结算
|
||||
* 以「源码契约断言」方式锁定关键并发与校验模式,防止后续重构悄悄回退。
|
||||
*/
|
||||
describe('订单与活动资金一致性护栏 (报名超卖 / 抢单 CAS / 评价结算绕过)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverMysqlPath = path.join(rootDir, 'server/src/db/mysql.ts');
|
||||
const pwuniappOrderListPath = path.join(rootDir, 'pwuniapp/pages/order/list.vue');
|
||||
|
||||
describe('1. 活动报名必须使用事务 + 行锁,杜绝超卖', () => {
|
||||
it('joinActivity 必须在事务内对活动行加锁,并以当前读统计名额', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('async joinActivity(');
|
||||
expect(code).toContain('FROM activities WHERE id = ? FOR UPDATE');
|
||||
expect(code).toContain('SELECT id, user_id, verify_code FROM participants WHERE activity_id = ? FOR UPDATE');
|
||||
expect(code).toContain('活动报名人数已满');
|
||||
});
|
||||
|
||||
it('joinActivity 必须防重复报名并生成活动内唯一核销码', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('用户已报名该活动');
|
||||
expect(code).toContain('const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code)));');
|
||||
});
|
||||
|
||||
it('数据库层必须为 participants 增加 (activity_id,user_id) 与 (activity_id,verify_code) 唯一约束', () => {
|
||||
const mysql = fs.readFileSync(serverMysqlPath, 'utf-8');
|
||||
expect(mysql).toContain('UNIQUE KEY uq_activity_user (activity_id, user_id)');
|
||||
expect(mysql).toContain('UNIQUE KEY uq_activity_verify (activity_id, verify_code)');
|
||||
expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_user (activity_id, user_id)');
|
||||
expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_verify (activity_id, verify_code)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 抢单必须以 claimed_by 原子 CAS 保证唯一赢家', () => {
|
||||
it('claimOrder 必须使用 claimed_by 条件更新并校验受影响行数', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('async claimOrder(');
|
||||
expect(code).toContain('claimed_by = ?, claimed_at = NOW()');
|
||||
expect(code).toContain('AND (claimed_by IS NULL OR claimed_by = ?)');
|
||||
expect(code).toContain('res.affectedRows !== 1');
|
||||
expect(code).toContain('该订单已被其他大神抢先接单');
|
||||
});
|
||||
|
||||
it('claimOrder 必须拦截不可接单状态', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain("const blocked = ['待付款', '待支付', '已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中']");
|
||||
});
|
||||
|
||||
it('数据库层必须为 orders 增加 claimed_by / claimed_at 认领标记列', () => {
|
||||
const mysql = fs.readFileSync(serverMysqlPath, 'utf-8');
|
||||
expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id');
|
||||
expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 完单结算必须原子且幂等,杜绝重复结算', () => {
|
||||
it('completeOrderWithSettlement 必须在事务内锁定订单并再次校验幂等键', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('async completeOrderWithSettlement(');
|
||||
expect(code).toContain('FROM orders WHERE id = ? OR order_no = ? FOR UPDATE');
|
||||
expect(code).toContain("related_id = ? AND type = '大神完单收益结算'");
|
||||
expect(code).toContain('await conn.commit();');
|
||||
expect(code).toContain('await conn.rollback();');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 订单评价必须服务端校验身份/状态/防重,杜绝绕过结算', () => {
|
||||
it('createOrderReview 必须校验买家身份、可评价状态与防重复评价', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('async createOrderReview(');
|
||||
expect(code).toContain('仅下单买家本人可以评价该订单');
|
||||
expect(code).toContain('不可评价');
|
||||
expect(code).toContain('该订单已评价,请勿重复提交');
|
||||
expect(code).toContain("const reviewable = ['履约中', '服务中', '已到场核销', '待确认', '已完成']");
|
||||
});
|
||||
|
||||
it('同一订单仅允许一条评价的数据库唯一约束必须存在', () => {
|
||||
const mysql = fs.readFileSync(serverMysqlPath, 'utf-8');
|
||||
expect(mysql).toContain('UNIQUE KEY uq_order_review (order_id)');
|
||||
expect(mysql).toContain('ALTER TABLE order_reviews ADD UNIQUE KEY uq_order_review (order_id)');
|
||||
});
|
||||
|
||||
it('小程序评价必须提交真实登录用户ID,严禁硬编码 1001 绕过买家校验', () => {
|
||||
const vue = fs.readFileSync(pwuniappOrderListPath, 'utf-8');
|
||||
expect(vue).toContain("userId: Number(uni.getStorageSync('userId') || 1001),");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第五批高危项回归闸门:订单生命周期越权 / 结算状态闸门 / 组局核销改价身份
|
||||
* 以「源码契约断言」锁定边界,防止后续重构悄然后退。
|
||||
*/
|
||||
describe('第五批安全加固闸门 (订单越权结算 / 订单身份收口 / 组局核销改价)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const apiPath = path.join(rootDir, 'pwuniapp/common/api.js');
|
||||
|
||||
const indexCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
const apiCode = fs.readFileSync(apiPath, 'utf-8');
|
||||
|
||||
const routeBlock = (marker: string, len = 1600) => {
|
||||
const start = indexCode.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return indexCode.slice(start, start + len);
|
||||
};
|
||||
|
||||
describe('1. 结算状态闸门 (防前端直接结算套现)', () => {
|
||||
it('completeOrderWithSettlement 必须拒绝未支付/未接单/已退款等状态结算', () => {
|
||||
const start = repoCode.indexOf('async completeOrderWithSettlement(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = repoCode.slice(start, start + 1600);
|
||||
expect(block).toContain("const settleBlocked = ['待支付', '待接单', '已取消', '已退款', '已全额退款']");
|
||||
expect(block).toContain('settleBlocked.includes(String(order.status))');
|
||||
expect(block).toContain('不可结算');
|
||||
expect(block).toContain('e.status = 409');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 订单归属解析与操作者角色', () => {
|
||||
it('必须提供 getOrderOwnership 并采用大神身份并集策略', () => {
|
||||
expect(repoCode).toContain('async getOrderOwnership(');
|
||||
const start = repoCode.indexOf('async getOrderOwnership(');
|
||||
const block = repoCode.slice(start, start + 1200);
|
||||
expect(block).toContain('LEFT JOIN companions c ON c.id = o.expert_id');
|
||||
expect(block).toContain('companionIds');
|
||||
expect(block).toContain('claimedBy');
|
||||
});
|
||||
|
||||
it('resolveOrderActor 必须区分管理员/买家/承接大神', () => {
|
||||
const start = indexCode.indexOf('function resolveOrderActor(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 900);
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain('isBuyer');
|
||||
expect(block).toContain('companionIds.includes(callerId)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 订单生命周期写接口身份收口', () => {
|
||||
it('确认完成订单仅交易双方或管理员可操作', () => {
|
||||
const block = routeBlock("app.post('/api/orders/:id/complete'");
|
||||
expect(block).toContain("resolveOrderActor(req, own, '确认完成订单')");
|
||||
expect(block).toContain('仅交易双方或平台管理员可完成该订单');
|
||||
});
|
||||
|
||||
it('取消订单仅买家本人或管理员可操作', () => {
|
||||
const block = routeBlock("app.post('/api/orders/:id/cancel'");
|
||||
expect(block).toContain("resolveOrderActor(req, own, '取消订单')");
|
||||
expect(block).toContain('仅买家本人或平台管理员可取消该订单');
|
||||
});
|
||||
|
||||
it('拒单仅承接大神本人或管理员可操作', () => {
|
||||
const block = routeBlock("app.post('/api/orders/:id/reject'");
|
||||
expect(block).toContain("resolveOrderActor(req, own, '拒单')");
|
||||
expect(block).toContain('仅承接大神本人或平台管理员可拒单');
|
||||
});
|
||||
|
||||
it('申请完成服务仅承接大神本人或管理员可操作', () => {
|
||||
const block = routeBlock("app.post('/api/orders/:id/apply-complete'");
|
||||
expect(block).toContain("resolveOrderActor(req, own, '申请完成服务')");
|
||||
expect(block).toContain('仅承接大神本人或平台管理员可申请完成服务');
|
||||
});
|
||||
|
||||
it('退款申诉仅买家本人或管理员可发起', () => {
|
||||
const block = routeBlock("app.post('/api/orders/:id/refund'");
|
||||
expect(block).toContain("resolveOrderActor(req, own, '发起退款申诉')");
|
||||
expect(block).toContain('仅买家本人或平台管理员可发起退款申诉');
|
||||
});
|
||||
|
||||
it('创建订单必须绑定服务端解析的买家身份', () => {
|
||||
const block = routeBlock("app.post('/api/orders',");
|
||||
expect(block).toContain("'创建订单'");
|
||||
expect(block).toContain('buyerId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 组局核销/改价身份以服务端会话为准', () => {
|
||||
it('核销、改价、通用核销必须经 resolveSecureUserId 解析操作人', () => {
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票')");
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '调整组局出价')");
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '核销门票')");
|
||||
expect(indexCode).not.toContain('Number(operatorId || 1001)');
|
||||
});
|
||||
|
||||
it('报名/退席必须绑定服务端解析的用户身份', () => {
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 2008), '报名参加活动')");
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(userId), '退出组局')");
|
||||
});
|
||||
|
||||
it('修改个人资料必须绑定服务端解析的用户身份', () => {
|
||||
expect(indexCode).toContain("resolveSecureUserId(req, Number(req.params.id), '修改个人资料')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 小程序端写接口携带真实身份', () => {
|
||||
it('订单生命周期接口必须携带 normalizeUserId 解析出的 userId', () => {
|
||||
const block = (marker: string, len = 420) => {
|
||||
const start = apiCode.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return apiCode.slice(start, start + len);
|
||||
};
|
||||
expect(block('export async function cancelOrder(')).toContain('{ userId: normalizeUserId() }');
|
||||
expect(block('export async function completeOrder(')).toContain('{ userId: normalizeUserId() }');
|
||||
expect(block('export async function rejectOrder(')).toContain('userId: normalizeUserId(payload.userId)');
|
||||
expect(block('export async function applyCompleteOrder(')).toContain('userId: normalizeUserId(payload.userId)');
|
||||
expect(block('export async function refundOrder(')).toContain('userId: normalizeUserId(data.userId)');
|
||||
});
|
||||
|
||||
it('下单必须携带买家身份,核销/改价必须携带操作人身份', () => {
|
||||
expect(apiCode).toContain('buyerId: uid');
|
||||
expect(apiCode).toContain('operatorId: normalizeUserId(operatorId)');
|
||||
expect(apiCode).not.toContain('operatorId = 1001');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第七批回归闸门:评价回复归属 / 服务上下架归属 / 通用核销归属
|
||||
* 以「源码契约断言」锁定安全边界。
|
||||
*/
|
||||
describe('第七批安全加固闸门 (评价回复 / 服务上下架 / 通用核销归属)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const apiPath = path.join(rootDir, 'pwuniapp/common/api.js');
|
||||
|
||||
const indexCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
const apiCode = fs.readFileSync(apiPath, 'utf-8');
|
||||
|
||||
const routeBlock = (marker: string, len = 1400) => {
|
||||
const start = indexCode.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return indexCode.slice(start, start + len);
|
||||
};
|
||||
|
||||
describe('1. 评价回复越权', () => {
|
||||
it('仓储层必须提供 getReviewOwnership (基于 order_reviews.expert_id)', () => {
|
||||
expect(repoCode).toContain('async getReviewOwnership(');
|
||||
const start = repoCode.indexOf('async getReviewOwnership(');
|
||||
const block = repoCode.slice(start, start + 400);
|
||||
expect(block).toContain('SELECT expert_id FROM order_reviews');
|
||||
expect(block).toContain('getCompanionOwnership');
|
||||
});
|
||||
|
||||
it('回复路由必须校验归属', () => {
|
||||
const block = routeBlock("app.post('/api/reviews/:id/reply'");
|
||||
expect(block).toContain('repo.getReviewOwnership(reviewId)');
|
||||
expect(block).toContain("assertCompanionActor(req, own, '回复评价')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 服务上下架越权', () => {
|
||||
it('仓储层必须提供 getExpertServiceOwnership (基于 expert_services.expert_id)', () => {
|
||||
expect(repoCode).toContain('async getExpertServiceOwnership(');
|
||||
const start = repoCode.indexOf('async getExpertServiceOwnership(');
|
||||
const block = repoCode.slice(start, start + 800);
|
||||
expect(block).toContain('SELECT expert_id FROM expert_services');
|
||||
expect(block).toContain('getCompanionOwnership');
|
||||
});
|
||||
|
||||
it('上下架路由必须校验归属', () => {
|
||||
const block = routeBlock("app.put('/api/companions/:id/services/:serviceId/status'");
|
||||
expect(block).toContain('repo.getExpertServiceOwnership(serviceId)');
|
||||
expect(block).toContain("assertCompanionActor(req, own, '调整服务上下架')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 通用核销归属 (生产闸门)', () => {
|
||||
it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => {
|
||||
const block = routeBlock("app.post('/api/verify/universal'");
|
||||
expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain("info.type === 'ACTIVITY'");
|
||||
expect(block).toContain('Number(info.creatorId) === operator');
|
||||
expect(block).toContain('own!.companionIds.includes(operator)');
|
||||
expect(block).toContain('无权核销该门票');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 小程序端写接口携带真实身份', () => {
|
||||
it('回复评价/切换服务状态必须携带当前用户身份', () => {
|
||||
expect(apiCode).toContain('{ replyContent, userId: normalizeUserId() }');
|
||||
expect(apiCode).toContain('{ status, userId: normalizeUserId() }');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -708,5 +708,18 @@ describe('RBAC Permission Architecture (TDD 全流程多角色权限与越权防
|
||||
expect(content).toContain('/pages/user/index');
|
||||
}
|
||||
});
|
||||
|
||||
it('大神端冷启动或刷新进入小程序首页时,首页与 App.vue 必须自动路由至大神接单大厅', () => {
|
||||
const indexPath = path.resolve(__dirname, '../../pwuniapp/pages/index/index.vue');
|
||||
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
||||
expect(indexContent).toContain('checkExpertRedirect');
|
||||
expect(indexContent).toContain('/pages/expert/hall');
|
||||
expect(indexContent).toContain('ROLE_TYPES.EXPERT');
|
||||
|
||||
const appPath = path.resolve(__dirname, '../../pwuniapp/App.vue');
|
||||
const appContent = fs.readFileSync(appPath, 'utf-8');
|
||||
expect(appContent).toContain('/pages/expert/hall');
|
||||
expect(appContent).toContain('ROLE_TYPES.EXPERT');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ContentSecurityService } from '../src/core/security/content-security.service';
|
||||
|
||||
/**
|
||||
* 第三批高危项回归闸门:友圈动态越权删除 / 组局解散提权 / 微信内容安全真实对接
|
||||
* 以「源码契约断言 + 服务行为断言」锁定安全边界,防止后续重构悄然后退。
|
||||
*/
|
||||
describe('第三批安全加固闸门 (动态越权删除 / 组局解散提权 / 真实微信内容安全)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const secServicePath = path.join(rootDir, 'server/src/core/security/content-security.service.ts');
|
||||
const pwuniappApiPath = path.join(rootDir, 'pwuniapp/common/api.js');
|
||||
|
||||
describe('1. 友圈动态仅作者本人可删除', () => {
|
||||
it('deletePost 必须校验作者归属并拒绝越权删除', () => {
|
||||
const code = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||
expect(code).toContain('async deletePost(');
|
||||
expect(code).toContain('无权删除他人发布的动态');
|
||||
expect(code).toContain('SELECT id, user_id FROM posts WHERE id = ? LIMIT 1');
|
||||
expect(code).toContain('DELETE FROM posts WHERE id = ? AND user_id = ?');
|
||||
});
|
||||
|
||||
it('删除路由必须要求显式身份,不得默认 1001', () => {
|
||||
const code = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const start = code.indexOf("app.delete('/api/posts/:id'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = code.slice(start, start + 700);
|
||||
expect(block).toContain("req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0");
|
||||
expect(block).toContain('await assertUserNotBanned(uid');
|
||||
expect(block).not.toContain('|| 1001)');
|
||||
});
|
||||
|
||||
it('小程序删除动态必须携带真实登录用户ID', () => {
|
||||
const code = fs.readFileSync(pwuniappApiPath, 'utf-8');
|
||||
const start = code.indexOf('export async function deletePost(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = code.slice(start, start + 400);
|
||||
expect(block).toContain("uni.getStorageSync('userId') || 1001");
|
||||
expect(block).not.toContain('userId = 1001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 组局解散的管理员特权必须来自服务端签名 token', () => {
|
||||
it('cancel 路由严禁信任客户端 operatorRole / x-user-role 提权', () => {
|
||||
const code = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(code).toContain('function isVerifiedAdminRequest(');
|
||||
const start = code.indexOf("app.post('/api/activities/:id/cancel'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = code.slice(start, start + 900);
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).not.toContain("operatorRole === 'admin'");
|
||||
expect(block).not.toContain("req.headers['x-user-role']");
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 内容安全真实对接微信 msgSecCheck', () => {
|
||||
it('服务必须提供可注入的远端真实审核器与异步检测方法', () => {
|
||||
const code = fs.readFileSync(secServicePath, 'utf-8');
|
||||
expect(code).toContain('setRemoteChecker');
|
||||
expect(code).toContain('checkTextAsync');
|
||||
expect(code).toContain('RemoteTextChecker');
|
||||
});
|
||||
|
||||
it('index.ts 必须注册微信 msg_sec_check 真实调用并接入关键 UGC 路由', () => {
|
||||
const code = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(code).toContain('contentSecurityService.setRemoteChecker');
|
||||
expect(code).toContain('https://api.weixin.qq.com/wxa/msg_sec_check');
|
||||
expect(code).toContain('version: 2');
|
||||
expect(code).toContain('await contentSecurityService.checkTextAsync(');
|
||||
});
|
||||
|
||||
it('checkTextAsync 在无远端审核器时降级本地词库:命中拦截、合规放行', async () => {
|
||||
const svc = new ContentSecurityService();
|
||||
const hit = await svc.checkTextAsync('同城美女上门特殊服务');
|
||||
expect(hit.pass).toBe(false);
|
||||
expect(hit.code).toBe(87014);
|
||||
expect(hit.suggest).toBe('block');
|
||||
const ok = await svc.checkTextAsync('今晚一起打王者荣耀五排,辅助位缺一');
|
||||
expect(ok.pass).toBe(true);
|
||||
expect(ok.code).toBe(0);
|
||||
});
|
||||
|
||||
it('注入的远端审核器会被调用,risky 内容应被二次拦截', async () => {
|
||||
const svc = new ContentSecurityService();
|
||||
let called = 0;
|
||||
svc.setRemoteChecker(async (text) => {
|
||||
called += 1;
|
||||
if (text.includes('远端拦截')) {
|
||||
return { pass: false, code: 87014, label: 'WECHAT_RISKY', suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
expect(svc.hasRemoteChecker()).toBe(true);
|
||||
|
||||
const risky = await svc.checkTextAsync('这条内容需要远端拦截处理');
|
||||
expect(called).toBe(1);
|
||||
expect(risky.pass).toBe(false);
|
||||
expect(risky.label).toBe('WECHAT_RISKY');
|
||||
|
||||
const normal = await svc.checkTextAsync('正常合规文本内容');
|
||||
expect(normal.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('远端审核器抛异常时必须 fail-open 降级本地,避免误杀正常内容', async () => {
|
||||
const svc = new ContentSecurityService();
|
||||
svc.setRemoteChecker(async () => {
|
||||
throw new Error('wechat api down');
|
||||
});
|
||||
const r = await svc.checkTextAsync('今晚一起打王者荣耀五排');
|
||||
expect(r.pass).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
server/tests/wechat-pay-v3.spec.ts
普通文件
212
server/tests/wechat-pay-v3.spec.ts
普通文件
@@ -0,0 +1,212 @@
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
buildAuthorization,
|
||||
buildJsapiPayParams,
|
||||
verifyNotifySignature,
|
||||
decryptNotifyResource,
|
||||
resolvePlatformKey,
|
||||
WechatPayConfig,
|
||||
} from '../src/core/pay/wechat-pay-v3';
|
||||
|
||||
/**
|
||||
* 微信支付 V3 加密管道行为测试
|
||||
* 通过内存生成 RSA 密钥对,离线验证:请求签名 / 回调验签 / APIv3 解密 / 小程序支付参数签名。
|
||||
* 这些是「真实收款对账」的安全基石,必须可被公钥验证且篡改必然失败。
|
||||
*/
|
||||
describe('WechatPayV3 (支付加密管道)', () => {
|
||||
let merchantPriv: string;
|
||||
let merchantPub: string;
|
||||
let platformPriv: string;
|
||||
let platformPub: string;
|
||||
let cfg: WechatPayConfig;
|
||||
|
||||
beforeAll(() => {
|
||||
const merchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
merchantPriv = merchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
merchantPub = merchant.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
const platform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
platformPriv = platform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
platformPub = platform.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
cfg = {
|
||||
appId: 'wx_test_app',
|
||||
mchId: '1600000000',
|
||||
apiV3Key: 'A'.repeat(32),
|
||||
serialNo: 'MERCHANTSERIAL001',
|
||||
privateKey: merchantPriv,
|
||||
};
|
||||
});
|
||||
|
||||
describe('1. 商户请求签名 (buildAuthorization)', () => {
|
||||
it('生成的 Authorization 头包含 mchid/序列号 且签名可被商户公钥验证', () => {
|
||||
const body = JSON.stringify({ appid: cfg.appId, out_trade_no: 'TEST_1' });
|
||||
const auth = buildAuthorization({
|
||||
method: 'POST',
|
||||
urlPath: '/v3/pay/transactions/jsapi',
|
||||
body,
|
||||
cfg,
|
||||
});
|
||||
const m = auth.match(
|
||||
/mchid="([^"]+)",nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)",serial_no="([^"]+)"/
|
||||
);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).toBe(cfg.mchId);
|
||||
expect(m![5]).toBe(cfg.serialNo);
|
||||
|
||||
const message = `POST\n/v3/pay/transactions/jsapi\n${m![4]}\n${m![2]}\n${body}\n`;
|
||||
const ok = crypto
|
||||
.createVerify('RSA-SHA256')
|
||||
.update(message)
|
||||
.verify(merchantPub, m![3], 'base64');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('报文体被篡改后验签必然失败', () => {
|
||||
const body = JSON.stringify({ out_trade_no: 'TEST_1' });
|
||||
const auth = buildAuthorization({
|
||||
method: 'POST',
|
||||
urlPath: '/v3/pay/transactions/jsapi',
|
||||
body,
|
||||
cfg,
|
||||
});
|
||||
const m = auth.match(/nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)"/)!;
|
||||
const tamperedMsg = `POST\n/v3/pay/transactions/jsapi\n${m[3]}\n${m[1]}\n${JSON.stringify({ out_trade_no: 'TEST_2' })}\n`;
|
||||
const ok = crypto
|
||||
.createVerify('RSA-SHA256')
|
||||
.update(tamperedMsg)
|
||||
.verify(merchantPub, m[2], 'base64');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 回调验签 (verifyNotifySignature)', () => {
|
||||
it('平台私钥签名 + 平台公钥验签 通过', () => {
|
||||
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonce = 'nonce123456';
|
||||
const body = JSON.stringify({ id: 'evt_1', event_type: 'TRANSACTION.SUCCESS' });
|
||||
const signature = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(`${timestamp}\n${nonce}\n${body}\n`)
|
||||
.sign(platformPriv, 'base64');
|
||||
|
||||
expect(
|
||||
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('报文被篡改后验签失败', () => {
|
||||
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonce = 'nonce123456';
|
||||
const body = JSON.stringify({ id: 'evt_1' });
|
||||
const signature = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(`${timestamp}\n${nonce}\n${body}\n`)
|
||||
.sign(platformPriv, 'base64');
|
||||
|
||||
expect(
|
||||
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body: body + 'x', signature, serial: 'P' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('时间戳超出 5 分钟容差时验签失败 (防重放)', () => {
|
||||
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
|
||||
const timestamp = String(Math.floor(Date.now() / 1000) - 1000);
|
||||
const nonce = 'nonce123456';
|
||||
const body = JSON.stringify({ id: 'evt_1' });
|
||||
const signature = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(`${timestamp}\n${nonce}\n${body}\n`)
|
||||
.sign(platformPriv, 'base64');
|
||||
|
||||
expect(
|
||||
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('未配置平台公钥时失败关闭 (fail-closed)', () => {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonce = 'nonce123456';
|
||||
const body = '{}';
|
||||
const signature = 'anything';
|
||||
expect(
|
||||
verifyNotifySignature({ cfg, timestamp, nonce, body, signature, serial: 'P' })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. APIv3 回调报文解密 (decryptNotifyResource)', () => {
|
||||
const apiV3Key = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
const nonce = 'randnonce12';
|
||||
const aad = 'transaction';
|
||||
|
||||
const encrypt = (plain: string) => {
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-gcm',
|
||||
Buffer.from(apiV3Key),
|
||||
Buffer.from(nonce)
|
||||
);
|
||||
cipher.setAAD(Buffer.from(aad));
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
return Buffer.concat([enc, cipher.getAuthTag()]).toString('base64');
|
||||
};
|
||||
|
||||
it('AES-256-GCM 解密还原原始业务报文', () => {
|
||||
const plain = JSON.stringify({
|
||||
out_trade_no: 'WX_RECHARGE_1',
|
||||
trade_state: 'SUCCESS',
|
||||
transaction_id: '42000',
|
||||
amount: { total: 1000 },
|
||||
});
|
||||
const decrypted = decryptNotifyResource(apiV3Key, {
|
||||
ciphertext: encrypt(plain),
|
||||
nonce,
|
||||
associated_data: aad,
|
||||
});
|
||||
expect(decrypted.out_trade_no).toBe('WX_RECHARGE_1');
|
||||
expect(decrypted.trade_state).toBe('SUCCESS');
|
||||
expect(decrypted.amount.total).toBe(1000);
|
||||
});
|
||||
|
||||
it('密钥错误时解密抛错,绝不返回伪造数据', () => {
|
||||
const plain = JSON.stringify({ out_trade_no: 'X' });
|
||||
const ciphertext = encrypt(plain);
|
||||
expect(() =>
|
||||
decryptNotifyResource('z'.repeat(32), { ciphertext, nonce, associated_data: aad })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('APIv3 密钥长度非 32 位时拒绝解密', () => {
|
||||
expect(() =>
|
||||
decryptNotifyResource('short', { ciphertext: 'AAAA', nonce, associated_data: aad })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 小程序支付参数签名 (buildJsapiPayParams)', () => {
|
||||
it('paySign 可被商户公钥验证', () => {
|
||||
const params = buildJsapiPayParams(cfg, 'wx_test_123');
|
||||
expect(params.signType).toBe('RSA');
|
||||
expect(params.package).toBe('prepay_id=wx_test_123');
|
||||
const message = `${cfg.appId}\n${params.timeStamp}\n${params.nonceStr}\n${params.package}\n`;
|
||||
const ok = crypto
|
||||
.createVerify('RSA-SHA256')
|
||||
.update(message)
|
||||
.verify(merchantPub, params.paySign, 'base64');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 平台公钥解析 (resolvePlatformKey)', () => {
|
||||
it('合法的公钥 PEM 可解析', () => {
|
||||
expect(resolvePlatformKey(platformPub)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('空值与非法内容返回 null', () => {
|
||||
expect(resolvePlatformKey('')).toBeNull();
|
||||
expect(resolvePlatformKey('not-a-pem')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户