Files
clinicpro/tests/Shared/ApiLeastPrivilegeTest.php
T

210 lines
12 KiB
PHP

<?php
namespace App\Tests\Shared;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Security\PermissionCatalog;
use App\Tests\ApiTestCase;
/**
* تور ایمنیِ ساختاری برای «کنترلر تازه‌ای که یادمان رفت گِیت مجوز بگذارد».
*
* آدیت ۲۰۲۶-۰۸-۰۷ نشان داد `TreatmentProtocolController` فقط
* `#[IsGranted('IS_AUTHENTICATED_FULLY')]` داشت و هیچ مجوزی را enforce نمی‌کرد.
* چنین کنترلری از دید هر ابزار ایستا سالم به نظر می‌رسد — روت گارد دارد — ولی
* عملاً برای هر کاربرِ داخل tenant باز است.
*
* پس این تست **رفتار** را می‌سنجد نه متن کد را: یک منشی می‌سازد که هر مجوزِ
* رجیستری برایش خاموش است، و هر روتِ GET بدون path parameter را با او می‌زند.
* چنین کاربری نباید به دادهٔ tenant برسد.
*
* پاسخ ۲۰۰ فقط برای روت‌هایی پذیرفته است که در `ALLOWED_200` آمده‌اند — یعنی
* عمداً عمومی‌اند یا به هیچ منبعِ مجوزداری وصل نیستند. افزودن روت به آن فهرست
* باید تصمیمِ آگاهانه باشد، نه پیش‌فرض.
*/
class ApiLeastPrivilegeTest extends ApiTestCase
{
/**
* روت‌هایی که ۲۰۰ دادنشان به منشیِ بی‌مجوز عمدی است.
*
* دو دسته‌اند: (۱) اندپوینت عمومی که بدون توکن هم کار می‌کند، (۲) اندپوینت
* «خودِ کاربر» که دادهٔ tenant نمی‌دهد و مجوزِ نقشی ندارد.
*
* @var array<string, string> 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<string, string> 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),
));
}
}