Files
zyt/server/tests/ConsultationTranscriptAuthorizationTest.php
2026-11-09 10:02:27 +08:00

260 lines
14 KiB
PHP

<?php
declare(strict_types=1);
// All identities and records are synthetic. The real controller and middleware
// execute below, while their IO dependencies stay entirely in memory.
namespace app\adminapi\logic {
final class LoginLogic
{
public static function adminMustBindWorkWechat(array $adminInfo): bool { return false; }
}
}
namespace app\common\cache {
final class AdminAuthCache
{
public static array $permissions = [];
public static int $allUriReads = 0;
public static array $actors = [];
public function __construct(int $adminId) { self::$actors[] = $adminId; }
public function getAdminUri(): array { return self::$permissions; }
public function getAllUri(): array { self::$allUriReads++; return []; }
}
}
namespace app\common\service {
final class JsonService
{
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): array
{
return compact('code', 'show', 'msg', 'data');
}
public static function data(array $data): array
{
return ['code' => 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' => "医生:合成问题 <test>\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' => "原文 <test> &\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";
}