192 lines
10 KiB
PHP
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";
|
|
}
|