Files
clinicpro/tests/Shared/TenantSchemaCoverageTest.php
T
hamedandClaude Opus 5 75d5052f72 feat(tenant): enforce environment isolation in the ORM layer
Phase 4 of the tenant-marking series. Until now isolation depended on every
query remembering its own WHERE clause. With 82 entities and 844 tests, that is
not a guarantee — it is a hope. MariaDB has no row-level security, so the
backstop has to live in Doctrine.

TenantFilter appends (entity_type, entity_id) to every DQL query on a
tenant-owning entity. It ships disabled and TenantFilterSubscriber turns it on
per request.

The filter engages only for a **chosen** environment — an explicit clinic_uuid
on the request, or a stored UserActiveContext. EntityContext now records which
of the two produced it. Locking a user to the role fallback instead would hide
data they are entitled to: a clinic-member doctor who never switched context
lost every appointment belonging to that clinic. Five tests caught exactly that
before the gate was added. Admins and unauthenticated marketplace traffic stay
outside the filter by design.

Two findings from running it rather than reasoning about it:

- Dereferencing a lazy proxy whose target the filter excluded raises
  EntityNotFoundException, which surfaced as 500 on four patient endpoints.
  ExceptionSubscriber now maps it to 404: outside your environment means it does
  not exist for you. It is logged at info level so a genuinely broken FK is still
  visible.
- EntityManager::find() by primary key IS filtered in Doctrine ORM 3, contrary
  to the limitation carried over from older versions. The stronger guarantee is
  pinned by a test so a future regression is noticed, and the documented table
  was corrected.

The filter also caught a real leak: a clinic secretary's appointment list
filtered by doctor id alone, so a doctor's personal-practice booking appeared in
the clinic list. The test had been asserting that behaviour.

GlobalTables classifies all 82 entities into four states — carries a tenant,
deliberately global, aggregate child, or recorded debt — and
TenantSchemaCoverageTest fails on anything unclassified. Aggregate children
declare their root explicitly, because several attach through a scalar FK rather
than a Doctrine association and cannot be inferred from metadata; the test walks
each chain to a tenant-owning root. Financial tables stay in DEFERRED with a
ceiling assertion so the list cannot grow quietly.

Deliberately not built: the prePersist assignment listener from the plan. The
tenant columns are NOT NULL without a default, so a missing assignTenant()
already fails loudly at flush — phase 2 surfaced 123 such failures. A listener
would add silent auto-assignment where the current behaviour is an explicit
crash.

EXPLAIN with the filter's conditions still picks idx_appointments_tenant_slot
and uniq_patient_record.

Tests: 844 passing. PHPStan unchanged at its 17 pre-existing errors, none in
files touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:27:27 +03:30

141 lines
5.7 KiB
PHP

<?php
namespace App\Tests\Shared;
use App\Shared\Tenant\GlobalTables;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* ضد رگرسیون برای فازهای بعدی: entity جدیدی که فردا اضافه شود و کسی محیطش را
* تعیین نکند، اینجا قرمز می‌شود — نه ماه‌ها بعد با یک نشت داده.
*
* شکست این تست یعنی «این کلاس طبقه‌بندی نشده»، نه «تست خراب است».
*/
class TenantSchemaCoverageTest extends ApiTestCase
{
/** @return class-string[] */
private function allEntityClasses(): array
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return array_map(
static fn($meta) => $meta->getName(),
$em->getMetadataFactory()->getAllMetadata(),
);
}
private function isTenantOwned(string $class): bool
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return $em->getClassMetadata($class)->hasField('entityType');
}
/**
* هر entity دقیقاً یکی از چهار وضعیت را دارد: جفت tenant، سراسری، فرزند
* aggregate، یا بدهی ثبت‌شده.
*/
public function testEveryEntityIsClassified(): void
{
$unclassified = array_values(array_filter(
$this->allEntityClasses(),
fn(string $class) => !$this->isTenantOwned($class)
&& !isset(GlobalTables::ENTITIES[$class])
&& !isset(GlobalTables::AGGREGATE_CHILDREN[$class])
&& !isset(GlobalTables::DEFERRED[$class]),
));
self::assertSame([], $unclassified, sprintf(
"این entityها نه جفت tenant دارند و نه در GlobalTables ثبت شده‌اند:\n%s",
implode("\n", $unclassified),
));
}
/** هیچ کلاسی نباید هم‌زمان در دو دستهٔ GlobalTables باشد. */
public function testClassificationsDoNotOverlap(): void
{
$buckets = [
'ENTITIES' => array_keys(GlobalTables::ENTITIES),
'AGGREGATE_CHILDREN' => array_keys(GlobalTables::AGGREGATE_CHILDREN),
'DEFERRED' => array_keys(GlobalTables::DEFERRED),
];
foreach ($buckets as $name => $classes) {
foreach ($buckets as $otherName => $otherClasses) {
if ($name === $otherName) {
continue;
}
self::assertSame(
[],
array_values(array_intersect($classes, $otherClasses)),
"{$name} و {$otherName} کلاس مشترک دارند",
);
}
}
}
/**
* هر فرزند aggregate باید زنجیره‌ای به یک ریشهٔ tenant-دار داشته باشد. بدون این،
* ثبت‌کردنش در whitelist فقط تست را ساکت می‌کند بی‌آنکه محیطی وجود داشته باشد.
*/
public function testEveryAggregateChildReachesATenantOwnedRoot(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
$chain = [$child];
$current = $child;
while (isset(GlobalTables::AGGREGATE_CHILDREN[$current])) {
$current = GlobalTables::AGGREGATE_CHILDREN[$current];
self::assertNotContains($current, $chain, 'زنجیرهٔ aggregate حلقه دارد: ' . implode(' → ', $chain));
$chain[] = $current;
}
self::assertTrue(
$this->isTenantOwned($current),
sprintf('ریشهٔ %s باید جفت tenant داشته باشد: %s', $child, implode(' → ', $chain)),
);
}
}
/** فرزندِ خودش نباید جفت tenant داشته باشد — وگرنه دو منبع حقیقت می‌شود. */
public function testAggregateChildrenDoNotCarryTheirOwnTenant(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
self::assertFalse(
$this->isTenantOwned($child),
"{$child} هم جفت tenant دارد هم فرزند aggregate ثبت شده — یکی را بردار",
);
}
}
/** هر ثبت باید کلاس موجود و دلیل ناتهی داشته باشد. */
public function testEveryClassificationIsRealAndJustified(): void
{
foreach ([GlobalTables::ENTITIES, GlobalTables::DEFERRED] as $bucket) {
foreach ($bucket as $class => $reason) {
self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}");
self::assertNotSame('', trim($reason), "دلیل {$class} خالی است");
}
}
foreach (GlobalTables::AGGREGATE_CHILDREN as $child => $root) {
self::assertTrue(class_exists($child), "کلاس ثبت‌شده وجود ندارد: {$child}");
self::assertTrue(class_exists($root), "ریشهٔ ثبت‌شده وجود ندارد: {$root}");
}
}
/**
* بدهی باید کوچک شود نه بزرگ. اگر کلاسی به DEFERRED اضافه شد، این عدد هم باید
* عمداً بالا برود — یعنی تصمیم دیده می‌شود، نه اینکه بی‌صدا بگذرد.
*/
public function testDeferredDebtDoesNotGrow(): void
{
self::assertLessThanOrEqual(
8,
count(GlobalTables::DEFERRED),
'جدول‌های مالی طبقه‌بندی‌نشده بیشتر شدند؛ فهرست DEFERRED باید کوچک شود',
);
}
}