Files
zyt/server/tests/DiagnosisWorkspaceRowAuthorizationTest.php
T
2026-09-09 15:47:48 +08:00

280 lines
13 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\controller\doctor\AppointmentController;
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\controller\tcm\PrescriptionController;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
function diagnosisWorkspaceAuthExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function diagnosisWorkspaceMethodSource(ReflectionMethod $method): string
{
$file = file($method->getFileName());
if (!is_array($file)) {
throw new RuntimeException('authorization method source is readable');
}
return implode('', array_slice(
$file,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
// Pure policy helpers are invoked directly so this security regression test never needs a real database.
$appointmentScope = (new ReflectionClass(AppointmentLogic::class))
->getMethod('appointmentRowManageableForScope');
$filterPrescriptions = (new ReflectionClass(PrescriptionLogic::class))
->getMethod('filterViewablePrescriptions');
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 31, 41, 31, [1], null, false) === true,
'assigned doctor can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 31, [1], null, false) === false,
'doctor cannot open another doctor appointment row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 41, [2], null, false) === true,
'assigned assistant can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 999, 999, 1, [1, 2], [], true) === true,
'root keeps reception compatibility regardless of role and data scope'
);
$ownPrescription = [
'id' => 51,
'creator_id' => 7,
'assistant_id' => 0,
'is_shared' => 0,
'visible_role_ids' => '',
];
$otherPrescription = [
'id' => 52,
'creator_id' => 8,
'assistant_id' => 9,
'is_shared' => 0,
'visible_role_ids' => '',
];
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$ownPrescription], 7, []) === [$ownPrescription],
'visible prescription keeps the existing response row unchanged'
);
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$otherPrescription], 1, ['root' => 1]) === [$otherPrescription],
'root keeps prescription compatibility'
);
$diagnosisLogicSource = file_get_contents((new ReflectionClass(DiagnosisLogic::class))->getFileName());
$diagnosisAiLogicSource = file_get_contents((new ReflectionClass(DiagnosisAiLogic::class))->getFileName());
$myPatientLogicSource = file_get_contents((new ReflectionClass(MyPatientLogic::class))->getFileName());
$appointmentLogicSource = file_get_contents((new ReflectionClass(AppointmentLogic::class))->getFileName());
$prescriptionLogicSource = file_get_contents((new ReflectionClass(PrescriptionLogic::class))->getFileName());
$diagnosisControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'
);
$appointmentControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/doctor/AppointmentController.php'
);
$prescriptionControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/PrescriptionController.php'
);
$appointmentListsSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/lists/doctor/AppointmentLists.php'
);
$doctorNoteLogicSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/logic/doctor/DoctorNoteLogic.php'
);
foreach ([
$diagnosisLogicSource,
$diagnosisAiLogicSource,
$myPatientLogicSource,
$appointmentLogicSource,
$prescriptionLogicSource,
$diagnosisControllerSource,
$appointmentControllerSource,
$prescriptionControllerSource,
$appointmentListsSource,
$doctorNoteLogicSource,
] as $source) {
diagnosisWorkspaceAuthExpect(is_string($source), 'authorization source is readable');
}
$myPatientScopeMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(MyPatientLogic::class))->getMethod('applyScope')
);
$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis')
);
$diagnosisManageAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canManageDiagnosis')
);
$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis')
);
$prescriptionListMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionLogic::class))->getMethod('listByDiagnosis')
);
$trackingWindowMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('fetchTrackingWindow')
);
$trackingWindowControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('trackingWindow')
);
$imChatControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('getImChatMessages')
);
$imChatSyncControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('triggerImChatSync')
);
$imChatLogicMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('getImChatMessagesForDiagnosis')
);
$imChatOwnerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('attachDiagnosisIdToImMessages')
);
$doctorNotesControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('doctorNotes')
);
$addDoctorNoteControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('addDoctorNote')
);
$receptionMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentLogic::class))->getMethod('reception')
);
$prescriptionControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionController::class))->getMethod('listByDiagnosis')
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, "'CAST(d.assistant_id AS UNSIGNED) = ' . \$adminId")
&& str_contains($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, 'scope_apt.doctor_id = {$adminId}'),
'diagnosis row policy keeps assistant assignment and doctor appointment ownership contracts'
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
&& str_contains($myPatientScopeMethod, 'DataScopeService::getVisibleAdminIds($adminId, $adminInfo)')
&& strpos($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
< strpos($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)'),
'DataScope ALL is reserved for team roles before ordinary doctor and assistant self-relations'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisReadonlyAuthMethod, "Diagnosis::where('id', \$diagnosisId)")
&& str_contains($diagnosisReadonlyAuthMethod, "in_array(2, \$roleIds, true)")
&& str_contains($diagnosisReadonlyAuthMethod, "\$query->where('assistant_id', \$adminId)")
&& str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'readonly diagnosis authorization matches diagnosis-list visibility instead of my-patient ownership'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisManageAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'diagnosis write authorization keeps the canonical my-patient ownership policy'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
< strpos($diagnosisAiAuthMethod, 'DiagnosisLogic::detail(')
&& !str_contains($diagnosisAiAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'AI diagnosis authorization reuses the canonical row policy before loading case details'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
&& strpos($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
< strpos($trackingWindowControllerMethod, 'DiagnosisLogic::fetchTrackingWindow('),
'trackingWindow authorizes the diagnosis before reading tracking records'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
&& strpos($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
< strpos($trackingWindowMethod, "'blood_records'"),
'trackingWindow returns the authorized diagnosis id at the response top level'
);
diagnosisWorkspaceAuthExpect(
str_contains($imChatControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($imChatControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($imChatControllerMethod, 'DiagnosisLogic::getImChatMessagesForDiagnosis('),
'IM history authorizes the diagnosis before reading patient messages'
);
diagnosisWorkspaceAuthExpect(
str_contains($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($imChatSyncControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($imChatSyncControllerMethod, 'DiagnosisLogic::syncImChatArchiveStep('),
'IM archive sync authorizes the diagnosis before syncing any cloud page'
);
diagnosisWorkspaceAuthExpect(
str_contains($imChatLogicMethod, 'attachDiagnosisIdToImMessages(')
&& str_contains($imChatOwnerMethod, "\$row['diagnosis_id'] = \$diagnosisId"),
'every IM response row declares its parent diagnosis for client-side ownership checks'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($doctorNotesControllerMethod, 'DoctorNoteLogic::getByDiagnosis('),
'doctorNotes authorizes the diagnosis before reading notes'
);
diagnosisWorkspaceAuthExpect(
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
< strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('),
'addDoctorNote keeps the stricter ownership check before writing any note data'
);
diagnosisWorkspaceAuthExpect(
str_contains($receptionMethod, 'appointmentRowManageableByAdmin(')
&& strpos($receptionMethod, 'appointmentRowManageableByAdmin(')
< strpos($receptionMethod, '$appointment = self::detail($params);'),
'reception authorizes the appointment before loading its detail DTO'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"),
'listByDiagnosis uses read visibility before the first prescription SQL query'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)')
&& str_contains(
$prescriptionControllerMethod,
'PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)'
)
&& str_contains($prescriptionControllerMethod, "PrescriptionLogic::getError() !== ''"),
'listByDiagnosis keeps child visibility filtering and surfaces parent authorization failure'
);
diagnosisWorkspaceAuthExpect(
str_contains($appointmentListsSource, 'u.patient_id AS source_patient_id'),
'appointment DTO exposes the source patient id separately from the diagnosis id'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNoteLogicSource, 'normalizeNewAttachmentPaths(')
&& str_contains($doctorNoteLogicSource, "\$domainHost === \$urlHost")
&& str_contains($doctorNoteLogicSource, "\$domainPort === \$urlPort")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$urlPath, \$domainPath . '/')")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$path, '//')"),
'new note attachments require an exact configured storage origin and path boundary'
);
diagnosisWorkspaceAuthExpect(
substr_count($diagnosisControllerSource, '诊单不存在或无权访问') >= 2
&& str_contains($appointmentControllerSource, '预约记录不存在或无权访问')
&& str_contains($appointmentControllerSource, '诊单不存在或无权访问'),
'missing and forbidden child-resource lookups share non-enumerating errors'
);
echo "Diagnosis workspace row authorization: OK\n";