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, '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";