1, 'show' => 0, 'msg' => '', 'data' => $data]; } } } namespace app\adminapi\logic\tcm { final class DiagnosisLogic { public static bool $visible = true; public static string $error = ''; public static array $events = []; public static array $records = []; public static function canViewReadonlyDiagnosis(int $id, int $adminId, array $adminInfo): bool { self::$events[] = ['authorize', $id, $adminId, $adminInfo]; return self::$visible; } public static function getCallRecords(array $params): array { if (!self::$visible) { throw new \RuntimeException('Forbidden records reached the read dependency'); } self::$events[] = ['read', $params]; return self::$records; } public static function saveLocalAudioTranscript(array $params): array { self::$events[] = ['recover', $params]; return ['call_record_id' => 1102, 'transcription_status' => 'completed', 'saved' => true]; } public static function getError(): string { return self::$error; } } } namespace app\adminapi\http\middleware { function request(): object { return $GLOBALS['consultation_transcript_request']; } function env(...$arguments): never { throw new \RuntimeException('This test must not access environment configuration'); } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\controller\tcm\DiagnosisController; use app\adminapi\http\middleware\AuthMiddleware; use app\adminapi\logic\tcm\DiagnosisLogic; use app\common\cache\AdminAuthCache; use app\common\service\JsonService; final class ConsultationTranscriptRequest { public array $adminInfo; public object $controllerObject; public string $actionName = 'getCallRecords'; public string $controllerName = 'tcm.Diagnosis'; public bool $postRequest = false; public function __construct(public array $query = [], bool $root = false) { $this->adminInfo = ['admin_id' => 91, 'root' => $root ? 1 : 0, 'login_ip' => '192.0.2.91', 'work_wechat_userid' => 'synthetic-doctor-91']; $this->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } }; } public function get(?string $name = null, $default = null) { return $name === null ? $this->query : ($this->query[$name] ?? $default); } public function param(string $name, $default = null) { return $default; } public function post($name = '', $default = null, $filter = '') { return $this->query; } public function isPost(): bool { return $this->postRequest; } public function controller(): string { return $this->controllerName; } public function action(): string { return $this->actionName; } public function ip(): string { return '192.0.2.91'; } } final class ConsultationTranscriptController extends DiagnosisController { public function __construct(ConsultationTranscriptRequest $request) { $this->request = $request; $this->adminId = $request->adminInfo['admin_id']; $this->adminInfo = $request->adminInfo; } protected function data($data) { return JsonService::data($data); } protected function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1) { return JsonService::fail($msg, $data, $code, $show); } } $assertions = 0; function transcriptAuthorizationExpect(bool $ok, string $label): void { global $assertions; if (!$ok) { throw new RuntimeException('FAIL: ' . $label); } $assertions++; } function transcriptAuthorizationInvoke(array $query, array $permissions, bool $root = false): array { AdminAuthCache::$permissions = $permissions; AdminAuthCache::$allUriReads = 0; AdminAuthCache::$actors = []; DiagnosisLogic::$events = []; $request = new ConsultationTranscriptRequest($query, $root); $GLOBALS['consultation_transcript_request'] = $request; $controller = new ConsultationTranscriptController($request); $request->controllerObject = $controller; return (new AuthMiddleware())->handle($request, static fn () => $controller->getCallRecords()); } DiagnosisLogic::$records = [ ['id' => 1102, 'diagnosis_id' => 701, 'caller_id' => 202, 'caller_type' => 'doctor', 'transcription_session_id' => 'synthetic-session-1102', 'transcription_status' => 'partial', 'transcription_segment_count' => 2, 'transcript_text' => "医生:合成问题 \n患者:合成回答 & 保留原文。"], ['id' => 1101, 'diagnosis_id' => 701, 'caller_id' => 201, 'caller_type' => 'doctor', 'transcription_session_id' => 'synthetic-session-1101', 'transcription_status' => 'completed', 'transcription_segment_count' => 1, 'transcript_text' => '未知说话人:另一通合成面诊。'], ]; foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/detail'], ['tcm.diagnosis/video-call'], ['tcm.diagnosis/huifangOther'], ['tcm.diagnosis/getCallRecordsOther']] as $permissions) { $result = transcriptAuthorizationInvoke(['diagnosis_id' => 701], $permissions); transcriptAuthorizationExpect($result['code'] === 0, 'unregistered read rejects unrelated grants ' . json_encode($permissions)); transcriptAuthorizationExpect(DiagnosisLogic::$events === [], 'function permission denial precedes row and transcript reads'); transcriptAuthorizationExpect(AdminAuthCache::$allUriReads === 0, 'transcript route never consults legacy unregistered-route bypass'); } foreach ([['tcm.diagnosis/huifang'], ['tcm.diagnosis/getCallRecords'], ['TCM.DIAGNOSIS/GETCALLRECORDS'], ['tcm.diagnosis/huifang', 'tcm.diagnosis/getCallRecords']] as $permissions) { DiagnosisLogic::$visible = true; $result = transcriptAuthorizationInvoke(['diagnosis_id' => '701', 'call_record_id' => 9999, 'appointment_id' => 9999, 'caller_id' => 91], $permissions); transcriptAuthorizationExpect($result['code'] === 1, 'either exact read grant allows the controller without edit ' . json_encode($permissions)); transcriptAuthorizationExpect($result['data'] === DiagnosisLogic::$records, 'full transcript and every call identity remain unchanged'); transcriptAuthorizationExpect(DiagnosisLogic::$events === [ ['authorize', 701, 91, $GLOBALS['consultation_transcript_request']->adminInfo], ['read', ['diagnosis_id' => 701]], ], 'real controller authorizes trusted actor before a diagnosis-only read, without a caller filter'); transcriptAuthorizationExpect(AdminAuthCache::$allUriReads === 0, 'grant works even with no registered menu routes'); } $root = transcriptAuthorizationInvoke(['diagnosis_id' => 701], [], true); transcriptAuthorizationExpect($root['data'] === DiagnosisLogic::$records, 'root retains the existing middleware grant bypass'); transcriptAuthorizationExpect(AdminAuthCache::$actors === [], 'root does not need a permission cache or menu grant'); transcriptAuthorizationExpect(DiagnosisLogic::$events[0][0] === 'authorize', 'root still passes the controller through diagnosis row authorization'); DiagnosisLogic::$visible = false; foreach (['诊单不存在或无权访问', ''] as $error) { DiagnosisLogic::$error = $error; $denied = transcriptAuthorizationInvoke(['diagnosis_id' => 702], ['tcm.diagnosis/huifang']); transcriptAuthorizationExpect($denied['code'] === 0 && $denied['msg'] === '诊单不存在或无权访问', 'forbidden or missing diagnosis shares the same non-enumerating error'); transcriptAuthorizationExpect(count(DiagnosisLogic::$events) === 1 && DiagnosisLogic::$events[0][0] === 'authorize' && DiagnosisLogic::$events[0][1] === 702, 'row denial happens before transcript records can be read'); } DiagnosisLogic::$visible = true; DiagnosisLogic::$error = ''; foreach ([[], ['diagnosis_id' => null], ['diagnosis_id' => 0], ['diagnosis_id' => -1], ['diagnosis_id' => '0'], ['diagnosis_id' => ''], ['diagnosis_id' => 'invalid'], ['diagnosis_id' => '701suffix'], ['diagnosis_id' => '701.5'], ['diagnosis_id' => 701.5], ['diagnosis_id' => true], ['diagnosis_id' => false], ['diagnosis_id' => []], ['diagnosis_id' => ['701']], ['diagnosis_id' => new stdClass()]] as $query) { $invalid = transcriptAuthorizationInvoke($query, ['tcm.diagnosis/huifang']); transcriptAuthorizationExpect($invalid['code'] === 0, 'missing or nonpositive diagnosis id is rejected ' . json_encode($query)); transcriptAuthorizationExpect(DiagnosisLogic::$events === [], 'invalid id never reaches row authorization or a record read'); } $request = new ConsultationTranscriptRequest(['diagnosis_id' => 701]); $request->controllerName = 'TcM.DiAgNoSiS'; $request->actionName = 'GeTcAlLrEcOrDs'; $GLOBALS['consultation_transcript_request'] = $request; AdminAuthCache::$permissions = []; $continued = false; $caseDenied = (new AuthMiddleware())->handle($request, static function () use (&$continued): array { $continued = true; return ['code' => 1]; }); transcriptAuthorizationExpect($caseDenied['code'] === 0 && !$continued, 'route case variations cannot evade the exact protected action'); DiagnosisLogic::$records = []; $empty = transcriptAuthorizationInvoke(['diagnosis_id' => 701], ['tcm.diagnosis/getCallRecords']); transcriptAuthorizationExpect($empty['code'] === 1 && $empty['data'] === [], 'authorized empty history retains the original array response'); $recoveryGrants = ['tcm.diagnosis/startCallTranscription', 'tcm.diagnosis/upsertCallTranscriptSegments', 'tcm.diagnosis/finishCallTranscription']; foreach ([[], ['tcm.diagnosis/huifang'], ['tcm.diagnosis/edit'], ['tcm.diagnosis/video-call'], ['tcm.diagnosis/saveLocalAudioTranscript'], [$recoveryGrants[0]], array_slice($recoveryGrants, 0, 2), [$recoveryGrants[0], $recoveryGrants[1], $recoveryGrants[2] . 'Other']] as $permissions) { $request = new ConsultationTranscriptRequest(); $request->actionName = 'SaVeLoCaLaUdIoTrAnScRiPt'; $GLOBALS['consultation_transcript_request'] = $request; AdminAuthCache::$permissions = $permissions; AdminAuthCache::$allUriReads = 0; $continued = false; $denied = (new AuthMiddleware())->handle($request, static function () use (&$continued): array { $continued = true; return ['code' => 1]; }); transcriptAuthorizationExpect($denied['code'] === 0 && !$continued, 'recovery requires all exact realtime grants'); transcriptAuthorizationExpect(AdminAuthCache::$allUriReads === 0, 'recovery never uses unregistered route bypass'); } foreach ([false, true] as $root) { $payload = ['diagnosis_id' => 701, 'call_record_id' => 1102, 'admin_id' => 9999, 'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'session', 'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/test.webm', 'transcript_text' => "原文 &\n结尾"]; $request = new ConsultationTranscriptRequest($payload, $root); $request->actionName = 'saveLocalAudioTranscript'; $request->postRequest = true; $GLOBALS['consultation_transcript_request'] = $request; $controller = new ConsultationTranscriptController($request); $request->controllerObject = $controller; AdminAuthCache::$permissions = $root ? [] : array_map('strtoupper', $recoveryGrants); DiagnosisLogic::$events = []; $saved = (new AuthMiddleware())->handle($request, static fn () => $controller->saveLocalAudioTranscript()); transcriptAuthorizationExpect($saved['code'] === 1 && $saved['data']['saved'] === true, 'granted actor can invoke recovery'); $payload['admin_id'] = 91; transcriptAuthorizationExpect(DiagnosisLogic::$events === [['recover', $payload]], 'controller uses login actor and preserves transcript text'); $request->postRequest = false; DiagnosisLogic::$events = []; transcriptAuthorizationExpect($controller->saveLocalAudioTranscript()['code'] === 0 && DiagnosisLogic::$events === [], 'recovery rejects GET before persistence'); } echo "Consultation transcript authorization: {$assertions} assertions passed (no DB or network)\n"; }