Files
xuetang/server/tests/QywxPromotionWidgetServiceTest.php
T
2026-09-08 11:40:15 +08:00

132 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
use app\common\service\qywx\QywxPromotionWidgetService;
require dirname(__DIR__) . '/vendor/autoload.php';
function widgetAssert(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function expectInvalidWidget(array $overrides, string $message): void
{
try {
QywxPromotionWidgetService::fromInput($overrides + QywxPromotionWidgetService::defaults());
} catch (InvalidArgumentException) {
return;
}
throw new RuntimeException($message);
}
$defaults = QywxPromotionWidgetService::defaults();
widgetAssert($defaults === [
'v' => 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' => '<img onerror=x>',
'subtitle' => '</script>',
'button_text' => '咨询',
'primary_color' => '#139A8C',
'bottom_offset' => 28,
'show_mobile' => true,
]);
$encoded = QywxPromotionWidgetService::encode($xssConfig);
widgetAssert(!str_contains($encoded, '<img') && str_contains($encoded, '\\u003Cimg'), '持久化 JSON 未使用 HEX 转义');
$key = str_repeat('a', 32);
$script = QywxPromotionWidgetService::renderScript(
$key,
'/api/qywx-promotion/go/' . $key,
$xssConfig,
true
);
widgetAssert(!str_contains($script, '<img onerror=x>'), '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.origin') && str_contains($script, 'location.pathname'), '来源地址未限制为 origin + pathname');
widgetAssert(!str_contains($script, 'location.href'), '公开脚本仍发送完整 location.href');
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";