From a95ee9a618ef12128d3d5300150ec9d415b349c0 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 28 Jul 2026 16:50:51 +0330 Subject: [PATCH] feat(tenant): give a tenant pair to the children reachable by a request uuid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 concluded that aggregate children needed no column of their own, because every repository query anchors to its root. That was true of the repositories, and it missed the case where the anchor never happens: $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']); A lookup by uuid is itself an unanchored query, and TenantFilter cannot help when the table has no column to filter on. All three leaks phase 7 found had exactly this shape, including the one that put another environment's service price on a patient's invoice. Measuring which children are actually loaded that way gives eight of the twenty-five — service_items (15 call sites), patient_sessions (7), session_payments, patient_notes, patient_calls, patient_messages, patient_attachments, patient_medical_records. They now carry their own pair and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed from their root and stay as they were. The pair is derived from the root inside the constructor rather than passed in, so no creation site can forget it and the value has one source. A root never changes environment, so the copy is written once and cannot drift. This is defence at the data layer rather than at the entry point: a forgotten guard now returns nothing instead of another environment's row. The existing TenantOwnershipChecker guards stay as the outer layer. Verified against an imported production database: 8 tables backfilled, zero rows unmatched, zero rows inconsistent with their root. Dropping the column again turns the leak test red. Tests: 911 backend (+5). PHPStan unchanged at 17. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/tenancy.md | 34 +++- migrations/Version20260728170000.php | 94 +++++++++++ src/ClinicService/Entity/ServiceItem.php | 13 +- src/Patient/Entity/PatientAttachment.php | 5 + src/Patient/Entity/PatientCall.php | 5 + src/Patient/Entity/PatientMedicalRecord.php | 5 + src/Patient/Entity/PatientMessage.php | 5 + src/Patient/Entity/PatientNote.php | 5 + src/Patient/Entity/PatientSession.php | 5 + src/Patient/Entity/SessionPayment.php | 5 + src/Shared/Tenant/GlobalTables.php | 12 +- .../RequestReachableChildTenantTest.php | 153 ++++++++++++++++++ 12 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 migrations/Version20260728170000.php create mode 100644 tests/Shared/RequestReachableChildTenantTest.php diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 761ccb9c..1bc6268f 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -100,9 +100,37 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده ### ⚠️ فرزندان aggregate تور ایمنی ندارند -فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. +فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `service_item_consumables` بدون JOIN به ریشه، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. -فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ `ServiceItem` این کار را با delegate به `ServiceSection` انجام می‌دهد. +**قاعدهٔ مرزبندی (فاز ۸):** فرزندی که uuidش از خودِ درخواست می‌آید، حق ندارد فرزند بماند. + +```php +$item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']); +``` + +این **خودش** یک کوئری بی‌لنگر است. فاز ۷ نتیجه گرفته بود فرزندان ستون لازم ندارند چون «هر کوئری repository به ریشه لنگر می‌زند» — که دربارهٔ DQLهای repository درست بود و همین حالت را ندید. هر سه نشتی فاز ۷ دقیقاً همین شکل بودند. + +پس هشت فرزندِ قابل‌دسترس با uuid جفت محیط **خودشان** را گرفتند و دیگر در `AGGREGATE_CHILDREN` نیستند: + +``` +service_items · patient_sessions · session_payments · patient_notes +patient_calls · patient_messages · patient_attachments · patient_medical_records +``` + +جفت در **سازنده** از ریشه مشتق می‌شود، نه از ورودی: + +```php +public function __construct(ServiceSection $section, ...) { + $this->section = $section; + $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); +} +``` + +دو خاصیت مهم دارد: هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند، و چون محیطِ یک ریشه هرگز عوض نمی‌شود، این کپی write-once است و واگرا نمی‌شود. + +۱۷ فرزند باقی‌مانده هیچ‌کدام با uuid از درخواست لود نمی‌شوند (اندازه‌گیری‌شده، نه فرض) و فقط از ریشه پیمایش می‌شوند. + +`RequestReachableChildTenantTest` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود. ### uuid از درخواست — خطرناک‌ترین الگو @@ -241,3 +269,5 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند | | `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست | | `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند | +| `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی | +| `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند | diff --git a/migrations/Version20260728170000.php b/migrations/Version20260728170000.php new file mode 100644 index 00000000..ed731461 --- /dev/null +++ b/migrations/Version20260728170000.php @@ -0,0 +1,94 @@ +serviceItemRepo->findByUuid($request['service_item_uuid']); + * + * A lookup by uuid *is* an unanchored query, and TenantFilter cannot help when + * the table has no column to filter on. All three leaks found in phase 7 had + * exactly this shape. With these columns the filter covers them, so a forgotten + * guard returns nothing instead of another environment's row. + * + * Only the eight children actually reachable that way are changed — measured, + * not guessed. The rest stay aggregate children and are still reached from their + * root. + * + * The pair is derived from the root, and a root never changes environment, so + * the copy is written once and can never drift. + */ +final class Version20260728170000 extends AbstractMigration +{ + /** table => [join clause, root alias] */ + private const BACKFILL = [ + 'patient_attachments' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_calls' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_medical_records' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_messages' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_notes' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_sessions' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'session_payments' => ['JOIN patient_sessions s ON s.id = t.session_id JOIN patient_records r ON r.id = s.record_id', 'r'], + 'service_items' => ['JOIN service_sections sec ON sec.id = t.section_id', 'sec'], + ]; + + public function getDescription(): string + { + return 'Give the request-reachable aggregate children their own tenant pair'; + } + + public function up(Schema $schema): void + { + foreach (self::BACKFILL as $table => [$join, $alias]) { + $this->connection->executeStatement( + "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" + ); + $this->connection->executeStatement( + "UPDATE {$table} t {$join} + SET t.entity_type = {$alias}.entity_type, t.entity_id = {$alias}.entity_id" + ); + + // A row whose root cannot be reached is already broken data; inventing + // an environment for it would hide the breakage behind a wrong owner. + $remaining = (int) $this->connection->fetchOne( + "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL" + ); + $this->abortIf( + $remaining > 0, + "{$remaining} row(s) in {$table} have no reachable root; fix them before rerunning." + ); + + $this->connection->executeStatement( + "ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL" + ); + $this->connection->executeStatement( + "CREATE INDEX idx_{$table}_entity ON {$table} (entity_type, entity_id)" + ); + } + } + + public function down(Schema $schema): void + { + foreach (array_keys(self::BACKFILL) as $table) { + $this->addSql("DROP INDEX idx_{$table}_entity ON {$table}"); + $this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id"); + } + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index f06b22dd..3250af88 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -7,13 +7,17 @@ use App\Insurance\Enum\ServiceCategory; use App\Staff\Entity\ClinicStaff; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: ServiceItemRepository::class)] #[ORM\Table(name: 'service_items')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_items_entity')] class ServiceItem { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -97,6 +101,7 @@ class ServiceItem { $this->uuid = Uuid::v4()->toRfc4122(); $this->section = $section; + $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); $this->name = $name; $this->priceRials = $priceRials; $this->createdAt = time(); @@ -153,14 +158,6 @@ class ServiceItem 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/Entity/PatientAttachment.php b/src/Patient/Entity/PatientAttachment.php index b2de6b44..70107d8a 100644 --- a/src/Patient/Entity/PatientAttachment.php +++ b/src/Patient/Entity/PatientAttachment.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientAttachmentRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A file attached to a patient record (the «ضمیمه» tab). */ #[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)] #[ORM\Table(name: 'patient_attachments')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_attachments_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')] class PatientAttachment { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -43,6 +47,7 @@ class PatientAttachment { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->name = $name; $this->url = $url; $this->mime = $mime; diff --git a/src/Patient/Entity/PatientCall.php b/src/Patient/Entity/PatientCall.php index db3a8021..086f71d5 100644 --- a/src/Patient/Entity/PatientCall.php +++ b/src/Patient/Entity/PatientCall.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientCallRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A logged phone call with a patient (the «کال سنتر» tab). */ #[ORM\Entity(repositoryClass: PatientCallRepository::class)] #[ORM\Table(name: 'patient_calls')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_calls_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_calls_record')] class PatientCall { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -51,6 +55,7 @@ class PatientCall { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->subject = $subject; $this->outcome = $outcome; $this->calledAt = $calledAt ?? time(); diff --git a/src/Patient/Entity/PatientMedicalRecord.php b/src/Patient/Entity/PatientMedicalRecord.php index bb6eda00..c0e9462e 100644 --- a/src/Patient/Entity/PatientMedicalRecord.php +++ b/src/Patient/Entity/PatientMedicalRecord.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientMedicalRecordRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */ #[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)] #[ORM\Table(name: 'patient_medical_records')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_medical_records_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')] class PatientMedicalRecord { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -41,6 +45,7 @@ class PatientMedicalRecord { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->title = $title; $this->body = $body; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientMessage.php b/src/Patient/Entity/PatientMessage.php index a30b46f1..f5e0d53b 100644 --- a/src/Patient/Entity/PatientMessage.php +++ b/src/Patient/Entity/PatientMessage.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientMessageRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A logged message/communication with a patient (the «پیام‌ها» tab). */ #[ORM\Entity(repositoryClass: PatientMessageRepository::class)] #[ORM\Table(name: 'patient_messages')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_messages_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_messages_record')] class PatientMessage { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -38,6 +42,7 @@ class PatientMessage { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->body = $body; $this->channel = $channel; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientNote.php b/src/Patient/Entity/PatientNote.php index 5a299d6e..dbdb300d 100644 --- a/src/Patient/Entity/PatientNote.php +++ b/src/Patient/Entity/PatientNote.php @@ -4,6 +4,7 @@ namespace App\Patient\Entity; use App\Auth\Entity\User; use App\Patient\Repository\PatientNoteRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -14,9 +15,12 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: PatientNoteRepository::class)] #[ORM\Table(name: 'patient_notes')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_notes_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_notes_record')] class PatientNote { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -56,6 +60,7 @@ class PatientNote { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->body = $body; $this->pinned = $pinned; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index 9299bd28..177c43fa 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -8,14 +8,18 @@ use App\Inventory\Entity\InventoryPackage; use App\Patient\Repository\PatientSessionRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: PatientSessionRepository::class)] #[ORM\Table(name: 'patient_sessions')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_sessions_entity')] #[ORM\Index(columns: ['created_at'], name: 'idx_patient_sessions_created_at')] class PatientSession { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -143,6 +147,7 @@ class PatientSession { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->appointment = $appointment; $this->createdAt = time(); $this->updatedAt = time(); diff --git a/src/Patient/Entity/SessionPayment.php b/src/Patient/Entity/SessionPayment.php index 8cc57c2f..c789f8ce 100644 --- a/src/Patient/Entity/SessionPayment.php +++ b/src/Patient/Entity/SessionPayment.php @@ -4,6 +4,7 @@ namespace App\Patient\Entity; use App\Auth\Entity\User; use App\Patient\Repository\SessionPaymentRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -13,8 +14,11 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: SessionPaymentRepository::class)] #[ORM\Table(name: 'session_payments')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_session_payments_entity')] class SessionPayment { + use TenantOwnedTrait; + public const METHODS = ['wallet', 'pos', 'cash', 'card']; #[ORM\Id] @@ -60,6 +64,7 @@ class SessionPayment { $this->uuid = Uuid::v4()->toRfc4122(); $this->session = $session; + $this->assignTenantPair($session->getEntityType(), $session->getEntityId()); $this->method = $method; $this->amountRials = $amountRials; $this->paidAt = $paidAt ?? time(); diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 8a39372b..ddd32667 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -80,23 +80,19 @@ final class GlobalTables * ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون * JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن. * + * فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی + * ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را + * گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند. + * * @var array فرزند => ریشه */ public const AGGREGATE_CHILDREN = [ \App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class, - \App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class, \App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class, - \App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class, - \App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class, \App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, diff --git a/tests/Shared/RequestReachableChildTenantTest.php b/tests/Shared/RequestReachableChildTenantTest.php new file mode 100644 index 00000000..2819052f --- /dev/null +++ b/tests/Shared/RequestReachableChildTenantTest.php @@ -0,0 +1,153 @@ +serviceItemRepo->findByUuid($data['service_item_uuid']); + * + * جست‌وجو با uuid **خودش** یک کوئری بی‌لنگر است، و فیلتر وقتی ستونی نباشد کاری + * نمی‌تواند بکند. هر سه نشتی فاز ۷ همین شکل را داشتند. + * + * این تست‌ها عمداً **هیچ گارد دستی صدا نمی‌زنند** — فقط خودِ فیلتر. اگر ستون‌ها + * برداشته شوند، همگی قرمز می‌شوند. + */ +class RequestReachableChildTenantTest extends ApiTestCase +{ + private function em(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $filters = $this->em()->getFilters(); + if ($filters->isEnabled(TenantFilter::NAME)) { + $filters->disable(TenantFilter::NAME); + } + + parent::tearDown(); + } + + private function enableFilterFor(string $type, int $id): void + { + $this->em()->getFilters() + ->enable(TenantFilter::NAME) + ->setParameter(TenantFilter::PARAM_TYPE, $type, 'string') + ->setParameter(TenantFilter::PARAM_ID, $id, 'integer'); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر فرزند'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function serviceItemOf(Doctor $doctor): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + $this->em->persist($section); + $item = new ServiceItem($section, 'سرویس', 100); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function sessionOf(Doctor $doctor): PatientSession + { + $record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(['ROLE_USER']), 'doctor', $doctor->getId()); + $this->em->persist($record); + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + return $session; + } + + /** جفت محیط از ریشه مشتق می‌شود، نه از ورودی سازنده — پس نمی‌تواند واگرا شود. */ + public function testTheChildInheritsItsRootsEnvironmentOnConstruction(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceItemOf($doctor); + $session = $this->sessionOf($doctor); + + self::assertSame('doctor', $item->getEntityType()); + self::assertSame($doctor->getId(), $item->getEntityId()); + self::assertSame($doctor->getId(), $session->getEntityId()); + } + + /** ✅ در محیط خودی، جست‌وجو با uuid موجودیت را می‌دهد. */ + public function testALookupByUuidSucceedsInsideItsOwnEnvironment(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->serviceItemOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId()); + + self::assertNotNull($this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $uuid])); + } + + /** + * ❌ همان جست‌وجو در محیط دیگر تهی برمی‌گردد — **بدون هیچ گارد دستی**. + * این همان نشتی مالی فاز ۷ است که حالا در سطح داده بسته شده. + */ + public function testTheSameLookupReturnsNothingInAnotherEnvironment(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->serviceItemOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $uuid])); + } + + /** ❌ همین قاعده برای مراجعه — که ۷ نقطهٔ جست‌وجو با uuid دارد. */ + public function testASessionOfAnotherEnvironmentIsInvisible(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->sessionOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $uuid])); + } + + /** ⚠️ مرزی: نوهٔ ریشه (پرداختِ مراجعه) هم محیطش را از همان زنجیره می‌گیرد. */ + public function testAGrandchildTakesTheEnvironmentOfTheChainToo(): void + { + $doctor = $this->makeDoctor(); + $session = $this->sessionOf($doctor); + $payment = new SessionPayment($session, 'cash', 50_000); + $this->em->persist($payment); + $this->em->flush(); + $uuid = $payment->getUuid(); + + self::assertSame($doctor->getId(), $payment->getEntityId()); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(SessionPayment::class)->findOneBy(['uuid' => $uuid])); + } +}