Files
zyt/server/tests/ImCallbackTest.php
2026-09-09 15:47:48 +08:00

192 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace app\api\controller {
// 覆盖配置读取以防测试读取真实 .env;控制器本身和 ThinkPHP 请求/响应均为真实类。
function config(string $key, mixed $default = null): mixed
{
return \ImCallbackFixture::$config[$key] ?? $default;
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\api\controller\ImController;
use app\api\http\middleware\LoginMiddleware;
use app\common\service\ImCallbackSignature;
final class ImCallbackFixture
{
public const TOKEN = 'fictional-unit-test-token';
public static array $config = ['im.callback_token' => self::TOKEN, 'project.trtc.sdkAppId' => 1400000000];
public static array $archived = [];
public static array $logs = [];
public static bool $throwArchive = false;
public static bool $throwLog = false;
public static int $inserted = 1;
public static function archiveImCallbackMessage(array $payload): int
{
if (self::$throwArchive) {
throw new \RuntimeException('patient-private-message-and-SQL-must-not-leak');
}
self::$archived[] = $payload;
return self::$inserted;
}
public static function error(string $message): void
{
if (self::$throwLog) throw new \RuntimeException('log storage unavailable');
self::$logs[] = $message;
}
public function getUserInfo(mixed $token): never
{
throw new \RuntimeException('Callback must not query session cache/database');
}
}
class_alias(ImCallbackFixture::class, 'app\\adminapi\\logic\\tcm\\DiagnosisLogic');
class_alias(ImCallbackFixture::class, 'think\\facade\\Log');
class_alias(ImCallbackFixture::class, 'app\\common\\cache\\UserTokenCache');
$assertions = 0;
function imCallbackExpect(bool $condition, string $message): void
{
global $assertions;
$assertions++;
if (!$condition) throw new \RuntimeException($message);
}
function imCallbackQuery(?int $timestamp = null): array
{
$timestamp ??= time();
return [
'SdkAppid' => '1400000000',
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
'RequestTime' => (string) $timestamp,
'Sign' => hash('sha256', ImCallbackFixture::TOKEN . $timestamp),
];
}
function imCallbackBody(): array
{
return [
'CallbackCommand' => 'C2C.CallbackAfterSendMsg',
'From_Account' => 'patient_1001',
'To_Account' => 'doctor_2001',
'MsgSeq' => 7,
'MsgRandom' => 8,
'MsgTime' => 1700000000,
'MsgKey' => '7_8_1700000000',
'SendMsgResult' => 0,
'MsgBody' => [['MsgType' => 'TIMTextElem', 'MsgContent' => ['Text' => 'synthetic-test-message']]],
];
}
/** @return array{0:ImController,1:\think\Request} */
function imCallbackController(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): array
{
$request = (new \think\Request())
->withGet($query ?? imCallbackQuery())
->withInput($body ?? json_encode(imCallbackBody(), JSON_THROW_ON_ERROR))
->withServer(['REQUEST_METHOD' => $method])
->withHeader($headers);
$request->setAction('messageNotify');
$controller = (new \ReflectionClass(ImController::class))->newInstanceWithoutConstructor();
(new \ReflectionProperty(\app\BaseController::class, 'request'))->setValue($controller, $request);
$request->controllerObject = $controller;
return [$controller, $request];
}
function imCallbackResponse(?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): \think\response\Json
{
[$controller, $request] = imCallbackController($query, $body, $method, $headers);
return (new LoginMiddleware())->handle($request, static fn () => $controller->messageNotify());
}
function imCallbackReject(int $status, ?array $query = null, ?string $body = null, string $method = 'POST', array $headers = []): void
{
$before = count(ImCallbackFixture::$archived);
$response = imCallbackResponse($query, $body, $method, $headers);
imCallbackExpect($response->getCode() === $status && $response->getData()['ActionStatus'] === 'FAIL', 'handler rejects with Tencent FAIL and HTTP ' . $status);
imCallbackExpect(count(ImCallbackFixture::$archived) === $before, 'rejected requests never reach archiving');
}
// 官方文档签名向量与时间窗口边界。
imCallbackExpect(ImCallbackSignature::verify('xxxxyyyy', '1669872112', '17773bc39a671d7b9aa835458704d2a6db81360a5940292b587d6d760d484061', 1669872112), 'official SHA256 concatenation vector matches');
foreach ([-60, -59, 0, 59, 60] as $offset) {
$query = imCallbackQuery(1700000000 + $offset);
imCallbackExpect(ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'timestamps within the inclusive minute window pass');
}
foreach ([-61, 61] as $offset) {
$query = imCallbackQuery(1700000000 + $offset);
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $query['RequestTime'], $query['Sign'], 1700000000), 'expired or future timestamps beyond one minute fail');
}
foreach (['', ' ', [], null, 1.5, '-1', '1e9', '1700000000\n'] as $value) {
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, $value, str_repeat('a', 64), 1700000000), 'malformed RequestTime fails closed');
}
foreach (['', ' ', [], null, str_repeat('a', 63), str_repeat('g', 64)] as $value) {
imCallbackExpect(!ImCallbackSignature::verify(ImCallbackFixture::TOKEN, '1700000000', $value, 1700000000), 'malformed Sign fails closed');
}
imCallbackExpect(!ImCallbackSignature::verify('', '1700000000', hash('sha256', '1700000000'), 1700000000), 'empty configured token cannot authenticate');
imCallbackExpect(!ImCallbackSignature::verify(' ', '1700000000', hash('sha256', ' 1700000000'), 1700000000), 'blank configured token cannot authenticate');
$success = imCallbackResponse();
imCallbackExpect($success->getCode() === 200 && $success->getData() === ['ActionStatus' => 'OK', 'ErrorCode' => 0, 'ErrorInfo' => ''], 'authenticated actual handler returns the exact Tencent success envelope');
imCallbackExpect(ImCallbackFixture::$archived === [imCallbackBody()], 'entire authenticated payload reaches archiveImCallbackMessage');
$successWithTokenHeader = imCallbackResponse(headers: ['token' => 'fictional-unrelated-user-token']);
imCallbackExpect($successWithTokenHeader->getCode() === 200, 'callback bypasses login session lookup even with an incidental token header');
ImCallbackFixture::$inserted = 0;
imCallbackExpect(imCallbackResponse()->getCode() === 200, 'idempotent duplicate or safely ignored patient pair acknowledges success');
imCallbackReject(405, method: 'GET');
imCallbackReject(405, method: 'GET', headers: ['x-http-method-override' => 'POST']);
imCallbackReject(413, headers: ['content-length' => '1048577']);
imCallbackReject(413, body: str_repeat('x', 1048577));
foreach (['[', '[]', 'null', '"string"', '{}', '{"CallbackCommand":123}'] as $body) imCallbackReject(400, body: $body);
imCallbackReject(400, body: str_repeat('{"nested":', 66) . '0' . str_repeat('}', 66));
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => 'Other.Callback']));
imCallbackReject(400, query: array_replace(imCallbackQuery(), ['CallbackCommand' => []]));
foreach ([null, [], '1400000001', '01400000000'] as $appId) imCallbackReject(403, query: array_replace(imCallbackQuery(), ['SdkAppid' => $appId]));
imCallbackReject(403, query: array_replace(imCallbackQuery(), ['Sign' => str_repeat('0', 64)]));
imCallbackReject(403, query: imCallbackQuery(time() - 120));
imCallbackReject(403, query: imCallbackQuery(time() + 120));
$noSign = imCallbackQuery();
unset($noSign['Sign']);
imCallbackReject(403, query: $noSign);
ImCallbackFixture::$config['im.callback_token'] = '';
imCallbackReject(503);
ImCallbackFixture::$config['im.callback_token'] = ImCallbackFixture::TOKEN;
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 0;
imCallbackReject(503);
ImCallbackFixture::$config['project.trtc.sdkAppId'] = 1400000000;
$before = count(ImCallbackFixture::$archived);
$unknown = imCallbackResponse(array_replace(imCallbackQuery(), ['CallbackCommand' => 'State.StateChange']), '{"CallbackCommand":"State.StateChange"}');
imCallbackExpect($unknown->getCode() === 200 && count(ImCallbackFixture::$archived) === $before, 'other authenticated callbacks are safely ignored');
$noQueryCommand = imCallbackQuery();
unset($noQueryCommand['CallbackCommand']);
imCallbackExpect(imCallbackResponse($noQueryCommand)->getCode() === 200, 'body command is accepted when optional query command is absent');
ImCallbackFixture::$throwArchive = true;
$failedArchive = imCallbackResponse();
imCallbackExpect($failedArchive->getCode() === 500 && $failedArchive->getData()['ActionStatus'] === 'FAIL', 'archive exception returns HTTP 500 and Tencent FAIL rather than OK');
imCallbackExpect(ImCallbackFixture::$logs === ['IM callback archive failed'], 'logging contains neither patient body nor exception SQL nor signature');
imCallbackExpect(!str_contains(json_encode($failedArchive->getData()), 'patient-private'), 'response cannot leak archive exception contents');
ImCallbackFixture::$throwLog = true;
imCallbackExpect(imCallbackResponse()->getCode() === 500, 'logging failure does not replace the Tencent archive failure response');
// 精确免登录分支不扩散到同一控制器的其他 action。
[$otherActionController, $otherActionRequest] = imCallbackController();
$otherActionRequest->setAction('otherAction');
$nextCalled = false;
(new LoginMiddleware())->handle($otherActionRequest, static function () use (&$nextCalled): void { $nextCalled = true; });
imCallbackExpect(!$nextCalled, 'noncallback action remains behind ordinary session middleware');
echo "IM callback signature, actual handler and login middleware: {$assertions} assertions passed (no database/network)\n";
}