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"; }