route name => دلیل */ private const ALLOWED_200 = [ // ── عمومی: بدون توکن هم ۲۰۰ می‌دهند، پس مجوز نقشی معنا ندارد ────────── 'app_blog_blog_list' => 'فهرست عمومی مقالات', 'app_blog_blog_tags' => 'تگ‌های عمومی مقالات', 'app_clinic_clinic_list' => 'فهرست عمومی کلینیک‌ها', 'app_doctor_doctor_list' => 'فهرست عمومی پزشکان', 'app_specialty_specialty_list' => 'فهرست عمومی تخصص‌ها', 'app_specialty_specialty_doctorcounts' => 'شمارش عمومی پزشکان هر تخصص', 'app_tag_tag_list' => 'تگ‌های عمومی', 'app_shared_captcha_captcha_challenge' => 'کپچا پیش از لاگین لازم است', 'app_shared_captcha_captcha_config' => 'کپچا پیش از لاگین لازم است', // ── دادهٔ مرجع/ثابت: به هیچ tenant وابسته نیست ──────────────────────── 'app_location_location_provinces' => 'فهرست استان‌ها — دادهٔ مرجع', 'app_location_location_cities' => 'فهرست شهرها — دادهٔ مرجع', 'practice_domain_list' => 'حوزه‌های فعالیت — دادهٔ مرجع', 'app_subscription_subscription_plans' => 'پلن‌های اشتراک — کاتالوگ عمومی', 'app_payment_payment_config' => 'نام درگاه‌ها و کارمزد — بدون مقدار محرمانه', 'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت', 'resource_strategies' => 'فهرست ثابتِ استراتژی‌های تخصیص منبع', 'app_clinicservice_clinicservice_listservicecategories' => 'دسته‌های ثابت خدمت (سرپایی/بستری)', 'app_inventory_inventory_meta' => 'واحدها و enumهای ثابت انبار', 'api_permission_catalog' => 'شکلِ خودِ رجیستری، نه مقدار مجوز کسی', // ── دادهٔ «خودِ کاربر»: منبعِ رجیستری نیست و مجوزی رویش تعریف نشده ──── 'app_secretary_secretary_me' => 'پروفایل خودِ منشی', 'app_secretary_secretary_earningssummary' => 'درآمد خودِ منشی', 'app_secretary_secretary_earningsreport' => 'گزارش درآمد خودِ منشی', 'app_settlement_settlement_balance' => 'کیف پول خودِ کاربر', 'app_settlement_settlement_transactions' => 'تراکنش‌های کیف پول خودِ کاربر', 'app_settlement_settlement_listmine' => 'تسویه‌های خودِ کاربر', 'app_dashboard_dashboard_secretary' => 'محیط و مجوزهای خودِ منشی — ورودی رندر پنل', // رفتار مستند: بدون مجوز فقط قابلیت‌های پلن می‌آید، نه وضعیت/تاریخ اشتراک. // تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly 'app_subscription_subscription_my' => 'نسخهٔ کاهش‌یافتهٔ عمدی', // فهرست پزشکانِ تخصیص‌یافته به همین منشی — تستش // SecretaryResourceEnforcementTest::testDoctorListReturnsOnlyAssignedDoctors 'app_appointment_myappointments_myclinicdoctors' => 'فقط پزشکانِ تخصیص‌یافته به خودِ منشی', ]; /** * بدهیِ شناخته‌شده — روت‌هایی که **باید** گِیت داشته باشند و ندارند. * * این‌ها در آدیت ۲۰۲۶-۰۸-۰۷ کشف شدند و عمداً همان جلسه رفع **نشدند**: هر سه * کنترلرشان (`BillingController`، `MyAppointmentsController`، * `DoctorServiceController`) هیچ checker مجوزی تزریق‌شده ندارند، و بستنشان * بدون دانستن نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد. * * در DB تست، tenant خالی است پس پاسخشان خالی می‌آید؛ در tenant واقعی دادهٔ * واقعی می‌دهند. نبودِ نشت در تست، دلیلِ امن‌بودن نیست. * * نقشِ این فهرست مثل baseline است: تست اجازه می‌دهد این‌ها ۲۰۰ بدهند، ولی * **بزرگ‌ترشدنش** را نمی‌پذیرد. هر روتِ تازه‌ای که بدون گِیت اضافه شود، تست را * قرمز می‌کند. حذف هر ردیف از اینجا یعنی آن گَپ بسته شد. * * @var array route name => منبعِ مجوزی که باید enforce شود */ private const KNOWN_GAPS = [ 'app_appointment_appointment_listbyuser' => 'appointments.view', 'app_appointment_myappointments_myappointments' => 'appointments.view', 'app_appointment_myappointments_todaystats' => 'appointments.view', 'app_billing_billing_listpayments' => 'payments.view', 'app_billing_billing_paymentssummary' => 'payments.view', 'app_billing_billing_listclaims' => 'payments.view', 'app_billing_billing_claimsbypatient' => 'payments.view', 'app_billing_billing_insurancedebt' => 'payments.view', 'app_doctorservice_doctorservice_list' => 'services.view', 'app_insurance_insurance_list' => 'insurances.view', ]; /** * این تست ~۱۳۰ درخواست پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا * می‌آورد، پس `$this->em` تا انتهای تست به یک نمونهٔ کهنه اشاره می‌کند. بدون * ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا — نه اینجا — می‌شکند. * (دقیقاً همان دامی که ApiTestCase::setUp برای «EntityManager is closed» بست.) */ protected function tearDown(): void { static::getContainer()->get('doctrine')->resetManager(); parent::tearDown(); } /** منشی‌ای که هیچ مجوزی ندارد — همهٔ منابع رجیستری خاموش. */ private function makePowerlessSecretary(): \App\Auth\Entity\User { $owner = $this->createUser(['ROLE_CLINIC']); $clinic = new Clinic($owner); $this->em->persist($clinic); $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); $this->em->persist($doctor); $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); $rel = new DoctorSecretary($doctor, $secretary, $clinic); $off = []; foreach (PermissionCatalog::RESOURCES as $resource => $spec) { foreach (array_keys($spec['actions']) as $action) { $off[$resource][$action] = false; } } $rel->mergePermissions(['resources' => $off]); $this->em->persist($rel); $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); $this->em->flush(); return $secretary; } public function testNoApiRouteLeaksToASecretaryWithoutAnyPermission(): void { $secretary = $this->makePowerlessSecretary(); $router = self::getContainer()->get('router'); $leaks = []; foreach ($router->getRouteCollection() as $name => $route) { $path = $route->getPath(); if (!str_starts_with($path, '/api/') || str_contains($path, '{')) { continue; } $methods = $route->getMethods(); if ($methods !== [] && !in_array('GET', $methods, true)) { continue; } if (isset(self::ALLOWED_200[$name]) || isset(self::KNOWN_GAPS[$name])) { continue; } $this->authJson('GET', $path, $secretary); if ($this->responseCode() < 400) { $leaks[] = sprintf('%s %s → %d', $name, $path, $this->responseCode()); } } $this->assertSame([], $leaks, sprintf( "این روت‌ها به منشیِ بدونِ هیچ مجوزی پاسخ موفق دادند.\n" . "اگر گِیت مجوز ندارند، اضافه‌اش کن. اگر عمدی‌اند، با دلیل به ALLOWED_200 برو.\n%s", implode("\n", $leaks), )); } /** * بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این * تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه * می‌ماند و کسی نمی‌فهمد بدهی تسویه شده. */ public function testKnownGapsAreStillGapsOrGetRemovedFromTheList(): void { $secretary = $this->makePowerlessSecretary(); $router = self::getContainer()->get('router'); $closed = []; foreach (self::KNOWN_GAPS as $name => $resource) { $route = $router->getRouteCollection()->get($name); $this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — ردیفش را از KNOWN_GAPS بردار"); $this->authJson('GET', $route->getPath(), $secretary); if ($this->responseCode() >= 400) { $closed[] = "{$name} ({$resource})"; } } $this->assertSame([], $closed, sprintf( "این گَپ‌ها بسته شده‌اند. ردیفشان را از KNOWN_GAPS بردار:\n%s", implode("\n", $closed), )); } }