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, 'register_shutdown_function('), 'IM archive sync authorizes the diagnosis before queuing background work' ); diagnosisWorkspaceAuthExpect( substr_count($imChatLogicMethod, 'attachDiagnosisIdToImMessages(') >= 3 && 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";