From d2f4b5c4284cd8b92b56f60e95d65d71b03e7a63 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 28 Jul 2026 13:39:59 +0330 Subject: [PATCH] fix(tenant): check the environment wherever a uuid comes from the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot reach. Measuring first — as the plan required — moved the target: all 22 children and their 20 repositories were already sound. Every list query anchors on its root, and ServiceItemRepository even joins service_sections and filters on the pair by hand. A repository-level guard would have found nothing. The real exposure was one layer up. Where a uuid arrives from a request body or query string, the entity it names is loaded by uuid alone, and the filter is no help: aggregate children have no tenant column, and a panel user who never chose an environment is not filtered at all. Three leaks, each proven by removing the fix and watching the new tests go red: - GET /api/v1/appointment-service-slots accepted service_item_uuids from any environment. Existence, bookable state and duration leaked through the error messages and the returned slots. The booking path in the same controller had guarded this since it was written; the slot path never did. - POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid, staff_uuid and the service list without any check, and persisted them onto the appointment. A write, not just a read. - PatientService did the same in all three of its loops — pricing, session create, session update — so another environment's service price entered the invoice and its SessionService row was stored, staff included. TenantOwnershipChecker is the single place that answers "does this belong to the current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now delegates that pair to its section: an aggregate child exposing the tenant it inherits. An entity that exposes no pair throws rather than returning false — silence here builds an always-closed guard, which is its own bug. TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its place immediately: the first run found more sites than the manual grep had, and reviewing them turned up the third PatientService loop. StaffController looked unguarded until read properly — ownsStaff sits two lines below the null check. One assertion was wrong before it was right: the create-path test read `$session['services'] ?? []`, which passes vacuously. It now counts the stored rows through the repository, and fails without the fix. Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/tenancy.md | 30 +++- .../Controller/AppointmentController.php | 7 + .../Controller/MyAppointmentsController.php | 15 +- src/ClinicService/Entity/ServiceItem.php | 9 + src/Patient/Service/PatientService.php | 23 ++- src/Shared/Tenant/TenantOwnershipChecker.php | 68 +++++++ .../ServiceModeSectionDurationTest.php | 159 +++++++++++++++++ tests/Patient/SessionServiceTenantTest.php | 167 ++++++++++++++++++ tests/Shared/TenantLookupInventoryTest.php | 106 +++++++++++ tests/Shared/TenantOwnershipCheckerTest.php | 142 +++++++++++++++ 10 files changed, 718 insertions(+), 8 deletions(-) create mode 100644 src/Shared/Tenant/TenantOwnershipChecker.php create mode 100644 tests/Patient/SessionServiceTenantTest.php create mode 100644 tests/Shared/TenantLookupInventoryTest.php create mode 100644 tests/Shared/TenantOwnershipCheckerTest.php diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index b3c89688..2de200e3 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -100,7 +100,31 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده ### ⚠️ فرزندان aggregate تور ایمنی ندارند -فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. +فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. + +فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ `ServiceItem` این کار را با delegate به `ServiceSection` انجام می‌دهد. + +### uuid از درخواست — خطرناک‌ترین الگو + +سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: + +| مسیر | چه بود | +|---|---| +| `GET /api/v1/appointment-service-slots` | با uuid سرویسِ محیط دیگر، وجود/فعال‌بودن/مدتش لو می‌رفت و اسلات‌ها با آن محاسبه می‌شد | +| `POST /api/v1/my/appointment` | بخش/سرویس/پرسنلِ محیط دیگر به نوبت **چسبانده و ذخیره** می‌شد | +| `POST/PATCH` مراجعه | قیمتِ سرویسِ محیط دیگر وارد **فاکتور** می‌شد و `SessionService` با آن ذخیره می‌ماند | + +`App\Shared\Tenant\TenantOwnershipChecker` نقطهٔ واحد این بررسی است: + +```php +$this->tenantOwnership->belongsTo($context, $entity); // با EntityContext +$this->tenantOwnership->belongsToPair($type, $id, $entity); // وقتی جفت اسکالر است +$this->tenantOwnership->allBelongTo($context, $entities); // یک بیگانه = رد کل فهرست +``` + +موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است. + +`TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### بدهی باقی‌مانده @@ -161,3 +185,7 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Shared/TenantSchemaCoverageTest.php` | هیچ entity طبقه‌بندی‌نشده نمی‌ماند | | `tests/Appointment/BookingTenantTest.php` | نوبت در محیط درست ثبت می‌شود | | `tests/Secretary/SecretaryMultiClinicScopeTest.php` | یک منشی، یک پزشک، چند کلینیک | +| `tests/Shared/TenantOwnershipCheckerTest.php` | خودِ checker: null، محیط حل‌نشده، نوعِ متفاوت با شناسهٔ یکسان | +| `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده | +| `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | +| `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index f5018e15..ce155dcd 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -207,6 +207,13 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); } + // پیش از هر بررسی دیگری: سرویس باید مالِ همین محیط باشد. مسیر ثبت نوبت همین + // گارد را دارد و این مسیر نداشت، پس با uuid سرویسِ محیط دیگر می‌شد وجود، + // فعال‌بودن و مدتش را از پیام‌های خطا و اسلات‌های برگشتی استنتاج کرد. + if (($err = $this->assertServicesMatchContext($uuids, $doctor, $clinic)) !== null) { + return $err; + } + // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes $overrides = (array) $request->query->all('durations'); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 65aff488..df3a444a 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -45,6 +45,7 @@ class MyAppointmentsController extends BaseController private readonly \App\Auth\Repository\UserRepository $userRepo, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly VisitPriceRequirementResolver $visitPriceResolver, + private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, ) {} /** @@ -187,7 +188,12 @@ class MyAppointmentsController extends BaseController $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); if ($locationId !== null) $appointment->setAddressId($locationId); - // Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422. + // بخش/سرویس/پرسنل با uuid از خودِ درخواست می‌آیند و TenantFilter پوششان + // نمی‌دهد (سرویس فرزند aggregate است، و کاربرِ بدون محیطِ انتخاب‌شده اصلاً + // فیلتر نمی‌خورد). بدون این بررسی، نوبت با بخش/سرویس/پرسنلِ محیط دیگری + // ذخیره می‌شد — نه فقط خوانده، بلکه نوشته. + $bookingContext = EntityContext::forBooking($doctor, $bookingClinic); + foreach ([ 'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'], 'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'], @@ -198,11 +204,16 @@ class MyAppointmentsController extends BaseController continue; } $entity = $repo->findByUuid($value); - if ($entity === null) { + if (!$this->tenantOwnership->belongsTo($bookingContext, $entity)) { return $this->error(ErrorCodes::VALIDATION, $label . ' یافت نشد', 422); } $appointment->$setter($entity); } + + if (!$this->tenantOwnership->allBelongTo($bookingContext, $serviceItems)) { + return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids'); + } + // پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem). foreach ($serviceItems as $si) { $appointment->addServiceItem($si); diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 9b005e65..f06b22dd 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -152,6 +152,15 @@ class ServiceItem public function getId(): ?int { return $this->id; } public function getUuid(): string { return $this->uuid; } public function getSection(): ServiceSection { return $this->section; } + + /** + * محیط را از بخشِ خودش به ارث می‌برد — ستون tenant ندارد و TenantFilter پوششش + * نمی‌دهد. این دو getter همان جفت را در دسترس می‌گذارند تا مالکیتش با بقیهٔ + * موجودیت‌های محیط‌دار یکسان بررسی شود ({@see \App\Shared\Tenant\TenantOwnershipChecker}). + */ + public function getEntityType(): string { return $this->section->getEntityType(); } + + public function getEntityId(): int { return $this->section->getEntityId(); } public function getStaff(): ?ClinicStaff { return $this->staff; } public function getName(): string { return $this->name; } public function getPriceRials(): int { return $this->priceRials; } diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index ed70a3a9..6746398a 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -56,6 +56,7 @@ class PatientService private readonly EntityInsurancePricingRepository $pricingRepo, private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo, + private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, private readonly LoggerInterface $logger, ) {} @@ -311,11 +312,12 @@ class PatientService } } - // جمع‌آوری service items (با احتساب تعداد) + // جمع‌آوری service items (با احتساب تعداد). سرویسِ محیط دیگر نادیده گرفته + // می‌شود، وگرنه قیمتش وارد محاسبهٔ فاکتور این محیط می‌شد. $serviceItemsData = []; foreach (($data['services'] ?? []) as $svc) { $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item !== null) { + if ($this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { $qty = max(1, (int) ($svc['quantity'] ?? 1)); $serviceItemsData[] = ['item_id' => $item->getId(), 'price_rials' => $item->getPriceRials() * $qty]; } @@ -364,13 +366,18 @@ class PatientService $session->addConsumable($sc); } - // ثبت session services + // ثبت session services — سرویس و پرسنلِ محیط دیگر نادیده گرفته می‌شوند، + // همان قاعده‌ای که کالای مصرفی بالاتر رعایت می‌کند. foreach (($data['services'] ?? []) as $svc) { $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item === null) { + if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; } + $staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null; + if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) { + $staff = null; + } $qty = max(1, (int) ($svc['quantity'] ?? 1)); $ss = new SessionService($session, $item, $staff, $qty); $this->sessionServiceRepo->save($ss); @@ -411,9 +418,15 @@ class PatientService $session->getServices()->removeElement($old); } foreach (($data['services'] ?? []) as $svc) { + // سرویس و پرسنل با uuid از بدنهٔ درخواست می‌آیند و روی SessionService + // ذخیره می‌شوند؛ بدون این بررسی، دادهٔ محیط دیگری در مراجعه می‌نشست. $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item === null) { continue; } + if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; } + $staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null; + if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) { + $staff = null; + } $qty = max(1, (int) ($svc['quantity'] ?? 1)); $ss = new SessionService($session, $item, $staff, $qty); $this->sessionServiceRepo->save($ss); diff --git a/src/Shared/Tenant/TenantOwnershipChecker.php b/src/Shared/Tenant/TenantOwnershipChecker.php new file mode 100644 index 00000000..93d32f13 --- /dev/null +++ b/src/Shared/Tenant/TenantOwnershipChecker.php @@ -0,0 +1,68 @@ +isResolved()) { + return false; + } + + [$type, $id] = $context->toEntityPair(); + + return $this->belongsToPair($type, $id, $entity); + } + + /** + * برای فراخوانی‌هایی که جفت را از قبل به‌صورت اسکالر دارند و موجودیت محیط را در + * دست ندارند. ساختن EntityContext از اسکالر عمداً ممکن نیست: چنین contextی + * isClinic() درست می‌دهد ولی ->clinic تهی دارد و مصرف‌کننده را بی‌صدا می‌شکند. + */ + public function belongsToPair(string $entityType, int $entityId, ?object $entity): bool + { + if ($entity === null) { + return false; + } + + if (!method_exists($entity, 'getEntityType') || !method_exists($entity, 'getEntityId')) { + throw new \InvalidArgumentException(sprintf( + '%s does not expose a tenant pair; add getEntityType()/getEntityId() or delegate them to its aggregate root.', + $entity::class, + )); + } + + return $entity->getEntityType() === $entityType && $entity->getEntityId() === $entityId; + } + + /** + * یک بیگانه در فهرست، کل فهرست را رد می‌کند. + * + * @param iterable $entities + */ + public function allBelongTo(EntityContext $context, iterable $entities): bool + { + foreach ($entities as $entity) { + if (!$this->belongsTo($context, $entity)) { + return false; + } + } + + return true; + } +} diff --git a/tests/Appointment/ServiceModeSectionDurationTest.php b/tests/Appointment/ServiceModeSectionDurationTest.php index 204a6a8c..c4306ef6 100644 --- a/tests/Appointment/ServiceModeSectionDurationTest.php +++ b/tests/Appointment/ServiceModeSectionDurationTest.php @@ -119,4 +119,163 @@ class ServiceModeSectionDurationTest extends ApiTestCase $reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]); self::assertSame(30, $reloaded->getDurationMinutes()); } + + // ── جداسازی محیط روی سرویس‌های انتخابی ────────────────────────────────── + + /** + * سرویس‌ها فرزند aggregateاند (service_items → service_sections) و ستون محیط + * ندارند، پس TenantFilter پوششان نمی‌دهد. مسیر ثبت نوبت با + * assertServicesMatchContext محافظت می‌شد ولی مسیر محاسبهٔ اسلات نه — با uuid + * سرویسِ محیط دیگر می‌شد وجود، فعال‌بودن و مدتش را استنتاج کرد. + */ + public function testServiceSlotsRejectsAServiceFromAnotherEnvironment(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 45); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $foreign->getUuid(), + ), $owner); + + self::assertSame(422, $this->responseCode()); + } + + /** حتی وقتی یک سرویسِ خودی هم در فهرست است، سرویسِ بیگانه کل درخواست را رد می‌کند. */ + public function testOneForeignServiceInvalidatesTheWholeRequest(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 45); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $mine->getUuid(), $foreign->getUuid(), + ), $owner); + + self::assertSame(422, $this->responseCode()); + } + + /** ⚠️ مسیر سالم نباید بشکند: سرویس همان پزشک همچنان اسلات می‌دهد. */ + public function testServiceSlotsStillWorkForTheDoctorsOwnService(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + + $res = $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $mine->getUuid(), + ), $owner); + + self::assertSame(200, $this->responseCode()); + self::assertSame(30, $res['data']['total_duration_minutes']); + self::assertNotEmpty($res['data']['start_times']); + } + + /** + * نشتیِ نوشتنی: مسیر ثبت نوبت از پنل، بخش/سرویس/پرسنل را با uuid از بدنهٔ + * درخواست می‌گرفت و بدون بررسی محیط به نوبت می‌چسباند — یعنی دادهٔ محیط دیگری + * ذخیره می‌شد، نه فقط خوانده. + */ + public function testPanelBookingRejectsAServiceFromAnotherEnvironment(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuids' => [$foreign->getUuid()], + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** همان نشتی از مسیر service_item_uuid تکی (فیلد workflow کلینیک). */ + public function testPanelBookingRejectsAForeignServiceItemField(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuid' => $foreign->getUuid(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** و بخشِ محیط دیگر هم رد می‌شود. */ + public function testPanelBookingRejectsAForeignServiceSection(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreignSection = $this->service($stranger, 'سرویس بیگانه', 30)->getSection(); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_section_uuid' => $foreignSection->getUuid(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** ⚠️ مسیر سالم نباید بشکند: سرویس خودی همچنان ثبت می‌شود. */ + public function testPanelBookingStillAcceptsTheDoctorsOwnService(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuids' => [$mine->getUuid()], + 'service_item_uuid' => $mine->getUuid(), + ]); + + self::assertSame(201, $this->responseCode()); + } + + /** uuid کاملاً ناموجود هم همان ۴۲۲ را می‌گیرد — پیام «وجود ندارد» جدا لو نمی‌رود. */ + public function testUnknownServiceUuidIsIndistinguishableFromAForeignOne(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, '00000000-0000-4000-8000-000000000000', + ), $owner); + + self::assertSame(422, $this->responseCode()); + } } diff --git a/tests/Patient/SessionServiceTenantTest.php b/tests/Patient/SessionServiceTenantTest.php new file mode 100644 index 00000000..32e0e61a --- /dev/null +++ b/tests/Patient/SessionServiceTenantTest.php @@ -0,0 +1,167 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر مراجعه'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $doctor, $record]; + } + + private function serviceFor(Doctor $doctor, string $name, int $price): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); + $this->em->persist($section); + + $item = new ServiceItem($section, $name, $price); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function createSession(\App\Auth\Entity\User $owner, PatientRecord $record, array $body = []): array + { + $res = $this->authJson( + 'POST', + '/api/v1/patient/' . $record->getUuid() . '/session', + $owner, + $body + ['visit_price_rials' => 500_000], + ); + self::assertSame(201, $this->responseCode(), 'ساخت مراجعه باید موفق باشد'); + + return $res['data']; + } + + /** ✅ سرویس خودی وارد فاکتور می‌شود. */ + public function testOwnServiceIsPriced(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 900_000); + + $session = $this->createSession($owner, $record, [ + 'services' => [['service_item_uuid' => $mine->getUuid(), 'quantity' => 1]], + ]); + + self::assertSame(900_000, $session['services_total_rials'], 'سرویس خودی باید قیمت بخورد'); + } + + /** ❌ سرویسِ محیط دیگر نه در قیمت می‌آید نه ذخیره می‌شود. */ + public function testAnotherEnvironmentsServiceIsIgnoredOnCreate(): void + { + [$owner, , $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record, [ + 'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]], + ]); + + self::assertSame(0, $session['services_total_rials'], 'قیمت سرویس بیگانه نباید اضافه شود'); + + // قیمت‌گذاری و ذخیره‌سازی دو حلقهٔ جدا در PatientService هستند؛ این یکی + // ذخیره را می‌سنجد و مستقیم از دیتابیس می‌خواند تا به شکل پاسخ وابسته نباشد. + self::assertSame(0, $this->storedServiceCount($session['uuid']), 'نباید روی مراجعه ذخیره شود'); + } + + private function storedServiceCount(string $sessionUuid): int + { + $this->em->clear(); + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + + return $session === null ? -1 : $session->getServices()->count(); + } + + /** ❌ همان قاعده در ویرایش مراجعه. */ + public function testAnotherEnvironmentsServiceIsIgnoredOnUpdate(): void + { + [$owner, , $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]], + ]); + self::assertSame(200, $this->responseCode()); + + self::assertSame(0, $res['data']['services_total_rials'], 'سرویس بیگانه نباید در فاکتور بنشیند'); + self::assertSame([], $res['data']['services'] ?? [], 'و نباید روی مراجعه ذخیره شود'); + } + + /** ⚠️ مرزی: سرویس خودی می‌ماند حتی وقتی یک بیگانه هم در فهرست است. */ + public function testOwnServiceSurvivesAlongsideAForeignOne(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [ + ['service_item_uuid' => $mine->getUuid(), 'quantity' => 1], + ['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1], + ], + ]); + self::assertSame(200, $this->responseCode()); + + self::assertSame(300_000, $res['data']['services_total_rials'], 'فقط سرویس خودی باید بماند'); + self::assertCount(1, $res['data']['services'] ?? []); + } + + /** ❌ پرسنلِ محیط دیگر هم روی سرویس نمی‌نشیند. */ + public function testAnotherEnvironmentsStaffIsNotAttached(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000); + + $foreignStaff = new ClinicStaff('doctor', $stranger->getId(), 'پرسنل بیگانه'); + $this->em->persist($foreignStaff); + $this->em->flush(); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [[ + 'service_item_uuid' => $mine->getUuid(), + 'staff_uuid' => $foreignStaff->getUuid(), + 'quantity' => 1, + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $service = ($res['data']['services'] ?? [])[0] ?? null; + self::assertNotNull($service, 'سرویس خودی باید ثبت شود'); + self::assertNull($service['staff_uuid'] ?? null, 'پرسنل بیگانه نباید چسبیده باشد'); + } +} diff --git a/tests/Shared/TenantLookupInventoryTest.php b/tests/Shared/TenantLookupInventoryTest.php new file mode 100644 index 00000000..721238aa --- /dev/null +++ b/tests/Shared/TenantLookupInventoryTest.php @@ -0,0 +1,106 @@ + تعداد جست‌وجوی بازبینی‌شده. + * + * @var array + */ + private const REVIEWED = [ + // assertServicesMatchContext در هر سه مسیر (اسلات سرویسی، ثبت، و خودِ گارد) + 'src/Appointment/Controller/AppointmentController.php' => 3, + // TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها + 'src/Appointment/Controller/MyAppointmentsController.php' => 1, + // ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه + 'src/Billing/Controller/BillingController.php' => 3, + // ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل) + 'src/ClinicService/Controller/ClinicServiceController.php' => 10, + 'src/Discount/Controller/DiscountController.php' => 1, + // قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType() + 'src/Insurance/Controller/InsuranceController.php' => 5, + // ownedItem() / ownedPackage() + 'src/Inventory/Controller/InventoryController.php' => 2, + 'src/Inventory/Service/InventoryService.php' => 1, + // ownsRecord ×۳۳ و دو پرداختِ لنگرخورده به مراجعهٔ بررسی‌شده + 'src/Patient/Controller/PatientController.php' => 35, + // TenantOwnershipChecker در هر سه حلقه (قیمت‌گذاری، ساخت، ویرایش) + کالا + 'src/Patient/Service/PatientService.php' => 5, + // ownsStaff() + 'src/Staff/Controller/StaffController.php' => 2, + ]; + + private function projectDir(): string + { + return dirname(__DIR__, 2); + } + + /** @return array */ + private function countLookups(): array + { + $pattern = '/->(' . implode('|', self::TENANT_REPOSITORY_VARIABLES) . ')->findByUuid\(/'; + $counts = []; + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->projectDir() . '/src', \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $found = preg_match_all($pattern, (string) file_get_contents($file->getPathname())); + if ($found > 0) { + $relative = str_replace($this->projectDir() . '/', '', $file->getPathname()); + $counts[$relative] = $found; + } + } + + ksort($counts); + + return $counts; + } + + public function testNoUnreviewedTenantLookupExists(): void + { + $actual = $this->countLookups(); + $expected = self::REVIEWED; + ksort($expected); + + self::assertSame($expected, $actual, <<<'MSG' + جست‌وجوی «uuid از درخواست → موجودیت محیط‌دار» تغییر کرده. + + برای هر مورد تازه ثابت کن محیطش بررسی می‌شود (ownsRecord/ownsSection/ + ownedItem، TenantOwnershipChecker، یا لنگر به ریشهٔ بررسی‌شده) و بعد عدد + همان فایل را در REVIEWED به‌روز کن. اگر عدد کم شده، فقط عدد را کم کن. + MSG); + } +} diff --git a/tests/Shared/TenantOwnershipCheckerTest.php b/tests/Shared/TenantOwnershipCheckerTest.php new file mode 100644 index 00000000..adb065e2 --- /dev/null +++ b/tests/Shared/TenantOwnershipCheckerTest.php @@ -0,0 +1,142 @@ +get(TenantOwnershipChecker::class); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر مالکیت'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function serviceFor(Doctor $doctor): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + $this->em->persist($section); + $item = new ServiceItem($section, 'سرویس', 0); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + public function testEntityOfTheSameEnvironmentBelongs(): void + { + $doctor = $this->makeDoctor(); + + self::assertTrue( + $this->checker()->belongsTo(EntityContext::forDoctor($doctor), $this->serviceFor($doctor)), + ); + } + + public function testEntityOfAnotherEnvironmentDoesNot(): void + { + $doctor = $this->makeDoctor(); + $foreign = $this->serviceFor($this->makeDoctor()); + + self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $foreign)); + } + + /** فرزند aggregate جفتش را از ریشه به ارث می‌برد — ServiceItem از ServiceSection. */ + public function testAggregateChildIsComparedThroughItsRoot(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceFor($doctor); + + self::assertSame('doctor', $item->getEntityType()); + self::assertSame($doctor->getId(), $item->getEntityId()); + } + + /** ❌ null یعنی «پیدا نشد» و همان‌قدر رد می‌شود که یک موجودیت بیگانه. */ + public function testNullIsNeverOwned(): void + { + $doctor = $this->makeDoctor(); + + self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), null)); + self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId(), null)); + } + + /** ❌ محیط حل‌نشده هیچ‌چیز را مالک نیست. */ + public function testUnresolvedContextOwnsNothing(): void + { + $item = $this->serviceFor($this->makeDoctor()); + + self::assertFalse($this->checker()->belongsTo(EntityContext::unknown(), $item)); + } + + /** + * ❌ موجودیتی که جفت محیطش را expose نمی‌کند باید بلند خطا بدهد، نه اینکه + * بی‌صدا false برگرداند — سکوت اینجا یعنی گاردِ همیشه-بسته که باگ می‌سازد. + */ + public function testEntityWithoutATenantPairThrows(): void + { + $doctor = $this->makeDoctor(); + + $this->expectException(\InvalidArgumentException::class); + $this->checker()->belongsTo(EntityContext::forDoctor($doctor), new \stdClass()); + } + + /** ⚠️ یک بیگانه در فهرست، کل فهرست را رد می‌کند. */ + public function testOneForeignEntityRejectsTheWholeList(): void + { + $doctor = $this->makeDoctor(); + $mine = $this->serviceFor($doctor); + $foreign = $this->serviceFor($this->makeDoctor()); + $context = EntityContext::forDoctor($doctor); + + self::assertTrue($this->checker()->allBelongTo($context, [$mine])); + self::assertFalse($this->checker()->allBelongTo($context, [$mine, $foreign])); + self::assertTrue($this->checker()->allBelongTo($context, []), 'فهرست خالی مانعی ندارد'); + } + + /** نوعِ محیط هم بخشی از هویت است: کلینیک ۵ با پزشک ۵ یکی نیست. */ + public function testTypeIsPartOfTheIdentityNotJustTheId(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceFor($doctor); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک'); + $this->em->persist($clinic); + $this->em->flush(); + + self::assertFalse( + $this->checker()->belongsToPair('clinic', $doctor->getId(), $item), + 'همان شناسه ولی نوع دیگر نباید مالک شمرده شود', + ); + } + + /** موجودیت‌هایی که خودشان جفت دارند (نه از ریشه) هم با همین checker سنجیده می‌شوند. */ + public function testEntitiesCarryingTheirOwnPairWorkToo(): void + { + $doctor = $this->makeDoctor(); + $staff = new ClinicStaff('doctor', $doctor->getId(), 'پرسنل'); + $this->em->persist($staff); + $this->em->flush(); + + self::assertTrue($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $staff)); + self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId() + 1, $staff)); + } +}