From 74d2034158d4cfb381dd0214c3d717828f318ea1 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 28 Jul 2026 16:11:49 +0330 Subject: [PATCH] fix(auth): offer every clinic a user owns as a switchable context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildAvailableContexts used ClinicRepository::findByUser(), which is a findOneBy — so a user who owns two clinics only ever saw the first one. switchContext validates its input against that same list, so the second clinic could not be selected at all. Before tenant isolation this was merely annoying. Since phase 4 it is a blocker: an environment that cannot be selected is an environment TenantFilter hides from its own owner. Found by running the suite against an imported production database, where one account owns two clinics and its second clinic had become unreachable. findByUser() stays for the fallbacks that only need "some clinic"; the context list now uses findAllByUser(). The other 20 findByUser() call sites are single-clinic fallbacks used when no context is chosen, and keep their current behaviour — once the owner can switch, UserActiveContext decides. Removing the fix turns 3 of the 4 new tests red. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/auth.md | 2 + src/Auth/Controller/AuthController.php | 6 +- src/Clinic/Repository/ClinicRepository.php | 15 +++ tests/Auth/MultiClinicOwnerContextTest.php | 117 +++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/Auth/MultiClinicOwnerContextTest.php diff --git a/docs/api/auth.md b/docs/api/auth.md index bdd416e5..25be9a05 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -316,6 +316,8 @@ Authorization: Bearer | `context` | object\|null | context فعال انتخاب‌شده | | `available_contexts` | array | همه محیط‌های کاری قابل انتخاب | +> **مالکِ چند کلینیک:** اگر یک کاربر مالک بیش از یک کلینیک باشد، **همهٔ** آن‌ها در این فهرست می‌آیند و به هرکدام می‌شود سوییچ کرد. تا پیش از این فقط اولی می‌آمد و کلینیک دوم اصلاً قابل انتخاب نبود — که با جداسازی محیط یعنی دادهٔ آن کلینیک برای خودِ مالکش هم نامرئی می‌ماند. (`MultiClinicOwnerContextTest`) + **فیلد `permissions` در هر context:** | حالت context | مقدار `permissions` | diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index e2badc66..d9f1d3eb 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -734,8 +734,10 @@ class AuthController extends BaseController } } - // صاحب کلینیک (اگر قبلاً اضافه نشده) - if ($clinic = $this->clinicRepo->findByUser($user)) { + // صاحب کلینیک — **همهٔ** کلینیک‌هایش، نه فقط اولی: با جداسازی محیط، محیطی که + // در این فهرست نباشد قابل انتخاب نیست و دادهٔ همان کلینیک برای مالکش هم + // نامرئی می‌ماند. + foreach ($this->clinicRepo->findAllByUser($user) as $clinic) { $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid()); if (empty($alreadyAdded)) { $contexts[] = [ diff --git a/src/Clinic/Repository/ClinicRepository.php b/src/Clinic/Repository/ClinicRepository.php index b0b75aba..6a137d04 100644 --- a/src/Clinic/Repository/ClinicRepository.php +++ b/src/Clinic/Repository/ClinicRepository.php @@ -39,6 +39,21 @@ class ClinicRepository extends ServiceEntityRepository return $this->findOneBy(['user' => $user]); } + /** + * همهٔ کلینیک‌هایی که این کاربر مالکشان است. + * + * {@see findByUser()} فقط اولی را می‌دهد و برای fallbackهایی که «یک کلینیک» + * کافی است بس است؛ ولی فهرست محیط‌های قابل انتخاب باید کامل باشد، وگرنه مالکِ + * دو کلینیک هرگز نمی‌تواند به دومی سوییچ کند و با جداسازی محیط، دادهٔ کلینیک + * دومش برای خودش هم نامرئی می‌شود. + * + * @return Clinic[] + */ + public function findAllByUser(User $user): array + { + return $this->findBy(['user' => $user], ['id' => 'ASC']); + } + public function findWithFilters(array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1)); diff --git a/tests/Auth/MultiClinicOwnerContextTest.php b/tests/Auth/MultiClinicOwnerContextTest.php new file mode 100644 index 00000000..4645551a --- /dev/null +++ b/tests/Auth/MultiClinicOwnerContextTest.php @@ -0,0 +1,117 @@ +createUser(['ROLE_CLINIC']); + + $first = new Clinic($owner); + $first->setName('کلینیک اول'); + $this->em->persist($first); + + $second = new Clinic($owner); + $second->setName('کلینیک دوم'); + $this->em->persist($second); + $this->em->flush(); + + return [$owner, $first, $second]; + } + + private function switchTo(User $owner, Clinic $clinic): array + { + return $this->authJson('POST', '/api/v1/auth/switch-context', $owner, [ + 'db_uuid' => $clinic->getUuid(), + 'type' => 'clinic', + ]); + } + + /** + * ✅ هر دو کلینیک قابل انتخاب‌اند. + * + * از راه خودِ switch سنجیده می‌شود، نه از فهرست: `switchContext` ورودی را با + * همان `buildAvailableContexts` می‌سنجد، پس سوییچِ موفق یعنی محیط در فهرست بوده. + */ + public function testBothOwnedClinicsCanBeSelected(): void + { + [$owner, $first, $second] = $this->ownerOfTwoClinics(); + + $this->switchTo($owner, $first); + self::assertSame(200, $this->responseCode(), 'کلینیک اول باید قابل انتخاب باشد'); + + $this->switchTo($owner, $second); + self::assertSame(200, $this->responseCode(), 'کلینیک دوم هم باید قابل انتخاب باشد'); + } + + /** ✅ سوییچ به کلینیک دوم واقعاً انجام می‌شود. */ + public function testTheOwnerCanSwitchToTheSecondClinic(): void + { + [$owner, , $second] = $this->ownerOfTwoClinics(); + + $res = $this->authJson('POST', '/api/v1/auth/switch-context', $owner, [ + 'db_uuid' => $second->getUuid(), + 'type' => 'clinic', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($second->getUuid(), $res['data']['context']['db_uuid']); + self::assertSame('clinic', $res['data']['context']['role']); + } + + /** ❌ کلینیکِ شخص دیگر همچنان قابل انتخاب نیست. */ + public function testAnotherOwnersClinicIsStillRefused(): void + { + [$owner] = $this->ownerOfTwoClinics(); + [, $strangersClinic] = $this->ownerOfTwoClinics(); + + $this->authJson('POST', '/api/v1/auth/switch-context', $owner, [ + 'db_uuid' => $strangersClinic->getUuid(), + 'type' => 'clinic', + ]); + + self::assertSame(403, $this->responseCode()); + } + + /** + * ⚠️ مرزی: پس از سوییچ، دادهٔ همان کلینیک دیده می‌شود — این همان چیزی است که + * پیش از رفع باگ غیرممکن بود. + */ + public function testDataOfTheSecondClinicBecomesReachableAfterSwitching(): void + { + [$owner, , $second] = $this->ownerOfTwoClinics(); + + $this->authJson('POST', '/api/v1/auth/switch-context', $owner, [ + 'db_uuid' => $second->getUuid(), + 'type' => 'clinic', + ]); + self::assertSame(200, $this->responseCode()); + + $created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [ + 'bank_name' => 'ملی', + 'account_number' => '0101234567890', + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame('clinic', $created['data']['entity_type']); + + $list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $owner); + self::assertSame(['ملی'], array_column($list['data'], 'bank_name')); + } +}