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) <noreply@anthropic.com>
274 lines
22 KiB
Markdown
274 lines
22 KiB
Markdown
# جداسازی محیط (tenancy)
|
||
|
||
هر دادهٔ عملیاتی در ClinicPro به یک **محیط** تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند میگوید آن محیط چطور تعیین میشود، کجا اجبار میشود، و — مهمتر — **کجا اجبار نمیشود**.
|
||
|
||
---
|
||
|
||
## هویت محیط
|
||
|
||
جفت `(entity_type, entity_id)` روی خودِ جدول:
|
||
|
||
| ستون | مقدار |
|
||
|---|---|
|
||
| `entity_type` | `doctor` یا `clinic` — `VARCHAR(10)` در هر ۲۰ جدول tenant-دار |
|
||
| `entity_id` | شناسهٔ همان پزشک یا کلینیک |
|
||
|
||
موجودیتها این جفت را از trait مشترک میگیرند:
|
||
|
||
```php
|
||
use App\Shared\Tenant\TenantOwnedTrait;
|
||
|
||
$appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));
|
||
// یا وقتی جفت از قبل بهصورت اسکالر حل شده:
|
||
$rule->assignTenantPair('clinic', $clinicId);
|
||
```
|
||
|
||
ستونها `NOT NULL` بدون مقدار پیشفرضاند. اگر سازندهای `assignTenant()` را فراموش کند، `flush` میشکند — عمدی است: ردیفِ بیمحیط بیصدا از دید همه پنهان میشود.
|
||
|
||
---
|
||
|
||
## تعیین محیط جاری
|
||
|
||
`App\Shared\Context\EntityContextResolver` تنها نقطهٔ تصمیم است. اولویت:
|
||
|
||
```
|
||
clinic_uuid صریحِ درخواست > UserActiveContext ذخیرهشده > fallback نقش
|
||
```
|
||
|
||
دو مورد اول **انتخابِ کاربر**اند و `EntityContext::$chosen` را `true` میکنند. سومی حدس است.
|
||
|
||
### ماتریس نقشها
|
||
|
||
| نقش | محیط مؤثر |
|
||
|---|---|
|
||
| پزشک مستقل | همیشه `('doctor', id)` |
|
||
| پزشک عضو یک یا چند کلینیک | طبق محیط فعال؛ بیرون از آن، مطب شخصی |
|
||
| پزشکِ مالک کلینیک | طبق محیط فعال؛ در محیط کلینیک بدون محدودیت `ClinicDoctorPermission` |
|
||
| مدیر/مالک کلینیک | همیشه `('clinic', id)` |
|
||
| منشی | فقط از محیط فعال؛ بدون آن `unknown` |
|
||
|
||
`user_active_context` هم `db_uuid` دارد هم `db_type`، پس حل محیط یک lookup است نه دو تا.
|
||
|
||
---
|
||
|
||
## اجبار: `TenantFilter`
|
||
|
||
یک `SQLFilter` که به هر کوئری DQL روی موجودیتهای tenant-دار شرط `(entity_type, entity_id)` اضافه میکند.
|
||
|
||
**پیشفرض خاموش است.** `TenantFilterSubscriber` آن را در `kernel.request` روشن میکند و فقط وقتی که:
|
||
|
||
- کاربر احراز شده باشد، **و**
|
||
- `ROLE_ADMIN` نداشته باشد (پنل ادمین ذاتاً cross-tenant است)، **و**
|
||
- محیطش **انتخابشده** باشد (`$context->chosen`)
|
||
|
||
> **چرا فقط محیط انتخابشده؟** کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست». قفلکردنش روی حدسِ نقش، دادهٔ کلینیکیای را که قانوناً حقش است پنهان میکند — مثلاً پزشکِ عضوی که هرگز محیط عوض نکرده، نوبتهای کلینیکش را از دست میداد. برای این کاربران، دسترسی را همان checkerهای دامنه تعیین میکنند (رفتار پیش از فاز ۴).
|
||
|
||
### چه تضمین میدهد و چه نمیدهد
|
||
|
||
| مسیر | فیلتر اعمال میشود؟ |
|
||
|---|---|
|
||
| DQL و QueryBuilder | ✅ |
|
||
| `findBy` / `findOneBy` | ✅ |
|
||
| `EntityManager::find()` با کلید اصلی | ✅ — در Doctrine ORM 3 (تثبیتشده در `TenantFilterLeakTest`) |
|
||
| بارگذاری تنبل کالکشنها | ✅ |
|
||
| entity که از قبل در identity map است | ❌ دوباره کوئری نمیشود |
|
||
| `getReference()` | ❌ |
|
||
| SQL خام DBAL | ❌ |
|
||
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
|
||
|
||
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان میمانند: آنها «چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها».
|
||
|
||
### تغییر رفتار: ۴۰۴ بهجای ۴۰۳
|
||
|
||
با فیلتر روشن، رکوردی که مال محیط دیگری است **وجود ندارد**، نه اینکه «ممنوع» باشد:
|
||
|
||
- کاربر با محیط انتخابشدهٔ A که رکورد محیط B را باز کند → `404`
|
||
- مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته → `EntityNotFoundException` که `ExceptionSubscriber` به `404` با `ERR_NOT_FOUND_001` نگاشت میکند (و در سطح `info` لاگ میشود تا FK واقعاً شکسته هم دیده شود)
|
||
|
||
---
|
||
|
||
## طبقهبندی جدولها — `App\Shared\Tenant\GlobalTables`
|
||
|
||
هر entity دقیقاً در یکی از چهار وضعیت است، و `TenantSchemaCoverageTest` این را اجبار میکند:
|
||
|
||
| وضعیت | یعنی | نمونه |
|
||
|---|---|---|
|
||
| جفت tenant دارد | فیلتر پوششش میدهد | `appointments`، `patient_records`، `service_sections` |
|
||
| `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` |
|
||
| `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث میبرد | `patient_notes` → `patient_records` |
|
||
| `DEFERRED` | بدهی ثبتشده، هنوز طبقهبندی نشده | **خالی** — فاز ۶ آخرین موردش را تعیین تکلیف کرد |
|
||
|
||
### ⚠️ فرزندان aggregate تور ایمنی ندارند
|
||
|
||
فیلتر روی آنها اعمال نمیشود. کوئری مستقیم روی `service_item_consumables` بدون JOIN به ریشه، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین میکند زنجیرهٔ اعلامشده به ریشهای با جفت tenant میرسد — نه اینکه کوئریها واقعاً از ریشه شروع میشوند.
|
||
|
||
**قاعدهٔ مرزبندی (فاز ۸):** فرزندی که 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 از درخواست — خطرناکترین الگو
|
||
|
||
سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچکدام در 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` تازه روی موجودیت محیطدار تست را قرمز میکند تا کسی ثابت کند محیطش بررسی میشود و بعد عدد را بهروز کند.
|
||
|
||
### جدولهای مالی
|
||
|
||
بدهی صفر است. `testThereIsNoUnclassifiedDebtLeft` خالیماندنش را اجبار میکند.
|
||
|
||
استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ **گیرنده** تعلق دارد و پرداختکننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است.
|
||
|
||
| جدول | تصمیم | چرا |
|
||
|---|---|---|
|
||
| `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول |
|
||
| `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر میخورند |
|
||
| `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` میرسد |
|
||
| `wallet_transactions` | `ENTITIES` + انتسابِ per-ردیف | کیف پولِ **شخص** است و موجودی سراسری میماند؛ ولی هر ردیف محیطِ ثبتش را در `recorded_entity_*` نگه میدارد تا دفتری که کلینیک میبیند به همان محیط محدود شود (پایین) |
|
||
| `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` میسنجد) |
|
||
| `bank_accounts` · `pos_devices` | جفت محیط، **تهیپذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) |
|
||
|
||
نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` میکند و فیلتر روی همان مینشیند؛ `FinancialChainTenantTest` همین را میسنجد.
|
||
|
||
### کیف پول: موجودی سراسری، دفتر per-محیط
|
||
|
||
کیف پول ستون tenant ندارد و نباید داشته باشد: پول مالِ شخص است و اگر فیلتر روی موجودی مینشست، بیمار در هر محیط بخشی از پول خودش را میدید.
|
||
|
||
ولی دفترِ تراکنش را کلینیک هم میبیند (`GET /api/v1/patient/{uuid}/wallet/transactions` و `recent_transactions`)، و گاردِ `ownsRecord` فقط **پرونده** را میسنجد نه سطرها. پس بدون تفکیک، کلینیک A میخواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده.
|
||
|
||
راهحل، انتساب per-ردیف است نه مالکیت per-کیفپول:
|
||
|
||
```php
|
||
// WalletTransaction — نامِ ستونها عمداً entity_type/entity_id نیست
|
||
#[ORM\Column(name: 'recorded_entity_type', ...)] private ?string $recordedEntityType = null;
|
||
#[ORM\Column(name: 'recorded_entity_id', ...)] private ?int $recordedEntityId = null;
|
||
```
|
||
|
||
⚠️ **نامها باید همین بمانند.** `TenantFilter` روی `entityType/entityId` کلید میزند؛ اگر این دو همان نام را میگرفتند، فیلتر خودکار روی محاسبهٔ موجودی هم مینشست و پول بیمار را نصف نشان میداد. اینجا انتساب است، نه مالکیت.
|
||
|
||
نتیجه: `balance_rials` سراسری، سطرهای دفتر per-محیط — پس **جمع سطرها با موجودی برابر نیست** و UI باید بگوید. ردیفهای بیانتساب (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همهجا دیده میشوند تا تاریخچهٔ موجود ناپدید نشود.
|
||
|
||
`PatientWalletTenantTest` هر سه را میسنجد: تفکیک سطرها، سراسریماندن موجودی، و دیدهشدن ردیف بیانتساب.
|
||
|
||
### ⚠️ نقطهٔ ضعف: کارتِ بیمحیط در هیچ محیطی دیده نمیشود
|
||
|
||
`bank_accounts` و `pos_devices` تنها جدولهاییاند که جفت محیطشان **تهیپذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت میشدند و برای کاربری که چند محیط دارد هیچ ستونی نمیگفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی میماند تا مالک خودش تعیین کند.
|
||
|
||
هزینهاش این است: فیلتر شرط تساوی میگذارد و `NULL` با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده.
|
||
|
||
راه خروج، تنها استثنای این دامنه است: `findUnassignedByUser()` و `assignEntity()` عمداً با DBAL خام اجرا میشوند (فیلتر رویشان اعمال نمیشود) و بهجای فیلتر، محدودیت `user_id` را در خودِ کوئری دارند. انتساب با `PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment` انجام میشود و شرطهای مالکیت و بیمحیط بودن داخل خودِ `UPDATE`اند تا دو درخواست همزمان یک کارت را به دو محیط نچسبانند.
|
||
|
||
پنل ادمین این ردیفها را با نشانهٔ «محیط تعییننشده» و دکمهٔ انتساب نشان میدهد، وگرنه کاربر چندمحیطی فکر میکند کارتش گم شده.
|
||
|
||
---
|
||
|
||
## SQL خام — آدیتشده
|
||
|
||
فیلتر روی `Connection::executeQuery/executeStatement` اعمال نمیشود. هر نقطهای که SQL خام میزند بررسی و طبقهبندی شده:
|
||
|
||
| فایل | دسته | چرا امن است |
|
||
|---|---|---|
|
||
| `Billing/Repository/ClaimRepository` | **tenant-دار** | هر سه کوئری `WHERE c.entity_type = :type AND c.entity_id = :id` دارند؛ `ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard` تثبیتش میکند |
|
||
| `Admin/Controller/AdminApiController` | ادمین | `#[IsGranted('ROLE_ADMIN')]` سطح کلاس؛ عمداً cross-tenant |
|
||
| `Representation/Controller/RepresentationActionController` | نماینده | فقط `doctors`، اسکوپ `representation_id` |
|
||
| `Category/Service/CategoryImporter` | سراسری | فقط جدولهای مرجع؛ نام جدول از ثابت `TABLES` میآید (نه ورودی کاربر) و `isValidBundle()` + `ROLE_ADMIN` گیتش میکنند |
|
||
| `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیشفرض، `--force` لازم، prod از سطح kernel مسدود |
|
||
| `Shared/Controller/HealthController` | سراسری | `SELECT 1` |
|
||
| `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` |
|
||
| `PaymentMethod/Repository/{BankAccount,Pos}Repository` | **عمداً بیرون فیلتر** | تنها راه رسیدن به ردیفِ بیمحیط؛ هر دو کوئری `WHERE user_id = ?` دارند و `assignEntity` شرط `entity_type IS NULL` را هم داخل `UPDATE` نگه میدارد. `PaymentMethodTenantTest` تلاش برای تصاحب کارت شخص دیگر را میسنجد |
|
||
|
||
`getReference()` در کل `src/` یک مورد است و روی `User` (سراسری) — بدون اثر tenant.
|
||
|
||
---
|
||
|
||
## بکاپ per-tenant
|
||
|
||
```bash
|
||
php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
|
||
```
|
||
|
||
جدولها از metadata خوانده میشوند (همان معیار `TenantFilter`)، پس جدولی که فردا جفت tenant بگیرد خودکار وارد خروجی میشود.
|
||
|
||
⚠️ فرزندان aggregate ستون محیط ندارند و در خروجی **نمیآیند**. برای بکاپ کامل یک محیط، آنها باید از ریشه دنبال شوند.
|
||
|
||
ورودی `--tenant` با regex بسته اعتبارسنجی میشود چون مستقیم داخل `--where` و خط فرمان میرود؛ `TenantDumpCommandTest` هفت ورودی بدشکل (تزریق SQL و شل، نوع ناشناخته، id صفر/منفی) را میسنجد.
|
||
|
||
---
|
||
|
||
## entity جدید میسازی؟
|
||
|
||
۱. اگر به یک محیط تعلق دارد → `use TenantOwnedTrait;` و در نقطهٔ ساخت `assignTenant()` را صدا بزن
|
||
۲. اگر ندارد → با **دلیل** در `GlobalTables::ENTITIES` ثبتش کن
|
||
۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح
|
||
۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
|
||
|
||
`NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیفهایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهیپذیر فقط تور ایمنی را سوراخ میکند.
|
||
|
||
`DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند.
|
||
|
||
ایندکسها: `entity_type, entity_id` باید **ستونهای اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمیکند.
|
||
|
||
---
|
||
|
||
## تستها
|
||
|
||
| فایل | چه چیزی را تضمین میکند |
|
||
|---|---|
|
||
| `tests/Shared/EntityContextResolverTest.php` | ماتریس ۵ نقش |
|
||
| `tests/Shared/TenantIsolationMatrixTest.php` | هر نقش فقط دادهٔ محیط خودش را از API میگیرد |
|
||
| `tests/Shared/TenantFilterLeakTest.php` | فیلتر **بهتنهایی** cross-tenant را میبندد؛ مسیر عمومی و ادمین باز میمانند |
|
||
| `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` | سرویس/پرسنلِ محیط دیگر نه قیمت میخورد نه ذخیره میشود |
|
||
| `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده مینشیند؛ بیمار پرداخت خودش را میبیند، محیط دیگر نمیبیند |
|
||
| `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا میشود؛ کیف پول عمداً سراسری میماند |
|
||
| `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارتها per-محیطاند؛ ردیف بیمحیط دیده میشود ولی تا انتساب قابل ویرایش نیست |
|
||
| `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری میماند |
|
||
| `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابلدسترس با uuid را **خودِ فیلتر** میبندد، بدون گارد دستی |
|
||
| `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام میتواند سوییچ کند |
|