438 lines
26 KiB
PHP
438 lines
26 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_payment_payment_callback' => 'callback بانک — عمدا PUBLIC؛ بدون order_id معتبر فقط صفحهٔ «یافت نشد» میدهد',
|
|
'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_userprofile_tourprogress_seen' => 'راهنماهای دیدهشدهٔ خودِ کاربر',
|
|
'app_dashboard_dashboard_secretary' => 'محیط و مجوزهای خودِ منشی — ورودی رندر پنل',
|
|
// رفتار مستند: بدون مجوز فقط قابلیتهای پلن میآید، نه وضعیت/تاریخ اشتراک.
|
|
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
|
|
'app_subscription_subscription_my' => 'نسخهٔ کاهشیافتهٔ عمدی',
|
|
// نوبتهای خودِ کاربر بهعنوان بیمار (`a.user = خودِ او`)، نه دادهٔ محیط.
|
|
// مصرفکنندهاش داشبورد بیمار در nobat724_front است. گِیتِ appointments.view
|
|
// اینجا یعنی منشیای که جایی بیمار است نوبتهای شخصیاش را نبیند.
|
|
'app_appointment_appointment_listbyuser' => 'نوبتهای خودِ کاربر بهعنوان بیمار',
|
|
|
|
// کاتالوگهای سراسری: `findActive()` بدون هیچ فیلترِ محیط. همردهٔ
|
|
// specialties و tags بالا؛ تنها فرقشان این است که پشت firewall نشستهاند.
|
|
// گِیتزدنشان یک مجوز را با نبودِ مجوزِ دیگری میشکند: منشیِ دارای
|
|
// patients.create فرمِ ثبت بیمار را با کمبوی خالیِ بیمه میگیرد.
|
|
'app_doctorservice_doctorservice_list' => 'کاتالوگ سراسری خدمات پزشک — دادهٔ مرجع',
|
|
'app_insurance_insurance_list' => 'کاتالوگ سراسری بیمهها — دادهٔ مرجع',
|
|
];
|
|
|
|
/**
|
|
* بدهیِ شناختهشده — روتهایی که **باید** گِیت داشته باشند و ندارند.
|
|
*
|
|
* **از ۲۰۲۶-۰۸-۰۸ خالی است.** ده ردیفِ اولیهاش در آدیت ۲۰۲۶-۰۸-۰۷ ثبت شده
|
|
* بود؛ هفتتایشان گِیت گرفتند و سهتا پس از خواندنِ کد مثبت کاذب درآمدند و به
|
|
* `ALLOWED_200` رفتند.
|
|
*
|
|
* فهرست عمداً باقی میماند: نقشش baseline است، و خالیبودنش یعنی «همین حالا
|
|
* بدهیای نداریم»، نه «این مکانیزم لازم نیست». ردیفِ تازه فقط با تصمیمِ آگاهانه
|
|
* اضافه شود.
|
|
*
|
|
* @var array<string, string> route name => منبعِ مجوزی که باید enforce شود
|
|
*/
|
|
private const KNOWN_GAPS = [];
|
|
|
|
/**
|
|
* روتهای نوشتنیای که منشیِ بیمجوز حق دارد از گِیت ردشان کند.
|
|
*
|
|
* سه دسته: (۱) اندپوینتِ عمومی یا پیشازلاگین؛ (۲) اکشنی روی دادهٔ خودِ کاربر که
|
|
* منبعی در `PermissionCatalog` ندارد؛ (۳) رفتارِ عمدیِ مستند.
|
|
*
|
|
* @var array<string, string> route name => دلیل
|
|
*/
|
|
private const ALLOWED_WRITE = [
|
|
// ── پیشازلاگین: اصلاً نقشی وجود ندارد که مجوز داشته باشد ─────────────
|
|
'app_auth_auth_login' => 'لاگین',
|
|
'app_auth_auth_sendcode' => 'ارسال کد تأیید',
|
|
'app_auth_auth_verifycode' => 'بررسی کد تأیید',
|
|
'app_auth_auth_register' => 'ثبتنام',
|
|
'app_auth_auth_otplogin' => 'ورود با رمز یکبارمصرف',
|
|
'app_auth_auth_resetpassword' => 'بازیابی رمز',
|
|
'app_auth_preregistration_submit' => 'پیشثبتنام عمومی',
|
|
'app_payment_payment_callback' => 'کالبک درگاه — بدون توکن فراخوانی میشود',
|
|
|
|
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
|
|
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
|
|
'app_auth_auth_switchcontext' => 'سوییچ محیطِ خودِ کاربر',
|
|
'app_auth_notificationmobile_requestotp' => 'موبایل اعلانِ خودِ کاربر',
|
|
'app_auth_notificationmobile_verify' => 'موبایل اعلانِ خودِ کاربر',
|
|
'app_auth_notificationmobile_remove' => 'موبایل اعلانِ خودِ کاربر',
|
|
'app_userprofile_userprofile_create' => 'پروفایل خودِ کاربر',
|
|
'app_userprofile_userprofile_update' => 'پروفایل خودِ کاربر',
|
|
'app_userprofile_userprofile_uploadavatar' => 'آواتار خودِ کاربر',
|
|
'app_userprofile_tourprogress_markseen' => 'ثبت دیدهشدن راهنما برای خودِ کاربر',
|
|
'app_settlement_settlement_request' => 'تسویهٔ کیفپول خودِ کاربر',
|
|
'app_secretary_secretary_addiban' => 'شبای خودِ منشی',
|
|
'app_secretary_secretary_removeiban' => 'شبای خودِ منشی',
|
|
'app_doctor_doctor_create' => 'ساخت پروفایل پزشکِ خودِ کاربر — ۴۰۹ اگر قبلاً دارد',
|
|
'app_clinic_clinic_create' => 'ساخت کلینیکِ خودِ کاربر',
|
|
'app_doctor_doctorclaim_claim' => 'ادعای مالکیتِ پروفایل پزشک توسط خودِ فرد',
|
|
'app_clinicinvitation_clinicinvitation_acceptinvitation' => 'پذیرشِ دعوتِ خودِ فرد',
|
|
'app_clinicinvitation_clinicinvitation_rejectinvitation' => 'ردِ دعوتِ خودِ فرد',
|
|
|
|
// ── اکشن بیمار روی محتوای عمومی ──────────────────────────────────────
|
|
'app_rating_rating_rate' => 'امتیازدهی بیمار',
|
|
'app_rating_rating_createcomment' => 'ثبت نظر بیمار',
|
|
'app_rating_rating_deletecomment' => 'حذف نظرِ خودِ فرد',
|
|
'app_rating_rating_togglelike' => 'لایک بیمار',
|
|
|
|
// ── جریان رزرو عمومی: مصرفکنندهاش nobat724_front است، نه پنل ────────
|
|
'app_appointment_appointment_book' => 'رزرو نوبت توسط خودِ بیمار',
|
|
'appointment_availability' => 'وقتهای آزاد — ورودی رزرو عمومی',
|
|
'appointment_hold_create' => 'نگهداشتن موقت اسلات در جریان رزرو',
|
|
'appointment_hold_release' => 'آزادکردن اسلاتِ نگهداشتهشده',
|
|
'appointment_confirm' => 'تأیید نهایی رزرو عمومی',
|
|
'appointment_rebook' => 'رزرو مجدد توسط خودِ بیمار',
|
|
'pricing_quote' => 'محاسبهٔ قیمت پیش از رزرو',
|
|
'app_payment_payment_initiateappointment' => 'پرداختِ نوبتِ خودِ بیمار',
|
|
|
|
// ── رفتار عمدیِ مستند ────────────────────────────────────────────────
|
|
// حذف سرویس/بخش اصلاً ممکن نیست و کنترلر بیقیدوشرط ۴۰۹ میدهد، پس هرگز
|
|
// به لایهٔ مجوز نمیرسد. دلیلش در خودِ ClinicServiceController نوشته شده:
|
|
// نوبت و فاکتور و سوابق پرداخت به سرویس ارجاع دارند.
|
|
'app_clinicservice_clinicservice_deleteitem' => 'حذف ممنوع — همیشه ۴۰۹',
|
|
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
|
|
|
|
// ── گِیت دارند ولی هدفشان از بدنه میآید، نه از path ──────────────────
|
|
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده میشود',
|
|
];
|
|
|
|
/**
|
|
* روتهایی که پیشچکِ منشی **پیش از واکشی** دارند، پس حتی با uuidِ ناموجود هم
|
|
* باید `403` بدهند نه `404`.
|
|
*
|
|
* این فهرست پیشرفتِ یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷ را قفل میکند: چکِ اصلیِ این روتها
|
|
* شیءمحور است و بالا نمیرود، ولی سهمِ منشی از آن بالا برده شد. اگر کسی آن خط را
|
|
* بردارد، پاسخ به `404` برمیگردد و همین تست قرمز میشود.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const GATE_BEFORE_LOOKUP = [
|
|
'app_appointment_appointment_updatestatus',
|
|
'app_appointment_appointment_confirm',
|
|
'app_appointment_appointment_update',
|
|
'app_appointment_appointment_servicereschedule',
|
|
'app_appointment_appointmentsettings_createschedule',
|
|
'app_appointment_appointmentsettings_updateschedule',
|
|
'app_appointment_appointmentsettings_deleteschedule',
|
|
'app_appointment_appointmentsettings_createoverride',
|
|
'app_appointment_appointmentsettings_updateoverride',
|
|
'app_appointment_appointmentsettings_deleteoverride',
|
|
'app_appointment_appointmentsettings_createholiday',
|
|
'app_appointment_appointmentsettings_updateholiday',
|
|
'app_appointment_appointmentsettings_deleteholiday',
|
|
'app_clinic_clinic_update',
|
|
'app_clinic_clinic_detachdoctor',
|
|
'app_clinic_clinicdoctorpermission_updatepermissions',
|
|
'app_clinicinvitation_clinicinvitation_invitedoctor',
|
|
'app_clinicinvitation_clinicinvitation_resendinvitation',
|
|
'app_clinicinvitation_clinicinvitation_changeinvitationstatus',
|
|
'app_clinicinvitation_clinicinvitation_deleteinvitation',
|
|
'resource_block_create',
|
|
'resource_block_delete',
|
|
];
|
|
|
|
/**
|
|
* این تست ~۱۳۰ درخواست پشتسرهم میزند و هر درخواست کرنل را دوباره بالا
|
|
* میآورد، پس `$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),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* مقدارِ جایگزینِ یک path parameter — طوری که روت **match شود** ولی رکوردی
|
|
* پیدا نشود.
|
|
*
|
|
* اگر مقدار با requirement نخواند، Symfony قبل از رسیدن به کنترلر ۴۰۴ میدهد و
|
|
* تست بیدلیل قرمز میشود. پس alternationهای ساده (`submit|approve|…`) اولین
|
|
* شاخهشان برداشته میشود، عددیها `999999999` میگیرند و بقیه uuidِ صفر.
|
|
*/
|
|
private static function sampleValueFor(string $name, ?string $requirement): string
|
|
{
|
|
$nilUuid = '00000000-0000-0000-0000-000000000000';
|
|
|
|
if (preg_match('/^[a-z_]+(\|[a-z_]+)+$/i', (string) $requirement)) {
|
|
return explode('|', $requirement)[0];
|
|
}
|
|
if (preg_match('/^(\\\\d\+?|\[0-9\]\+)$/', (string) $requirement)) {
|
|
return '999999999';
|
|
}
|
|
// بدون requirement، نامِ پارامتر تنها سرنخِ نوعِ آرگومانِ کنترلر است. uuid
|
|
// فرستادن به `int $id` قبل از رسیدن به کنترلر ۵۰۰ میدهد، نه ۴۰۴.
|
|
if ($name === 'id' || str_ends_with($name, 'Id') || str_ends_with($name, '_id')) {
|
|
return '999999999';
|
|
}
|
|
|
|
return $nilUuid;
|
|
}
|
|
|
|
/**
|
|
* روتِ نوشتنی با پارامترهای جایگزینشده؛ null اگر پارامتری داشت که نمیشد
|
|
* مقدارِ مطمئنی برایش ساخت.
|
|
*/
|
|
private static function probePath(\Symfony\Component\Routing\Route $route): string
|
|
{
|
|
$path = $route->getPath();
|
|
|
|
return preg_replace_callback(
|
|
'/\{!?(\w+)\}/',
|
|
static fn(array $m) => self::sampleValueFor($m[1], $route->getRequirement($m[1])),
|
|
$path,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* قرینهٔ تستِ بالا برای `POST`/`PUT`/`PATCH`/`DELETE`.
|
|
*
|
|
* بدنهٔ معتبر لازم نیست و عمداً فرستاده نمیشود. استدلالش همان یافتهٔ ۱ آدیت
|
|
* ۲۰۲۶-۰۸-۰۷ است، وارونه: آنجا `422` شاهدِ **عبور** از لایهٔ authorization بود،
|
|
* چون کد خطا از داخلِ writer میآمد. پس `2xx` یا `422` برای منشیِ بیمجوز یعنی
|
|
* گِیت نخورده و فقط اعتبارسنجی جلویش را گرفته.
|
|
*
|
|
* قاعده دو سطحی است، چون `404` دو معنای متفاوت دارد:
|
|
*
|
|
* - **روتِ بدون path parameter** باید دقیقاً `403` بدهد. چیزی برای واکشی وجود
|
|
* ندارد، پس هیچ توجیهی برای پاسخِ دیگر نیست.
|
|
* - **روتِ پارامتردار** `403` یا `404` هر دو قبول است. uuidِ ناموجود میفرستیم و
|
|
* بیشترِ کنترلرهای این پروژه اول رکورد را واکشی میکنند و بعد مجوز را
|
|
* میسنجند، پس `404` میدهند بیآنکه بیگِیت باشند.
|
|
*
|
|
* محدودیتِ صادقانهٔ سطح دوم: با `404` نمیشود «گِیت بعد از واکشی» را از «اصلاً
|
|
* گِیت ندارد» تفکیک کرد. تفکیکش رکوردِ واقعی در tenantِ همین منشی میخواهد،
|
|
* یعنی fixture به ازای هر روت. فهرستِ ۳۴ روتی که گِیتشان بعد از واکشی است در
|
|
* `docs/security/AUDIT-2026-08-07.md` (یافتهٔ ۱۰) ثبت شده تا بدهی گم نشود.
|
|
*/
|
|
public function testNoApiWriteRouteSkipsItsPermissionGate(): void
|
|
{
|
|
$secretary = $this->makePowerlessSecretary();
|
|
$router = self::getContainer()->get('router');
|
|
|
|
$ungated = [];
|
|
foreach ($router->getRouteCollection() as $name => $route) {
|
|
if (!str_starts_with($route->getPath(), '/api/')) {
|
|
continue;
|
|
}
|
|
$writeMethods = array_values(array_intersect(
|
|
$route->getMethods(),
|
|
['POST', 'PUT', 'PATCH', 'DELETE'],
|
|
));
|
|
if ($writeMethods === [] || isset(self::ALLOWED_WRITE[$name])) {
|
|
continue;
|
|
}
|
|
|
|
$hasPathParam = str_contains($route->getPath(), '{');
|
|
$probe = self::probePath($route);
|
|
|
|
$this->authJson($writeMethods[0], $probe, $secretary);
|
|
$code = $this->responseCode();
|
|
|
|
$accepted = $hasPathParam ? in_array($code, [403, 404], true) : $code === 403;
|
|
if (!$accepted) {
|
|
$ungated[] = sprintf('%s %s %s → %d', $name, $writeMethods[0], $probe, $code);
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $ungated, sprintf(
|
|
"این روتهای نوشتنی به منشیِ بدونِ هیچ مجوزی گِیت مجوز را رد کردند.\n"
|
|
. "روتِ بدون parameter باید ۴۰۳ بدهد؛ روتِ پارامتردار ۴۰۳ یا ۴۰۴.\n"
|
|
. "اگر عمدیاند، با دلیل به ALLOWED_WRITE برو.\n%s",
|
|
implode("\n", $ungated),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* قفلِ پیشرفت: هر روتِ `GATE_BEFORE_LOOKUP` با uuidِ ناموجود باید `403` بدهد.
|
|
*
|
|
* `404` یعنی گِیت دوباره پایینتر از واکشی رفته و enumeration oracle برگشته.
|
|
*/
|
|
public function testHoistedGatesAnswer403BeforeTheLookup(): void
|
|
{
|
|
$secretary = $this->makePowerlessSecretary();
|
|
$router = self::getContainer()->get('router');
|
|
|
|
$regressed = [];
|
|
foreach (self::GATE_BEFORE_LOOKUP as $name) {
|
|
$route = $router->getRouteCollection()->get($name);
|
|
$this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — فهرست را بهروز کن");
|
|
|
|
$method = array_values(array_intersect(
|
|
$route->getMethods(),
|
|
['POST', 'PUT', 'PATCH', 'DELETE'],
|
|
))[0];
|
|
|
|
$this->authJson($method, self::probePath($route), $secretary);
|
|
if ($this->responseCode() !== 403) {
|
|
$regressed[] = sprintf('%s → %d', $name, $this->responseCode());
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $regressed, sprintf(
|
|
"این روتها دیگر پیش از واکشی گِیت نمیخورند:\n%s",
|
|
implode("\n", $regressed),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* بدهی نباید بیصدا بماند: بهمحض اینکه گِیتِ یکی از 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),
|
|
));
|
|
}
|
|
}
|