Files
zyt/server/tests/DoctorWebConsultationPolicyTest.php
2026-10-22 11:02:57 +08:00

233 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\service {
// 可控的业务时钟:不启动框架、访问数据库或改变本机时钟。
function time(): int { return $GLOBALS['migration_test_now']; }
}
namespace {
require dirname(__DIR__) . '/app/adminapi/service/DoctorWebConsultationPolicy.php';
require dirname(__DIR__) . '/app/adminapi/http/middleware/DoctorWebConsultationMiddleware.php';
use app\adminapi\service\DoctorWebConsultationPolicy as Policy;
use app\adminapi\http\middleware\DoctorWebConsultationMiddleware;
final class MigrationTestJson
{
public static function fail(string $msg, array $data = [], int $code = 0): array
{
return compact('code', 'msg', 'data');
}
public static function data(array $data): array
{
return ['code' => 1, 'show' => 0, 'msg' => '', 'data' => $data];
}
}
class_alias(MigrationTestJson::class, 'app\common\service\JsonService');
require dirname(__DIR__) . '/vendor/autoload.php';
final class MigrationConfigController extends \app\adminapi\controller\ConfigController
{
public function __construct(string $action = 'doctorWebConsultation')
{
$this->request = (new \think\Request())->setAction($action);
}
}
$assertions = 0;
function migrationExpect(bool $condition, string $message): void
{
global $assertions;
$assertions++;
if (!$condition) throw new \RuntimeException($message);
}
function migrationRejects(array $configuration, string $key): void
{
try {
Policy::publicPolicy(123, $configuration);
} catch (\InvalidArgumentException $error) {
migrationExpect(str_contains($error->getMessage(), 'doctor_web_consultation.' . $key), 'configuration error names the invalid field');
return;
}
migrationExpect(false, 'invalid configuration was accepted: ' . $key);
}
$cutoff = (new \DateTimeImmutable('2026-10-23T16:00:00Z'))->getTimestamp();
$GLOBALS['migration_test_now'] = $cutoff;
date_default_timezone_set('UTC');
migrationExpect(Policy::cutoffTimestamp() === $cutoff, 'cutoff is independent of process timezone');
$defaults = require dirname(__DIR__) . '/config/doctor_web_consultation.php';
$defaultPolicy = Policy::publicPolicy($cutoff);
migrationExpect($defaultPolicy === [
'cutoff_at' => '2026-10-24T00:00:00+08:00',
'approaching_at' => '2026-10-22T00:00:00+08:00',
'urgent_at' => '2026-10-23T00:00:00+08:00',
'server_time' => $cutoff,
], 'default config exposes the cutoff and both reminder boundaries with explicit Beijing offsets');
migrationExpect(Policy::cutoffAt() === $defaultPolicy['cutoff_at'], 'cutoff helper uses the same config');
migrationExpect(str_contains(Policy::message(), '2026年10月24日00:00:00'), 'message derives the exact configured second');
$doctor = ['admin_id' => 101, 'root' => 0, 'role_id' => [1], 'terminal' => 1];
$browser = ['User-Agent' => 'Mozilla/5.0', 'Origin' => 'https://example.test'];
foreach (['getCallSignature', 'startCall', 'getDoctorSignature', 'getPatientSignature'] as $action) {
foreach ([$cutoff - 1 => false, $cutoff => true, $cutoff + 1 => true] as $now => $expected) {
migrationExpect(Policy::blocksRequest($doctor, 'tcm.Diagnosis', $action, $browser, $now) === $expected, "$action boundary $now");
}
}
foreach ([['role_id' => [2]], ['role_id' => [3, 7, 8]], ['root' => 1, 'role_id' => [1]], []] as $identity) {
migrationExpect(!Policy::blocksRequest($identity, 'tcm.diagnosis', 'startCall', $browser), 'only non-root doctors migrate');
}
migrationExpect(Policy::blocksRequest(['role_id' => ['2', '1']], 'tcm.diagnosis', 'startCall', $browser), 'mixed and numeric string roles retain doctor policy');
foreach (['ZytDoctorWorkstation/1.4.8', 'python-httpx/0.27.2', 'python-httpx/0.28.1'] as $ua) {
migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', 'getCallSignature', ['User-Agent' => $ua]), 'new and already installed workstations keep working');
foreach (['Origin', 'Referer', 'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Sec-Fetch-Dest'] as $signal) {
migrationExpect(Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['User-Agent' => $ua, $signal => 'same-origin']), 'browser metadata takes priority over desktop UA');
}
}
foreach (['', 'unknown', 'Mozilla/5.0', 'Mozilla/5.0 ZytDoctorWorkstation/1.4.8', 'python-httpx/0.28.1 extra'] as $ua) {
migrationExpect(Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['user-agent' => $ua]), 'missing or unknown client never gains a desktop exemption');
}
foreach (['lists', 'detail', 'readonlyDetail', 'edit', 'bindCallRoom', 'endCall', 'watchCall', 'attachLocalCallRecording', 'upsertCallTranscriptSegments', 'finishCallTranscription'] as $action) {
migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', $action, $browser), 'history and in-flight cleanup remain available: ' . $action);
}
foreach (['doctor.appointment', 'tcm.prescription', 'auth.admin'] as $controller) {
migrationExpect(!Policy::blocksRequest($doctor, $controller, 'startCall', $browser), 'exact controller scope');
}
$custom = ['cutoff_at' => '2032-01-01 14:30:45', 'approaching_hours' => 72, 'urgent_hours' => 6];
$customCutoff = (new \DateTimeImmutable('2032-01-01T14:30:45+08:00'))->getTimestamp();
$customPolicy = Policy::publicPolicy($customCutoff - 1, $custom);
migrationExpect($customPolicy === [
'cutoff_at' => '2032-01-01T14:30:45+08:00',
'approaching_at' => '2031-12-29T14:30:45+08:00',
'urgent_at' => '2032-01-01T08:30:45+08:00',
'server_time' => $customCutoff - 1,
], 'arbitrary midday/year schedule moves both reminder phases automatically');
foreach ([$customCutoff - 1 => false, $customCutoff => true, $customCutoff + 1 => true] as $now => $expected) {
migrationExpect(Policy::blocksRequest($doctor, 'tcm.Diagnosis', 'startCall', $browser, $now, $custom) === $expected,
'custom schedule controls the exact-second backend cutoff');
}
migrationExpect(str_contains(Policy::message($custom), '2032年1月1日14:30:45'), 'custom schedule also changes backend notice text');
foreach (['UTC', 'America/New_York', 'Asia/Tokyo'] as $processTimezone) {
date_default_timezone_set($processTimezone);
migrationExpect(Policy::publicPolicy(123, $custom)['cutoff_at'] === $customPolicy['cutoff_at'], 'Beijing configuration ignores PHP process timezone');
}
date_default_timezone_set('UTC');
$zeroHours = Policy::publicPolicy(123, array_replace($custom, ['approaching_hours' => 0, 'urgent_hours' => 0]));
migrationExpect($zeroHours['approaching_at'] === $zeroHours['cutoff_at'] && $zeroHours['urgent_at'] === $zeroHours['cutoff_at'], 'zero-hour reminders are valid');
$equalHours = Policy::publicPolicy(123, array_replace($custom, ['approaching_hours' => 6]));
migrationExpect($equalHours['approaching_at'] === $equalHours['urgent_at'], 'equal reminder offsets are valid');
migrationExpect(Policy::publicPolicy(123, array_replace($custom, ['cutoff_at' => '2032-02-29 12:00:01']))['cutoff_at'] === '2032-02-29T12:00:01+08:00', 'valid leap date is retained');
foreach ([null, false, 20320101, [], '', 'tomorrow', '2032-1-01 14:30:45', '2032-01-01 14:30',
'2032-01-01 14:30:45 ', '2031-02-29 14:30:45', '2032-04-31 14:30:45', '2032-01-01 24:00:00',
'2032-01-01 14:60:45', '2032-01-01 14:30:60', '0000-01-01 00:00:00', '1991-04-14 02:30:00',
'2032-01-01T14:30:45+08:00', '2032-01-01 14:30:45+08:00', '2032-01-01T14:30:45+25:00'] as $invalid) {
migrationRejects(array_replace($defaults, ['cutoff_at' => $invalid]), 'cutoff_at');
}
migrationRejects([], 'cutoff_at');
foreach (['approaching_hours', 'urgent_hours'] as $key) {
foreach ([null, -1, 1.5, '24', true, [], PHP_INT_MAX] as $invalid) {
migrationRejects(array_replace($defaults, [$key => $invalid]), $key);
}
$missing = $defaults;
unset($missing[$key]);
migrationRejects($missing, $key);
}
migrationRejects(array_replace($defaults, ['approaching_hours' => 12, 'urgent_hours' => 24]), 'approaching_hours');
migrationRejects(array_replace($defaults, ['approaching_hours' => intdiv(PHP_INT_MAX, 3600)]), 'approaching_hours');
migrationExpect(!Policy::blocksRequest($doctor, 'config', 'doctorWebConsultation', $browser, $cutoff, []), 'invalid migration config never affects unrelated routes');
migrationExpect(!Policy::blocksRequest(['root' => 1, 'role_id' => [1]], 'tcm.diagnosis', 'startCall', $browser, $cutoff, []), 'invalid migration config never affects root exemption');
migrationExpect(!Policy::blocksRequest(['role_id' => [2]], 'tcm.diagnosis', 'startCall', $browser, $cutoff, []), 'invalid migration config never affects other roles');
migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['User-Agent' => 'python-httpx/0.28.1'], $cutoff, []), 'invalid migration config never affects native exemption');
$request = new class($doctor, $browser) {
public string $actionName = 'getCallSignature';
public function __construct(public array $adminInfo, public array $headers) {}
public function controller(): string { return 'tcm.Diagnosis'; }
public function action(): string { return $this->actionName; }
public function header(): array { return $this->headers; }
};
$executed = 0;
$next = static function ($request) use (&$executed): string { $executed++; return 'business action executed'; };
$middleware = new DoctorWebConsultationMiddleware();
$GLOBALS['migration_test_now'] = $cutoff - 1;
migrationExpect($middleware->handle($request, $next) === 'business action executed', 'middleware allows October 23');
$GLOBALS['migration_test_now'] = $cutoff;
$failure = $middleware->handle($request, $next);
migrationExpect($executed === 1, 'expired doctor request never reaches signature generation');
migrationExpect($failure['code'] === 0 && $failure['data']['reason'] === 'doctor_web_consultation_expired', 'business failure does not log the user out');
migrationExpect(str_contains($failure['msg'], '桌面应用'), 'failure explains migration');
$request->headers = ['User-Agent' => 'python-httpx/0.28.1'];
migrationExpect($middleware->handle($request, $next) === 'business action executed', 'same terminal=1 native session remains usable');
$request->headers = $browser;
$request->actionName = 'endCall';
migrationExpect($middleware->handle($request, $next) === 'business action executed', 'active calls can still finish and persist recordings');
$request->actionName = 'getDoctorSignature';
$customMiddleware = new DoctorWebConsultationMiddleware($custom);
$GLOBALS['migration_test_now'] = $customCutoff - 1;
migrationExpect($customMiddleware->handle($request, $next) === 'business action executed', 'middleware respects configured midday cutoff before its exact second');
$GLOBALS['migration_test_now'] = $customCutoff;
$beforeBlocked = $executed;
$customFailure = $customMiddleware->handle($request, $next);
migrationExpect($executed === $beforeBlocked && $customFailure['data']['cutoff_at'] === $customPolicy['cutoff_at'], 'actual middleware guard and payload use custom config');
migrationExpect(str_contains($customFailure['msg'], '2032年1月1日14:30:45'), 'actual middleware response formats custom config');
foreach (['ZytDoctorWorkstation/1.4.8', 'python-httpx/0.28.1'] as $ua) {
$request->headers = ['User-Agent' => $ua];
migrationExpect($customMiddleware->handle($request, $next) === 'business action executed', 'custom cutoff preserves native client compatibility');
}
$routes = file_get_contents(dirname(__DIR__) . '/app/adminapi/config/route.php');
migrationExpect(strpos($routes, 'AuthMiddleware::class') < strpos($routes, 'DoctorWebConsultationMiddleware::class'), 'migration runs after normal authorization');
$GLOBALS['migration_test_now'] = $cutoff;
migrationExpect(Policy::publicPolicy()['server_time'] === $cutoff, 'profile exposes synchronized server time');
$controller = new MigrationConfigController();
migrationExpect($controller->isNotNeedLogin(), 'public polling endpoint passes the real inherited login whitelist');
migrationExpect(!(new MigrationConfigController('unlistedAction'))->isNotNeedLogin(), 'whitelist only exposes explicitly public actions');
$endpoint = $controller->doctorWebConsultation();
migrationExpect($endpoint['code'] === 1 && $endpoint['data'] === $defaultPolicy, 'actual ConfigController returns the full read-only public policy contract');
$profileSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/auth/AdminLogic.php');
migrationExpect(str_contains($profileSource, "DoctorWebConsultationPolicy::publicPolicy()"), 'logged-in profile keeps sharing the same backend policy');
// 在独立临时目录验证配置热加载;不改仓库配置,也不读取 .env 或项目密钥。
$fixtureRoot = sys_get_temp_dir() . '/zyt-consultation-policy-' . bin2hex(random_bytes(8));
$fixtureServiceDir = $fixtureRoot . '/app/adminapi/service';
$fixtureConfigDir = $fixtureRoot . '/config';
mkdir($fixtureServiceDir, 0700, true);
mkdir($fixtureConfigDir, 0700, true);
$fixtureService = $fixtureServiceDir . '/DoctorWebConsultationPolicy.php';
$fixtureConfig = $fixtureConfigDir . '/doctor_web_consultation.php';
try {
$source = file_get_contents(dirname(__DIR__) . '/app/adminapi/service/DoctorWebConsultationPolicy.php');
file_put_contents($fixtureService, str_replace('namespace app\\adminapi\\service;', 'namespace migration_reload_fixture;', $source));
file_put_contents($fixtureConfig, '<?php return ' . var_export($defaults, true) . ';');
$frameworkConfig = new \think\Config();
$frameworkConfig->set($defaults, 'doctor_web_consultation');
\think\Container::getInstance()->instance('config', $frameworkConfig);
require $fixtureService;
$fixtureClass = \migration_reload_fixture\DoctorWebConsultationPolicy::class;
migrationExpect($fixtureClass::publicPolicy(123)['cutoff_at'] === $defaultPolicy['cutoff_at'], 'direct config loader starts with default fixture');
file_put_contents($fixtureConfig, '<?php return ' . var_export($custom, true) . ';');
migrationExpect($fixtureClass::publicPolicy($customCutoff - 1) === $customPolicy, 'editing only config immediately changes cutoff and both reminders in the same PHP process');
migrationExpect(\think\facade\Config::get('doctor_web_consultation.cutoff_at') === $defaults['cutoff_at'], 'framework config deliberately remains cached at the old date while policy reloads the new file');
migrationExpect(str_contains($fixtureClass::message(), '2032年1月1日14:30:45'), 'hot-loaded config immediately changes backend message');
migrationExpect($fixtureClass::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', $browser, $customCutoff), 'hot-loaded config controls backend enforcement');
file_put_contents($fixtureConfig, '<?php return "invalid";');
try {
$fixtureClass::publicPolicy(123);
migrationExpect(false, 'non-array config must fail clearly');
} catch (\UnexpectedValueException $error) {
migrationExpect(str_contains($error->getMessage(), 'doctor_web_consultation'), 'non-array config has a clear error');
}
} finally {
foreach ([$fixtureConfig, $fixtureService] as $file) {
if (is_file($file)) unlink($file);
}
foreach ([$fixtureServiceDir, dirname($fixtureServiceDir), $fixtureRoot . '/app', $fixtureConfigDir, $fixtureRoot] as $directory) {
if (is_dir($directory)) rmdir($directory);
}
}
echo "Doctor web consultation policy: {$assertions} assertions passed\n";
}