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>
154 lines
5.8 KiB
PHP
154 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Shared;
|
|
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Patient\Entity\PatientSession;
|
|
use App\Patient\Entity\SessionPayment;
|
|
use App\Shared\Tenant\TenantFilter;
|
|
use App\Tests\ApiTestCase;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
/**
|
|
* فرزندی که uuidش از خودِ درخواست میآید، جفت محیط **خودش** را دارد.
|
|
*
|
|
* فاز ۷ نتیجه گرفت فرزندان aggregate ستون لازم ندارند چون هر کوئری repository به
|
|
* ریشه لنگر میزند. آن حرف دربارهٔ repositoryها درست بود و یک حالت را ندید:
|
|
*
|
|
* $item = $this->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]));
|
|
}
|
|
}
|