feat: Implement permission gate for appointment and billing controllers
- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController. - Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments. - Updated existing methods in both controllers to utilize the new permission checks. - Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management. - Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
This commit is contained in:
@@ -52,7 +52,15 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
|
||||
$this->assertSame(1, $body['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testSecretaryWithNoAssignmentSeesNothing(): void
|
||||
/**
|
||||
* منشیِ بدون رابطهٔ فعال دیگر لیست خالی نمیگیرد، ۴۰۳ میگیرد.
|
||||
*
|
||||
* تا ۲۰۲۶-۰۸-۰۷ این مسیر گِیت مجوز نداشت و پاسخ خالی از فیلترِ خودِ کوئری
|
||||
* میآمد. با گِیتِ `appointments.view` (یافتهٔ ۸ آدیت)، `SecretaryAccessChecker`
|
||||
* برای منشیِ بیرابطه `can()=false` میدهد — همان رفتار fail-closed مستندِ
|
||||
* `SecretaryPermissionChecker`. «خالیِ خاموش» با «اجازه نداری» یکی نیست.
|
||||
*/
|
||||
public function testSecretaryWithNoAssignmentIsDenied(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
@@ -73,7 +81,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
|
||||
|
||||
// بدون رابطهی فعال → resolveSecretaryFilter=null → لیست خالی
|
||||
$this->assertSame(0, $body['meta']['totalRecords']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
$this->assertSame('ERR_FORBIDDEN_001', $body['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,39 +70,102 @@ class ApiLeastPrivilegeTest extends ApiTestCase
|
||||
// رفتار مستند: بدون مجوز فقط قابلیتهای پلن میآید، نه وضعیت/تاریخ اشتراک.
|
||||
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
|
||||
'app_subscription_subscription_my' => 'نسخهٔ کاهشیافتهٔ عمدی',
|
||||
// فهرست پزشکانِ تخصیصیافته به همین منشی — تستش
|
||||
// SecretaryResourceEnforcementTest::testDoctorListReturnsOnlyAssignedDoctors
|
||||
'app_appointment_myappointments_myclinicdoctors' => 'فقط پزشکانِ تخصیصیافته به خودِ منشی',
|
||||
// نوبتهای خودِ کاربر بهعنوان بیمار (`a.user = خودِ او`)، نه دادهٔ محیط.
|
||||
// مصرفکنندهاش داشبورد بیمار در nobat724_front است. گِیتِ appointments.view
|
||||
// اینجا یعنی منشیای که جایی بیمار است نوبتهای شخصیاش را نبیند.
|
||||
'app_appointment_appointment_listbyuser' => 'نوبتهای خودِ کاربر بهعنوان بیمار',
|
||||
|
||||
// کاتالوگهای سراسری: `findActive()` بدون هیچ فیلترِ محیط. همردهٔ
|
||||
// specialties و tags بالا؛ تنها فرقشان این است که پشت firewall نشستهاند.
|
||||
// گِیتزدنشان یک مجوز را با نبودِ مجوزِ دیگری میشکند: منشیِ دارای
|
||||
// patients.create فرمِ ثبت بیمار را با کمبوی خالیِ بیمه میگیرد.
|
||||
'app_doctorservice_doctorservice_list' => 'کاتالوگ سراسری خدمات پزشک — دادهٔ مرجع',
|
||||
'app_insurance_insurance_list' => 'کاتالوگ سراسری بیمهها — دادهٔ مرجع',
|
||||
];
|
||||
|
||||
/**
|
||||
* بدهیِ شناختهشده — روتهایی که **باید** گِیت داشته باشند و ندارند.
|
||||
*
|
||||
* اینها در آدیت ۲۰۲۶-۰۸-۰۷ کشف شدند و عمداً همان جلسه رفع **نشدند**: هر سه
|
||||
* کنترلرشان (`BillingController`، `MyAppointmentsController`،
|
||||
* `DoctorServiceController`) هیچ checker مجوزی تزریقشده ندارند، و بستنشان
|
||||
* بدون دانستن نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد.
|
||||
* **از ۲۰۲۶-۰۸-۰۸ خالی است.** ده ردیفِ اولیهاش در آدیت ۲۰۲۶-۰۸-۰۷ ثبت شده
|
||||
* بود؛ هفتتایشان گِیت گرفتند و سهتا پس از خواندنِ کد مثبت کاذب درآمدند و به
|
||||
* `ALLOWED_200` رفتند.
|
||||
*
|
||||
* در DB تست، tenant خالی است پس پاسخشان خالی میآید؛ در tenant واقعی دادهٔ
|
||||
* واقعی میدهند. نبودِ نشت در تست، دلیلِ امنبودن نیست.
|
||||
*
|
||||
* نقشِ این فهرست مثل baseline است: تست اجازه میدهد اینها ۲۰۰ بدهند، ولی
|
||||
* **بزرگترشدنش** را نمیپذیرد. هر روتِ تازهای که بدون گِیت اضافه شود، تست را
|
||||
* قرمز میکند. حذف هر ردیف از اینجا یعنی آن گَپ بسته شد.
|
||||
* فهرست عمداً باقی میماند: نقشش baseline است، و خالیبودنش یعنی «همین حالا
|
||||
* بدهیای نداریم»، نه «این مکانیزم لازم نیست». ردیفِ تازه فقط با تصمیمِ آگاهانه
|
||||
* اضافه شود.
|
||||
*
|
||||
* @var array<string, string> route name => منبعِ مجوزی که باید enforce شود
|
||||
*/
|
||||
private const KNOWN_GAPS = [
|
||||
'app_appointment_appointment_listbyuser' => 'appointments.view',
|
||||
'app_appointment_myappointments_myappointments' => 'appointments.view',
|
||||
'app_appointment_myappointments_todaystats' => 'appointments.view',
|
||||
'app_billing_billing_listpayments' => 'payments.view',
|
||||
'app_billing_billing_paymentssummary' => 'payments.view',
|
||||
'app_billing_billing_listclaims' => 'payments.view',
|
||||
'app_billing_billing_claimsbypatient' => 'payments.view',
|
||||
'app_billing_billing_insurancedebt' => 'payments.view',
|
||||
'app_doctorservice_doctorservice_list' => 'services.view',
|
||||
'app_insurance_insurance_list' => 'insurances.view',
|
||||
private const KNOWN_GAPS = [];
|
||||
|
||||
/**
|
||||
* روتهای نوشتنیای که منشیِ بیمجوز حق دارد از گِیت ردشان کند.
|
||||
*
|
||||
* سه دسته: (۱) اندپوینتِ عمومی یا پیشازلاگین؛ (۲) اکشنی روی دادهٔ خودِ کاربر که
|
||||
* منبعی در `PermissionCatalog` ندارد؛ (۳) رفتارِ عمدیِ مستند.
|
||||
*
|
||||
* @var array<string, string> route name => دلیل
|
||||
*/
|
||||
private const ALLOWED_WRITE = [
|
||||
// ── پیشازلاگین: اصلاً نقشی وجود ندارد که مجوز داشته باشد ─────────────
|
||||
'app_auth_auth_login' => 'لاگین',
|
||||
'app_auth_auth_sendcode' => 'ارسال کد تأیید',
|
||||
'app_auth_auth_verifycode' => 'بررسی کد تأیید',
|
||||
'app_auth_auth_register' => 'ثبتنام',
|
||||
'app_auth_auth_otplogin' => 'ورود با رمز یکبارمصرف',
|
||||
'app_auth_auth_resetpassword' => 'بازیابی رمز',
|
||||
'app_auth_preregistration_submit' => 'پیشثبتنام عمومی',
|
||||
'app_payment_payment_callback' => 'کالبک درگاه — بدون توکن فراخوانی میشود',
|
||||
'app_payment_payment_subscriptioncallback' => 'کالبک درگاه اشتراک',
|
||||
|
||||
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
|
||||
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
|
||||
'app_auth_auth_switchcontext' => 'سوییچ محیطِ خودِ کاربر',
|
||||
'app_auth_notificationmobile_requestotp' => 'موبایل اعلانِ خودِ کاربر',
|
||||
'app_auth_notificationmobile_verify' => 'موبایل اعلانِ خودِ کاربر',
|
||||
'app_auth_notificationmobile_remove' => 'موبایل اعلانِ خودِ کاربر',
|
||||
'app_userprofile_userprofile_create' => 'پروفایل خودِ کاربر',
|
||||
'app_userprofile_userprofile_update' => 'پروفایل خودِ کاربر',
|
||||
'app_userprofile_userprofile_uploadavatar' => 'آواتار خودِ کاربر',
|
||||
'app_settlement_settlement_request' => 'تسویهٔ کیفپول خودِ کاربر',
|
||||
'app_secretary_secretary_addiban' => 'شبای خودِ منشی',
|
||||
'app_secretary_secretary_removeiban' => 'شبای خودِ منشی',
|
||||
'app_doctor_doctor_create' => 'ساخت پروفایل پزشکِ خودِ کاربر — ۴۰۹ اگر قبلاً دارد',
|
||||
'app_clinic_clinic_create' => 'ساخت کلینیکِ خودِ کاربر',
|
||||
'app_doctor_doctorclaim_claim' => 'ادعای مالکیتِ پروفایل پزشک توسط خودِ فرد',
|
||||
'app_clinicinvitation_clinicinvitation_acceptinvitation' => 'پذیرشِ دعوتِ خودِ فرد',
|
||||
'app_clinicinvitation_clinicinvitation_rejectinvitation' => 'ردِ دعوتِ خودِ فرد',
|
||||
|
||||
// ── اکشن بیمار روی محتوای عمومی ──────────────────────────────────────
|
||||
'app_rating_rating_rate' => 'امتیازدهی بیمار',
|
||||
'app_rating_rating_createcomment' => 'ثبت نظر بیمار',
|
||||
'app_rating_rating_deletecomment' => 'حذف نظرِ خودِ فرد',
|
||||
'app_rating_rating_togglelike' => 'لایک بیمار',
|
||||
|
||||
// ── جریان رزرو عمومی: مصرفکنندهاش nobat724_front است، نه پنل ────────
|
||||
'app_appointment_appointment_book' => 'رزرو نوبت توسط خودِ بیمار',
|
||||
'appointment_availability' => 'وقتهای آزاد — ورودی رزرو عمومی',
|
||||
'appointment_hold_create' => 'نگهداشتن موقت اسلات در جریان رزرو',
|
||||
'appointment_hold_release' => 'آزادکردن اسلاتِ نگهداشتهشده',
|
||||
'appointment_confirm' => 'تأیید نهایی رزرو عمومی',
|
||||
'appointment_rebook' => 'رزرو مجدد توسط خودِ بیمار',
|
||||
'pricing_quote' => 'محاسبهٔ قیمت پیش از رزرو',
|
||||
'app_payment_payment_initiateappointment' => 'پرداختِ نوبتِ خودِ بیمار',
|
||||
|
||||
// ── رفتار عمدیِ مستند ────────────────────────────────────────────────
|
||||
// حذف سرویس/بخش اصلاً ممکن نیست و کنترلر بیقیدوشرط ۴۰۹ میدهد، پس هرگز
|
||||
// به لایهٔ مجوز نمیرسد. دلیلش در خودِ ClinicServiceController نوشته شده:
|
||||
// نوبت و فاکتور و سوابق پرداخت به سرویس ارجاع دارند.
|
||||
'app_clinicservice_clinicservice_deleteitem' => 'حذف ممنوع — همیشه ۴۰۹',
|
||||
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
|
||||
|
||||
// ── گِیت دارند ولی هدفشان از بدنه میآید، نه از path ──────────────────
|
||||
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ میدهند. مثل روتهای
|
||||
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان میشد.
|
||||
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
||||
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
||||
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
||||
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده میشود',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -180,6 +243,107 @@ class ApiLeastPrivilegeTest extends ApiTestCase
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* مقدارِ جایگزینِ یک path parameter — طوری که روت **match شود** ولی رکوردی
|
||||
* پیدا نشود.
|
||||
*
|
||||
* اگر مقدار با requirement نخواند، Symfony قبل از رسیدن به کنترلر ۴۰۴ میدهد و
|
||||
* تست بیدلیل قرمز میشود. پس alternationهای ساده (`submit|approve|…`) اولین
|
||||
* شاخهشان برداشته میشود، عددیها `999999999` میگیرند و بقیه uuidِ صفر.
|
||||
*/
|
||||
private static function sampleValueFor(string $name, ?string $requirement): string
|
||||
{
|
||||
$nilUuid = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
if (preg_match('/^[a-z_]+(\|[a-z_]+)+$/i', (string) $requirement)) {
|
||||
return explode('|', $requirement)[0];
|
||||
}
|
||||
if (preg_match('/^(\\\\d\+?|\[0-9\]\+)$/', (string) $requirement)) {
|
||||
return '999999999';
|
||||
}
|
||||
// بدون requirement، نامِ پارامتر تنها سرنخِ نوعِ آرگومانِ کنترلر است. uuid
|
||||
// فرستادن به `int $id` قبل از رسیدن به کنترلر ۵۰۰ میدهد، نه ۴۰۴.
|
||||
if ($name === 'id' || str_ends_with($name, 'Id') || str_ends_with($name, '_id')) {
|
||||
return '999999999';
|
||||
}
|
||||
|
||||
return $nilUuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* روتِ نوشتنی با پارامترهای جایگزینشده؛ null اگر پارامتری داشت که نمیشد
|
||||
* مقدارِ مطمئنی برایش ساخت.
|
||||
*/
|
||||
private static function probePath(\Symfony\Component\Routing\Route $route): string
|
||||
{
|
||||
$path = $route->getPath();
|
||||
|
||||
return preg_replace_callback(
|
||||
'/\{!?(\w+)\}/',
|
||||
static fn(array $m) => self::sampleValueFor($m[1], $route->getRequirement($m[1])),
|
||||
$path,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* قرینهٔ تستِ بالا برای `POST`/`PUT`/`PATCH`/`DELETE`.
|
||||
*
|
||||
* بدنهٔ معتبر لازم نیست و عمداً فرستاده نمیشود. استدلالش همان یافتهٔ ۱ آدیت
|
||||
* ۲۰۲۶-۰۸-۰۷ است، وارونه: آنجا `422` شاهدِ **عبور** از لایهٔ authorization بود،
|
||||
* چون کد خطا از داخلِ writer میآمد. پس `2xx` یا `422` برای منشیِ بیمجوز یعنی
|
||||
* گِیت نخورده و فقط اعتبارسنجی جلویش را گرفته.
|
||||
*
|
||||
* قاعده دو سطحی است، چون `404` دو معنای متفاوت دارد:
|
||||
*
|
||||
* - **روتِ بدون path parameter** باید دقیقاً `403` بدهد. چیزی برای واکشی وجود
|
||||
* ندارد، پس هیچ توجیهی برای پاسخِ دیگر نیست.
|
||||
* - **روتِ پارامتردار** `403` یا `404` هر دو قبول است. uuidِ ناموجود میفرستیم و
|
||||
* بیشترِ کنترلرهای این پروژه اول رکورد را واکشی میکنند و بعد مجوز را
|
||||
* میسنجند، پس `404` میدهند بیآنکه بیگِیت باشند.
|
||||
*
|
||||
* محدودیتِ صادقانهٔ سطح دوم: با `404` نمیشود «گِیت بعد از واکشی» را از «اصلاً
|
||||
* گِیت ندارد» تفکیک کرد. تفکیکش رکوردِ واقعی در tenantِ همین منشی میخواهد،
|
||||
* یعنی fixture به ازای هر روت. فهرستِ ۳۴ روتی که گِیتشان بعد از واکشی است در
|
||||
* `docs/security/AUDIT-2026-08-07.md` (یافتهٔ ۱۰) ثبت شده تا بدهی گم نشود.
|
||||
*/
|
||||
public function testNoApiWriteRouteSkipsItsPermissionGate(): void
|
||||
{
|
||||
$secretary = $this->makePowerlessSecretary();
|
||||
$router = self::getContainer()->get('router');
|
||||
|
||||
$ungated = [];
|
||||
foreach ($router->getRouteCollection() as $name => $route) {
|
||||
if (!str_starts_with($route->getPath(), '/api/')) {
|
||||
continue;
|
||||
}
|
||||
$writeMethods = array_values(array_intersect(
|
||||
$route->getMethods(),
|
||||
['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
));
|
||||
if ($writeMethods === [] || isset(self::ALLOWED_WRITE[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasPathParam = str_contains($route->getPath(), '{');
|
||||
$probe = self::probePath($route);
|
||||
|
||||
$this->authJson($writeMethods[0], $probe, $secretary);
|
||||
$code = $this->responseCode();
|
||||
|
||||
$accepted = $hasPathParam ? in_array($code, [403, 404], true) : $code === 403;
|
||||
if (!$accepted) {
|
||||
$ungated[] = sprintf('%s %s %s → %d', $name, $writeMethods[0], $probe, $code);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $ungated, sprintf(
|
||||
"این روتهای نوشتنی به منشیِ بدونِ هیچ مجوزی گِیت مجوز را رد کردند.\n"
|
||||
. "روتِ بدون parameter باید ۴۰۳ بدهد؛ روتِ پارامتردار ۴۰۳ یا ۴۰۴.\n"
|
||||
. "اگر عمدیاند، با دلیل به ALLOWED_WRITE برو.\n%s",
|
||||
implode("\n", $ungated),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* بدهی نباید بیصدا بماند: بهمحض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
|
||||
* تست قرمز میشود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\CatalogCategoryInclude;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\SessionAreaRecord;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentCaseArea;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
|
||||
/**
|
||||
* IDOR بینمحیطی برای نقش پرسنل.
|
||||
*
|
||||
* آدیت ۲۰۲۶-۰۸-۰۷ این سناریو را در بخش «محدودیت پوشش» باز گذاشت: در DB زنده تنها
|
||||
* یک `ClinicStaff` وجود داشت، پس «پرسنل کلینیک الف روی جلسهٔ کلینیک ب» هرگز اجرا
|
||||
* نشد. اینجا هر دو محیط در fixture ساخته میشوند تا آن حفرهٔ پوشش دائمی بسته شود.
|
||||
*
|
||||
* انتظار در همهٔ پروبها `404` است نه `403`: تفاوت «یافت نشد» و «اجازه نداری»
|
||||
* خودش یک enumeration oracle است و به مهاجم میگوید کدام uuid در سیستم وجود دارد.
|
||||
*/
|
||||
class StaffCrossTenantTest extends ApiTestCase
|
||||
{
|
||||
private const LASER_SCHEMA = [
|
||||
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
|
||||
];
|
||||
|
||||
/**
|
||||
* یک محیطِ کاملِ مستقل: کلینیک، پرسنل، پروتکل دوجلسهای، پرونده و جلسهٔ فعال.
|
||||
*
|
||||
* @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession}
|
||||
*/
|
||||
private function tenant(string $clinicName): array
|
||||
{
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName($clinicName);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
|
||||
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر');
|
||||
$type->setFieldSchema(self::LASER_SCHEMA);
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = new ClinicResource($address, $type, 'Diode Laser');
|
||||
$resource->setSupervisor($doctor);
|
||||
$this->em->persist($resource);
|
||||
|
||||
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
||||
$this->em->persist($section);
|
||||
|
||||
$parent = new CatalogCategory('clinic', (int) $clinic->getId(), 'توتال');
|
||||
$this->em->persist($parent);
|
||||
$area = new CatalogCategory('clinic', (int) $clinic->getId(), 'زیر بغل');
|
||||
$this->em->persist($area);
|
||||
$this->em->flush();
|
||||
$this->em->persist(new CatalogCategoryInclude($parent, $area));
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر توتال', 10_000_000);
|
||||
$service->setCatalogCategory($parent)->setDurationMinutes(30);
|
||||
$this->em->persist($service);
|
||||
|
||||
$staffUser = $this->createUser(['ROLE_USER', 'ROLE_STAFF']);
|
||||
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
|
||||
$staff->setUser($staffUser);
|
||||
$this->em->persist($staff);
|
||||
|
||||
$protocol = new TreatmentProtocol($service);
|
||||
$this->em->persist($protocol);
|
||||
$protocol->replaceSteps([
|
||||
new TreatmentProtocolStep($protocol, 1, 0),
|
||||
new TreatmentProtocolStep($protocol, 2, 30),
|
||||
]);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
|
||||
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
||||
$case->addArea(new TreatmentCaseArea($case, $area, 0));
|
||||
|
||||
$appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic);
|
||||
$appointment->setResource($resource);
|
||||
$appointment->setStaff($staff);
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($appointment);
|
||||
|
||||
$session = new TreatmentSession($case, 1);
|
||||
$session->attachAppointment($appointment);
|
||||
$case->addSession($session);
|
||||
$case->addSession(new TreatmentSession($case, 2));
|
||||
$this->em->persist($case);
|
||||
$this->em->flush();
|
||||
|
||||
// نقش بهتنهایی محیط نمیسازد؛ پرسنل باید محیط فعالش ست شده باشد.
|
||||
static::getContainer()->get(UserActiveContextRepository::class)
|
||||
->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
||||
|
||||
return ['staffUser' => $staffUser, 'staff' => $staff, 'session' => $session];
|
||||
}
|
||||
|
||||
/** تنها ناحیهٔ یک جلسهٔ شروعشده. */
|
||||
private function areaRecord(TreatmentSession $session): SessionAreaRecord
|
||||
{
|
||||
$records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]);
|
||||
self::assertNotSame([], $records, 'جلسه باید دستکم یک ناحیه داشته باشد');
|
||||
|
||||
return $records[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* شاهد مثبت: بدون این، «۴۰۴ در همهجا» میتوانست معنیِ «مسیر اصلاً کار نمیکند»
|
||||
* بدهد و تستِ جداسازی بیاثر شود.
|
||||
*/
|
||||
public function testStaffCanStartASessionInsideTheirOwnTenant(): void
|
||||
{
|
||||
$a = $this->tenant('کلینیک الف');
|
||||
|
||||
$body = $this->authJson(
|
||||
'POST',
|
||||
'/api/v1/dashboard/staff/treatment-session/' . $a['session']->getUuid() . '/start',
|
||||
$a['staffUser'],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame($a['staff']->getUuid(), $body['data']['performed_by']['uuid']);
|
||||
}
|
||||
|
||||
/** خواندن و نوشتنِ جلسهٔ محیط دیگر — هر چهار مسیر باید ۴۰۴ بدهند. */
|
||||
public function testStaffCannotTouchASessionOfAnotherTenant(): void
|
||||
{
|
||||
$a = $this->tenant('کلینیک الف');
|
||||
$b = $this->tenant('کلینیک ب');
|
||||
|
||||
$victim = $b['session']->getUuid();
|
||||
$probes = [
|
||||
['GET', "/api/v1/dashboard/staff/treatment-session/{$victim}"],
|
||||
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/start"],
|
||||
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/finish"],
|
||||
];
|
||||
|
||||
foreach ($probes as [$method, $uri]) {
|
||||
$body = $this->authJson($method, $uri, $a['staffUser']);
|
||||
|
||||
self::assertSame(404, $this->responseCode(), "{$method} {$uri}");
|
||||
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], "{$method} {$uri}");
|
||||
}
|
||||
}
|
||||
|
||||
/** ناحیهٔ جلسهٔ محیط دیگر — همان قاعده یک لایه پایینتر در aggregate. */
|
||||
public function testStaffCannotTouchASessionAreaOfAnotherTenant(): void
|
||||
{
|
||||
$a = $this->tenant('کلینیک الف');
|
||||
$b = $this->tenant('کلینیک ب');
|
||||
|
||||
// ناحیهها هنگام شروعِ جلسه ساخته میشوند، پس اول محیط ب جلسهاش را شروع میکند.
|
||||
$this->authJson(
|
||||
'POST',
|
||||
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid() . '/start',
|
||||
$b['staffUser'],
|
||||
);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$victim = $this->areaRecord($b['session'])->getUuid();
|
||||
|
||||
foreach (['start', 'complete', 'skip', 'reopen'] as $action) {
|
||||
$uri = "/api/v1/dashboard/staff/session-area/{$victim}/{$action}";
|
||||
$body = $this->authJson('POST', $uri, $a['staffUser'], ['parameters' => ['shots' => 10]]);
|
||||
|
||||
self::assertSame(404, $this->responseCode(), $uri);
|
||||
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], $uri);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* مرزی: uuidِ اصلاً ناموجود باید همان ۴۰۴ را بدهد که uuidِ محیطِ دیگر میدهد.
|
||||
* اگر این دو فرق کنند، همان تفاوت به مهاجم میگوید کدام uuid واقعی است.
|
||||
*/
|
||||
public function testUnknownUuidIsIndistinguishableFromAnotherTenantsUuid(): void
|
||||
{
|
||||
$a = $this->tenant('کلینیک الف');
|
||||
$b = $this->tenant('کلینیک ب');
|
||||
|
||||
$nil = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
$unknown = $this->authJson('GET', "/api/v1/dashboard/staff/treatment-session/{$nil}", $a['staffUser']);
|
||||
$unknownCode = $this->responseCode();
|
||||
|
||||
$foreign = $this->authJson(
|
||||
'GET',
|
||||
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid(),
|
||||
$a['staffUser'],
|
||||
);
|
||||
|
||||
self::assertSame($unknownCode, $this->responseCode());
|
||||
self::assertSame($unknown['errors'][0]['code'], $foreign['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user