233 lines
16 KiB
PHP
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";
|
|
}
|