1, 'enabled' => false, 'template' => 'bubble', 'position' => 'bottom-right', 'title' => '专属顾问在线', 'subtitle' => '点击添加企业微信,获取一对一服务', 'button_text' => '立即咨询', 'primary_color' => '#139A8C', 'bottom_offset' => 28, 'show_mobile' => true, ], '默认浮窗配置与公开契约不一致'); $normalised = QywxPromotionWidgetService::fromInput([ 'enabled' => '1', 'template' => 'card', 'position' => 'bottom-left', 'title' => " 在线\n顾问 ", 'subtitle' => '', 'button_text' => '去咨询', 'primary_color' => '#a1b2c3', 'bottom_offset' => '64', 'show_mobile' => '0', ]); widgetAssert($normalised['v'] === 1, '缺省输入未补齐配置版本'); widgetAssert($normalised['enabled'] === true, '启用状态规范化失败'); widgetAssert($normalised['title'] === '在线 顾问', '文案空白规范化失败'); widgetAssert($normalised['primary_color'] === '#A1B2C3', '主题色未规范为大写'); widgetAssert($normalised['bottom_offset'] === 64, '底部距离规范化失败'); widgetAssert($normalised['show_mobile'] === false, '移动端开关规范化失败'); widgetAssert( QywxPromotionWidgetService::decode(QywxPromotionWidgetService::encode($normalised)) === $normalised, '浮窗配置编解码不能稳定往返' ); expectInvalidWidget(['v' => 2], '未知版本没有被拒绝'); expectInvalidWidget(['template' => 'html'], '未知模板没有被拒绝'); expectInvalidWidget(['position' => 'top-right'], '未知位置没有被拒绝'); expectInvalidWidget(['title' => ''], '空标题没有被拒绝'); expectInvalidWidget(['title' => str_repeat('中', 25)], '超长标题没有被拒绝'); expectInvalidWidget(['subtitle' => str_repeat('中', 49)], '超长副标题没有被拒绝'); expectInvalidWidget(['button_text' => str_repeat('中', 13)], '超长按钮文案没有被拒绝'); expectInvalidWidget(['primary_color' => 'red;background:url(x)'], 'CSS 注入色值没有被拒绝'); expectInvalidWidget(['bottom_offset' => 15], '过小底部距离没有被拒绝'); expectInvalidWidget(['bottom_offset' => 161], '过大底部距离没有被拒绝'); expectInvalidWidget(['show_mobile' => 'yes'], '非法布尔值没有被拒绝'); expectInvalidWidget(['template' => null], '显式 null 模板没有被拒绝'); $decodedInvalid = QywxPromotionWidgetService::decode('{broken'); widgetAssert($decodedInvalid === $defaults && $decodedInvalid['enabled'] === false, '损坏 JSON 未 fail-closed'); $decodedIncomplete = QywxPromotionWidgetService::decode('{"enabled":true}'); widgetAssert($decodedIncomplete === $defaults && $decodedIncomplete['enabled'] === false, '字段缺失配置未 fail-closed'); $decodedUnknown = QywxPromotionWidgetService::decode('{"v":2,"enabled":true}'); widgetAssert($decodedUnknown === $defaults && $decodedUnknown['enabled'] === false, '未知版本未 fail-closed'); $decodedIllegal = QywxPromotionWidgetService::decode('{"v":1,"enabled":true,"template":"raw-html"}'); widgetAssert($decodedIllegal === $defaults && $decodedIllegal['enabled'] === false, '非法持久化配置未 fail-closed'); $xssConfig = QywxPromotionWidgetService::fromInput([ 'v' => 1, 'enabled' => true, 'template' => 'message', 'position' => 'bottom-right', 'title' => '', 'subtitle' => '', 'button_text' => '咨询', 'primary_color' => '#139A8C', 'bottom_offset' => 28, 'show_mobile' => true, ]); $encoded = QywxPromotionWidgetService::encode($xssConfig); widgetAssert(!str_contains($encoded, ''), 'XSS 文案以原始标签进入公开脚本'); widgetAssert(!str_contains($script, 'innerHTML'), '公开脚本不得使用 innerHTML'); widgetAssert(str_contains($script, 'node.textContent=value'), '公开脚本文案未通过 textContent 写入'); widgetAssert(str_contains($script, 'data-wecom-promotion'), '旧 data-wecom-promotion 触发方式丢失'); widgetAssert(str_contains($script, '.wecom-promotion-link[data-pool'), '旧 data-pool 触发方式丢失'); widgetAssert(str_contains($script, 'w.WecomPromotion=w.WecomPromotion||{}'), '全局 WecomPromotion 注册表丢失'); widgetAssert(str_contains($script, 'open:openPromotion'), '全局 open 方法丢失'); widgetAssert(str_contains($script, 'show:show') && str_contains($script, 'hide:hide') && str_contains($script, 'destroy:destroy'), '浮窗生命周期方法不完整'); widgetAssert(str_contains($script, 'location.assign(targetUrl)'), '公开脚本没有直达企业微信官方链接'); widgetAssert(str_contains($script, 'work.weixin.qq.com/ca/test?customer_channel=zyt_pool:123'), '公开脚本缺少官方渠道链接'); widgetAssert(!str_contains($script, '?from='), '公开脚本仍追加本站跳转来源参数'); widgetAssert(str_contains($script, 'attachShadow'), '公开脚本未隔离浮窗样式'); widgetAssert(str_contains($script, 'd.currentScript') && str_contains($script, 'new w.URL(value,node.src)'), '跳转地址未从安装脚本来源解析'); widgetAssert(str_contains($script, "style.setAttribute('nonce',nonce)"), '公开脚本未向动态样式传递 CSP nonce'); widgetAssert(str_contains($script, 'event.composedPath'), '公开脚本未兼容 Shadow DOM 内的手动触发元素'); widgetAssert(!str_contains($script, 'root.style.'), '公开脚本仍依赖会被严格 CSP 拦截的元素内联样式'); widgetAssert(str_contains($script, 'safe-area-inset-bottom'), '公开脚本未适配移动端安全区'); foreach (['bubble', 'pill', 'card', 'message', 'edge', 'bar'] as $template) { widgetAssert(str_contains($script, '.wcp-' . $template), '公开脚本缺少模板:' . $template); } $disabledScript = QywxPromotionWidgetService::renderScript($key, 'https://example.test/go', $xssConfig, false); widgetAssert(str_contains($disabledScript, '"enabled":false'), '停用方案仍会自动挂载浮窗'); widgetAssert(str_contains($disabledScript, 'open:openPromotion'), '停用方案脚本没有保留手动 open 兼容接口'); echo "QYWX_PROMOTION_WIDGET_SERVICE_OK\n";