Files
zyt/server/tests/QywxPromotionMediaServiceTest.php
2026-08-31 15:17:34 +08:00

89 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionMediaStore;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use think\file\UploadedFile;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
new think\App();
function mediaCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
final class MemoryPromotionMediaStore extends QywxPromotionMediaStore
{
public array $rows = [];
public array $references = [];
public function find(string $assetId): ?array { return $this->rows[$assetId] ?? null; }
public function insert(array $row): void { $this->rows[$row['asset_id']] = $row; }
public function update(string $assetId, array $fields): void { $this->rows[$assetId] = array_replace($this->rows[$assetId], $fields); }
public function referencedAssetIds(): array { return $this->references; }
}
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'qywx_media_test_' . bin2hex(random_bytes(6));
mkdir($root, 0700);
$mock = new MockHandler([
new Response(200, [], json_encode(['media_id' => 'prepared', 'created_at' => time()])),
new Response(200, [], json_encode(['media_id' => 'refreshed', 'created_at' => time()])),
]);
$api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock)]), static fn (): string => 'mock');
$store = new MemoryPromotionMediaStore();
$service = new QywxPromotionMediaService($api, $store, $root . DIRECTORY_SEPARATOR . 'private');
try {
$path = $root . DIRECTORY_SEPARATOR . 'upload.png';
file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2l9sAAAAASUVORK5CYII='));
$asset = $service->upload(new UploadedFile($path, '../../cover.png', null, null, true), 'image', 7);
mediaCheck(preg_match('/^[0-9a-f]{48}$/', $asset['asset_id']) === 1 && $asset['name'] === 'cover.png', 'random asset and safe name');
mediaCheck(array_keys($asset) === ['asset_id', 'name', 'type'], 'no private path returned');
$attachment = ['msgtype' => 'image', 'image' => ['asset_id' => $asset['asset_id']]];
$config = ['welcome' => ['text' => '你好', 'attachments' => [$attachment]], 'welcome_schedule' => [], 'untouched' => 'retained'];
mediaCheck($service->validateConfig($config, 7)['untouched'] === 'retained', 'keep unrelated config');
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'prepared', 'cached materialization');
mediaCheck(count($mock) === 1, 'welcome preparation never uploads');
$denied = false;
try { $service->validateConfig($config, 8); } catch (RuntimeException) { $denied = true; }
mediaCheck($denied, 'cross-admin asset must be denied');
mediaCheck($service->validateConfig($config, 8, $config)['welcome']['attachments'] === [$attachment], 'shared editor may retain existing authorized asset');
foreach ([
[['msgtype' => 'image', 'image' => ['asset_id' => '../secret']]],
[['msgtype' => 'image', 'image' => ['pic_url' => 'http://127.0.0.1/private']]],
[['msgtype' => 'video', 'video' => ['asset_id' => $asset['asset_id']]]],
[['msgtype' => 'link', 'link' => ['title' => 'x', 'url' => 'javascript:alert(1)']]],
[['msgtype' => 'link', 'link' => ['title' => str_repeat('字', 43), 'url' => 'https://example.com']]],
[['msgtype' => 'miniprogram', 'miniprogram' => ['title' => 'test', 'appid' => 'bad', 'page' => '/pages/a', 'pic_asset_id' => $asset['asset_id']]]],
] as $invalid) {
$failed = false;
try { $service->validateAttachments($invalid, 7); } catch (RuntimeException) { $failed = true; }
mediaCheck($failed, 'invalid attachment denied');
}
$valid = $service->validateAttachments([
['msgtype' => 'link', 'link' => ['title' => '就诊', 'url' => 'https://example.com', 'desc' => '说明', 'picurl' => 'https://example.com/p.png']],
['msgtype' => 'miniprogram', 'miniprogram' => ['title' => '预约', 'appid' => 'wx0123456789abcdef', 'page' => 'pages/index?a=1', 'pic_asset_id' => $asset['asset_id']]],
], 7);
mediaCheck(count($valid) === 2, 'link/miniprogram normalize');
$store->rows[$asset['asset_id']]['media_expires_at'] = time() - 1;
$failed = false;
try { $service->materialize([$attachment], $config); } catch (RuntimeException) { $failed = true; }
mediaCheck($failed && count($mock) === 1, 'expired media fails without upload during welcome');
$store->references = [$asset['asset_id']];
mediaCheck($service->refreshReferenced()['refreshed'] === 1, 'scheduled refresh restores expired media');
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'refreshed', 'use refreshed media id');
file_put_contents($path, '<?php echo "not image";');
$failed = false;
try { $service->upload(new UploadedFile($path, 'fake.png', null, null, true), 'image', 7); } catch (RuntimeException) { $failed = true; }
mediaCheck($failed, 'MIME spoofed image rejected');
$failed = false;
try { $service->upload($path, 'file', 7); } catch (RuntimeException) { $failed = true; }
mediaCheck($failed, 'arbitrary server path rejected');
} finally {
foreach (glob($root . DIRECTORY_SEPARATOR . 'private' . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { unlink($file); }
if (is_dir($root . DIRECTORY_SEPARATOR . 'private')) { rmdir($root . DIRECTORY_SEPARATOR . 'private'); }
foreach (glob($root . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
rmdir($root);
}
echo "QYWX_PROMOTION_MEDIA_OK\n";