From 6876135a530f802e262d9da311c53a3ea8cdcc4d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 21:13:38 +0330 Subject: [PATCH] feat: add BlogBodySanitizer for HTML sanitization on article save - Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control. --- .../prompt/security-audit-delta-2026-08.md | 520 ++++++++++++++++++ .claude/skills/qa-clinicpro/driver.mjs | 11 +- .env.test | 5 +- assets/admin/App.tsx | 2 +- .../components/AppointmentActions.test.tsx | 4 +- .../admin/components/AppointmentActions.tsx | 2 +- .../admin/components/ClinicDoctorsManager.tsx | 2 +- .../admin/components/InvoiceSummaryModal.tsx | 2 +- .../admin/components/ServiceCategoryTab.tsx | 2 +- .../dashboard/NewAppointmentsTable.tsx | 2 +- .../dashboard/TauriDashboardView.tsx | 2 +- .../holidays/NationalHolidaysCard.tsx | 2 +- .../admin/components/layout/AdminLayout.tsx | 2 +- .../components/layout/ProfileMenu.test.tsx | 4 +- .../admin/components/layout/ProfileMenu.tsx | 2 +- .../layout/PurchaseSubscriptionSidebar.tsx | 2 +- assets/admin/components/layout/Sidebar.tsx | 2 +- .../admin/components/layout/Topbar.test.tsx | 4 +- assets/admin/components/layout/Topbar.tsx | 2 +- .../components/resources/ResourcesSubNav.tsx | 2 +- .../admin/components/ui/BackButton.test.tsx | 2 +- assets/admin/components/ui/FeatureGate.tsx | 2 +- assets/admin/components/ui/PageHeader.tsx | 2 +- assets/admin/hooks/useGoBack.ts | 2 +- assets/admin/hooks/useUrlState.test.tsx | 2 +- assets/admin/hooks/useUrlState.ts | 2 +- assets/admin/index.tsx | 2 +- assets/admin/pages/AppointmentCreatePage.tsx | 2 +- .../pages/AppointmentDetailPage.test.tsx | 2 +- assets/admin/pages/AppointmentDetailPage.tsx | 2 +- .../AppointmentEditPage.serviceMode.test.tsx | 2 +- .../admin/pages/AppointmentEditPage.test.tsx | 2 +- assets/admin/pages/AppointmentEditPage.tsx | 2 +- assets/admin/pages/AppointmentsPage.tsx | 2 +- assets/admin/pages/BlogFormPage.test.tsx | 2 +- assets/admin/pages/BlogFormPage.tsx | 2 +- assets/admin/pages/BlogsPage.tsx | 2 +- assets/admin/pages/ClaimPatientDetailPage.tsx | 2 +- assets/admin/pages/ClaimsPage.tsx | 2 +- .../pages/ClinicAppointmentSettingsPage.tsx | 2 +- assets/admin/pages/ClinicDetailPage.test.tsx | 2 +- assets/admin/pages/ClinicDetailPage.tsx | 2 +- assets/admin/pages/ClinicDoctorsPage.tsx | 2 +- assets/admin/pages/ClinicFormPage.tsx | 2 +- assets/admin/pages/ClinicServicesPage.tsx | 2 +- assets/admin/pages/ClinicsPage.tsx | 2 +- assets/admin/pages/DashboardPage.test.tsx | 4 +- assets/admin/pages/DashboardPage.tsx | 2 +- assets/admin/pages/DoctorDetailPage.tsx | 2 +- assets/admin/pages/DoctorFormPage.tsx | 2 +- assets/admin/pages/DoctorsPage.tsx | 2 +- assets/admin/pages/EditSessionPage.tsx | 2 +- assets/admin/pages/LoginPage.tsx | 2 +- assets/admin/pages/MyPatientsPage.tsx | 2 +- .../admin/pages/MyPaymentDetailPage.test.tsx | 2 +- assets/admin/pages/MyPaymentDetailPage.tsx | 2 +- assets/admin/pages/MyPaymentsPage.test.tsx | 4 +- assets/admin/pages/MyPaymentsPage.tsx | 2 +- assets/admin/pages/MySecretariesPage.tsx | 2 +- assets/admin/pages/NewSessionPage.test.tsx | 2 +- assets/admin/pages/NewSessionPage.tsx | 2 +- assets/admin/pages/PatientDetailPage.test.tsx | 2 +- assets/admin/pages/PatientDetailPage.tsx | 2 +- assets/admin/pages/PatientRecordFormPage.tsx | 2 +- assets/admin/pages/PatientsListPage.tsx | 2 +- assets/admin/pages/PaymentDetailPage.tsx | 2 +- .../admin/pages/PaymentSuccessPage.test.tsx | 4 +- assets/admin/pages/PaymentSuccessPage.tsx | 2 +- assets/admin/pages/PaymentsPage.tsx | 2 +- .../pages/RepresentationBlogFormPage.tsx | 2 +- .../admin/pages/RepresentationBlogsPage.tsx | 2 +- .../admin/pages/RepresentationDetailPage.tsx | 2 +- assets/admin/pages/RepresentationsPage.tsx | 2 +- .../admin/pages/ReserveAppointmentsPage.tsx | 2 +- assets/admin/pages/ResourceBookingPage.tsx | 2 +- .../admin/pages/ResourceDetailPage.test.tsx | 2 +- assets/admin/pages/ResourceDetailPage.tsx | 2 +- assets/admin/pages/ResourcesPage.tsx | 2 +- assets/admin/pages/SecretariesPage.tsx | 2 +- .../admin/pages/SecretaryDetailPage.test.tsx | 2 +- assets/admin/pages/SecretaryDetailPage.tsx | 2 +- assets/admin/pages/SelectContextPage.tsx | 2 +- assets/admin/pages/ServiceDetailPage.test.tsx | 2 +- assets/admin/pages/ServiceDetailPage.tsx | 2 +- .../admin/pages/SessionPaymentPage.test.tsx | 2 +- assets/admin/pages/SessionPaymentPage.tsx | 2 +- assets/admin/pages/SettingsMenuPage.tsx | 2 +- assets/admin/pages/SettlementDetailPage.tsx | 2 +- assets/admin/pages/SettlementsPage.tsx | 2 +- .../pages/StaffSessionDetailPage.test.tsx | 4 +- assets/admin/pages/StaffSessionDetailPage.tsx | 2 +- .../pages/StaffTreatmentSessionsPage.tsx | 2 +- assets/admin/pages/TreatmentCasesPage.tsx | 2 +- assets/admin/pages/UserDetailPage.tsx | 2 +- assets/admin/pages/UsersPage.tsx | 2 +- assets/admin/test/utils.tsx | 2 +- composer.json | 1 + composer.lock | 500 ++++++++++++++--- config/packages/html_sanitizer.yaml | 67 +++ config/reference.php | 2 +- config/services.yaml | 5 + docs/api/treatment.md | 10 +- docs/security/AUDIT-2026-08-07.md | 406 ++++++++++++++ package-lock.json | 62 +-- package.json | 5 +- src/Blog/Controller/BlogController.php | 7 +- .../RepresentationBlogController.php | 7 +- src/Blog/Service/BlogBodySanitizer.php | 37 ++ src/Payment/Gateway/MellatGateway.php | 55 +- .../TreatmentProtocolController.php | 25 + tests/Blog/BlogBodySanitizerTest.php | 111 ++++ .../SecretaryResourceEnforcementTest.php | 61 ++ tests/Shared/ApiLeastPrivilegeTest.php | 196 +++++++ yarn.lock | 43 +- 114 files changed, 2067 insertions(+), 269 deletions(-) create mode 100644 .claude/prompt/security-audit-delta-2026-08.md create mode 100644 config/packages/html_sanitizer.yaml create mode 100644 docs/security/AUDIT-2026-08-07.md create mode 100644 src/Blog/Service/BlogBodySanitizer.php create mode 100644 tests/Blog/BlogBodySanitizerTest.php create mode 100644 tests/Shared/ApiLeastPrivilegeTest.php diff --git a/.claude/prompt/security-audit-delta-2026-08.md b/.claude/prompt/security-audit-delta-2026-08.md new file mode 100644 index 00000000..a0ed4d5c --- /dev/null +++ b/.claude/prompt/security-audit-delta-2026-08.md @@ -0,0 +1,520 @@ +# آدیت امنیتی دلتایی — سطح حملهٔ ساخته‌شده بعد از ۲۰۲۶-۰۷-۱۹ + +## زمینه + +آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا +امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آن‌ها دقیقاً روی سطحی است +که آدیت قبلی ندیده بود: + +- رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog` +- بازنویسی گیت‌های منشی و پزشکِ مهمانِ کلینیک +- دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل +- نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber` +- `PatientRecordScopeResolver` برای محدودکردن دید پرونده‌ها + +آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر +`admin` و `doctor` در DB بود. آن محدودیت حالا برطرف‌شدنی است. + +این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافته‌های قبلی فقط regression می‌شوند، +و بودجهٔ اصلی صرف کدی می‌شود که هرگز آدیت نشده. + +## مشکل / هدف + +**مشکل:** بزرگ‌ترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچ‌وقت +تست امنیتی نشده. یک رجیستری مجوز که در UI رندر می‌شود ولی در backend enforce نشود، یعنی هر +منشی/پزشکِ مهمان می‌تواند با یک درخواست مستقیم به API از مجوزش فرار کند. + +**هدف:** پیدا کردن و رفع آسیب‌پذیری‌های این سطح، به‌علاوهٔ تأیید اینکه یافته‌های آدیت قبلی +برنگشته‌اند. + +**سیاست رفع (تصمیم کاربر):** + +- 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود. +- 🟨 Medium و پایین‌تر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن. + +**خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به +پکیج umbrella `ckeditor5` v45+. فقط به‌عنوان «risk پذیرفته‌شده» در گزارش ثبت شود، پیاده نشود. + +## معیار پذیرش + +- ✅ **موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید + اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که + ثابت می‌کند خاموش‌بودن آن مجوز، درخواستِ متناظر API را با **403** رد می‌کند — نه اینکه فقط + دکمه را در UI پنهان کند. +- ❌ **خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر → + **403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ + tenant دیگر و هرگز 500 با stack trace. +- ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از + `POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو + نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله + مسیرهایی که بعد از نوشتن گارد اضافه شده‌اند ــ 403 می‌گیرد. +- ✅ تست‌ها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`. +- ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه به‌روز شد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** | +| `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقش‌ها (`roles`) | +| `docs/security/AUDIT-2026-07-19.md` | یافته‌های قبلی برای regression | +| `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمی‌تر) | +| `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشن‌های مجوزدهی | +| `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` | +| `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی | +| `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی | +| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان | +| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان | +| `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل | +| `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل | +| `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار | +| `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان | +| `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس | +| `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل | +| `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاه‌ها | +| `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط | +| `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط | +| `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant | +| `config/packages/security.yaml` | firewall و مسیرهای public | +| `assets/admin/` | پنل ادمین؛ مجوزهای UI | +| `docs/architecture/tenancy.md` | «چه تضمین می‌دهد و چه نمی‌دهد» | + +## وضعیت فعلی + +### ۱. عدم تقارن گیت در دامنهٔ Treatment — قوی‌ترین lead + +`TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متد‌به‌متد می‌سنجد: + +```php +// src/Treatment/Controller/TreatmentCaseController.php:31 +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class TreatmentCaseController extends BaseController +{ + #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + $case = $this->requireCase($user, $uuid); + // ... + } + + #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + // ... + } +} +``` + +ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی +`PUT`، نه روی `DELETE`: + +```php +// src/Treatment/Controller/TreatmentProtocolController.php:24 +#[IsGranted('IS_AUTHENTICATED_FULLY')] +class TreatmentProtocolController extends BaseController +{ + #[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])] + public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data); + + return $this->success($protocol->toArray()); + } +} +``` + +یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز +اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشی‌ای که مجوز `services` ندارد — می‌تواند پروتکل +درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.** + +### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر + +```php +// src/Staff/Security/StaffRouteGuardSubscriber.php +private const ALLOWED_PREFIXES = [ + '/api/v1/dashboard/staff', + '/api/v1/auth/switch-context', + '/api/v1/user/change-password', +]; + +private const OVERRIDING_ROLES = [ + 'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION', +]; + +public function onKernelRequest(RequestEvent $event): void +{ + // ... + $path = $event->getRequest()->getPathInfo(); + if (!str_starts_with($path, '/api/')) { + return; + } + // ... + foreach (self::ALLOWED_PREFIXES as $prefix) { + if (str_starts_with($path, $prefix)) { + return; + } + } + + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403); +} +``` + +دو ریسک ساختاری که باید تست شوند: + +- گارد روی `getPathInfo()` و `str_starts_with` کار می‌کند. مسیر نرمال‌نشده + (`/api/v1/../v1/patients`، دابل‌اسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از + allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه. +- `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار می‌گذارد. اگر کاربری هم پرسنل + و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمی‌شود. + +### ۳. یافته‌های باز از ۲۰۲۶-۰۷-۱۹ + +| # | یافته | شدت | وضعیت ثبت‌شده | +|---|-------|-----|----------------| +| 2 | `lodash` code injection via `_.template` | 🟧 High | نیمه‌رفع | +| 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت | +| 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز | + +### ۴. وضعیت اکانت‌های تست + +`TEST_USERS.md` بی‌اعتبار است و `create_test_users.php` که به آن ارجاع می‌دهد در repo نیست. + +**وضعیت واقعی DB لوکال، سنجیده‌شده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی +`driver.mjs roles` و `ddev mysql`: + +``` +admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق +representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق +doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست +clinic 09127000000 — ✗ کاربر در DB نیست +secretary 09123456778 — ✗ کاربر در DB نیست +``` + +DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند: + +``` +id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است +id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است +id=24 0912000301 ROLE_USER,ROLE_CLINIC +id=5 0912000109 ROLE_USER,ROLE_SECRETARY +id=15 0912000209 ROLE_USER,ROLE_SECRETARY +id=28 0912000309 ROLE_USER,ROLE_SECRETARY +``` + +پس کاربر پرسنل و کاربر چندنقشی **ساخته نمی‌شوند** — فقط پسوردشان باید معلوم/ست شود. +پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش. + +--- + +## وظایف + +### ۱. آماده‌سازی ماتریس نقش‌ها + +اول وضعیت واقعی اکانت‌ها را بسنج، بعد کمبود را بساز: + +```bash +ddev start +node .claude/skills/qa-clinicpro/driver.mjs roles +``` + +کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته +نمی‌شوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن: + +```bash +ddev exec php bin/console security:hash-password 'QaTest@1234' +ddev mysql -e "UPDATE users SET password_hash='' WHERE mobile_number='09128726723';" +``` + +این تغییر فقط روی DB لوکال است و در گزارش ثبت می‌شود. + +علاوه بر آن، برای تست IDOR لازم است: + +- **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار. + اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز. + +خروجی این وظیفه یک جدول creds در ابتدای گزارش است. + +**نحوه تست:** برای هر کاربر ساخته‌شده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در +گزارش، `roles` هر توکن decode‌شده ثبت شود. + +--- + +### ۲. اجرای درایور به‌عنوان baseline + +**با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق +`.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config — +black-box: authz/headers/cors/injection). + +خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و +leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹ +رعایت کرده بود. + +**نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود. + +--- + +### ۳. regression یافته‌های آدیت قبلی + +برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان +گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است: + +```bash +# CSP روی SPA ادمین +curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy + +# APP_SECRET در فایل env تحت git +git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET=' + +# فلگ‌های session cookie +ddev exec php bin/console debug:config framework session + +# وابستگی‌های npm +npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)" +``` + +هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت می‌شود — چون قبلاً رفع شده بوده +و دوباره شکسته. + +**نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش. + +--- + +### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت + +`PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این +سه سؤال جواب داده شود: + +1. کدام اندپوینت(ها) این مجوز را نمایندگی می‌کنند؟ +2. آیا backend واقعاً enforce می‌کند، یا فقط UI دکمه را پنهان می‌کند؟ +3. آیا منشی و پزشکِ مهمان **هر دو** enforce می‌شوند، یا فقط یکی؟ + +روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن. + +```bash +TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])') + +# مجوز treatment.update خاموش است → باید 403 بدهد +curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \ + https://clinic-pro.ddev.site/api/v1/treatment-case/ \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"status":"closed"}' +``` + +هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود +عمداً باز است — که آن‌وقت باید در `docs/api/` مستند باشد. + +خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش. + +**نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد. + +--- + +### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت + +سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً: + +الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن +`requireItem()` دقیقاً چه می‌سنجد — فقط tenant، یا مجوز اکشن هم؟ + +```bash +# با توکن منشیِ بدون مجوز services +curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \ + https://clinic-pro.ddev.site/api/v1/service-item//treatment-protocol \ + -H "Authorization: Bearer $SECRETARY_TOKEN" +# انتظار: 403 — اگر 200/204 داد، یافتهٔ High +``` + +ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر: + +``` +GET /api/v1/treatment-case/{uuid} +PATCH /api/v1/treatment-case/{uuid} +GET /api/v1/treatment-case/{uuid}/plan +GET /api/v1/treatment-session/{uuid} +GET /api/v1/treatment-session/{uuid}/slot-suggestions +GET /api/v1/service-item/{uuid}/treatment-protocol +PUT /api/v1/service-item/{uuid}/treatment-protocol +DELETE /api/v1/service-item/{uuid}/treatment-protocol +POST /api/v1/dashboard/staff/treatment-session/{uuid}/start +POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish +POST /api/v1/dashboard/staff/session-area/{uuid}/start +POST /api/v1/dashboard/staff/session-area/{uuid}/complete +POST /api/v1/dashboard/staff/session-area/{uuid}/skip +POST /api/v1/dashboard/staff/session-area/{uuid}/reopen +``` + +انتظار: 403 یا 404. هر 200 یا 500 یافته است. + +ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد +شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد می‌شود. + +د) **دستکاری وضعیت.** `finish` روی جلسه‌ای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته، +`complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود. + +**نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit +در `tests/` که آن مسیر را با کاربر بیگانه می‌زند و 403 انتظار دارد. + +--- + +### ۶. آدیت فرار از tenant + +`docs/architecture/tenancy.md` صریح می‌گوید `TenantFilter` یک تور ایمنی است، نه authorization، +و روی سه چیز اعمال **نمی‌شود**: SQL خام، `getReference()`، و فرزندان aggregate. + +```bash +# SQL خام +grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php + +# getReference +grep -rn "getReference(" src/ --include=*.php + +# entityهای بدون طبقه‌بندی tenant +ddev exec php bin/phpunit --filter TenantSchemaCoverageTest +``` + +هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری می‌شود؟ tenant دستی چک شده؟ + +**نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر می‌گیرد، +یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتری‌سازی شده. + +--- + +### ۷. آدیت گاردِ پرسنل + +الف) **نرمال‌سازی مسیر** (بخش «وضعیت فعلی ۲»): + +```bash +for P in \ + '/api/v1/patients' \ + '/api//v1/patients' \ + '/api/v1/dashboard/staff/../../patients' \ + '/api/v1/%2e%2e/v1/patients' \ + '/API/v1/patients' ; do + printf '%s -> ' "$P" + curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \ + "https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN" +done +``` + +هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است. + +ب) **کشف مسیرهای تازه‌ای که گارد نمی‌بیند.** فهرست کامل روت‌ها را بگیر و همه را با توکن پرسنل +بزن: + +```bash +ddev exec php bin/console debug:router --format=json > /tmp/routes.json +``` + +اندازهٔ واقعی (سنجیده‌شده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت +`GET` و **۱۲۸** روت `GET` بدون path parameter. + +**سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و +404 آن با 403 قابل تفکیک نیست. روت‌های پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده +می‌شوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع. + +برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است. + +ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز +به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل +بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقش‌ها. + +**نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش. + +--- + +### ۸. آدیت پنل ادمین + +مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را +پنهان می‌کند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را می‌دهد؛ اینجا فقط +نگاشت UI به endpoint ثبت شود. + +علاوه بر آن: + +- توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را + بدون sanitize رندر نمی‌کند (`dangerouslySetInnerHTML`). +- CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای + CSP در کنسول نمی‌دهند. + +```bash +grep -rn "dangerouslySetInnerHTML" assets/admin/ +``` + +**نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را +می‌دهد، از همان استفاده کن. + +--- + +### ۹. رفع + +طبق سیاست تعیین‌شده: + +- **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف + بزرگ. +- **Medium و پایین‌تر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**. + +قواعد پروژه هنگام رفع: + +- گیت جدید در همان لایه‌ای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه + یک مکانیزم موازی جدید. +- منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection. +- خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از + `src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همان‌جا اضافه شود. +- اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`. + +**نحوه تست:** + +```bash +ddev exec php bin/phpunit +ddev exec php vendor/bin/phpstan analyse +``` + +--- + +### ۱۰. گزارش + +فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹: + +1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت` +2. جدول creds نقش‌ها که آدیت با آن اجرا شد +3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمال‌شده، علت انتخاب + راه‌حل +4. جدول کامل ماتریس مجوز (وظیفهٔ ۴) +5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند +6. «leadهایی که رد شدند» با دلیل +7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا +8. «risk پذیرفته‌شده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز + +--- + +## نکات مهم + +- **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نمی‌شود. خروجی درایور lead است، نه + یافته. این قانون از گزارش قبلی می‌آید و باید حفظ شود. +- **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و + کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو. +- **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط می‌گوید کاربر پرسنل است، نه اینکه این جلسه + مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است. +- **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate + اعمال نمی‌شود. `docs/architecture/tenancy.md` مرجع است. +- **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش + یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن. +- **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست + تست شود که هنوز 200 می‌دهد. +- **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر + ثبت شود. این قانون ایستادهٔ پروژه است. +- **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف + می‌کند، در گزارش هشدار بده — build کلاینت خطا نمی‌دهد. +- **بدون DoS.** آدیت روی محیط لوکال ddev اجرا می‌شود. تست rate limit با چند درخواست شمارشی، نه + با سیل ترافیک. +- **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده. diff --git a/.claude/skills/qa-clinicpro/driver.mjs b/.claude/skills/qa-clinicpro/driver.mjs index 5c870e92..71cd7a5d 100644 --- a/.claude/skills/qa-clinicpro/driver.mjs +++ b/.claude/skills/qa-clinicpro/driver.mjs @@ -33,12 +33,17 @@ const PORT = Number(process.env.CDP_PORT ?? 9444); * provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles` * until they are. TEST_USERS.md is stale — its accounts do not exist. */ +// Re-verified 2026-08-07 by real logins: the DB was reseeded, so the old clinic +// and secretary numbers are gone and 09390039833 is now ROLE_CLINIC, not doctor. +// `staff` was seeded with its mobile as the password rather than QaTest@1234. const ROLES = { admin: ['09120671756', 'QaTest@1234'], - clinic: ['09127000000', 'QaTest@1234'], - secretary: ['09123456778', 'QaTest@1234'], - doctor: ['09390039833', 'QaTest@1234'], + clinic: ['09390039833', 'QaTest@1234'], + secretary: ['0912000109', 'QaTest@1234'], + doctor: ['0912000101', 'QaTest@1234'], representation: ['09124000001', 'QaTest@1234'], + staff: ['09128726723', '09128726723'], + multirole: ['0912000201', 'QaTest@1234'], // Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234. doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic diff --git a/.env.test b/.env.test index 78a65c22..1b5785a6 100644 --- a/.env.test +++ b/.env.test @@ -1,6 +1,9 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' +# مقدارِ ثابت و آشکارا غیرعملیاتی: محیط تست هیچ‌وقت به داده یا کاربر واقعی وصل +# نمی‌شود، و همین شفافیت جلوی این را می‌گیرد که کسی این فایل را منبع یک secret +# واقعی بپندارد. secret واقعی فقط در `.env.local` و خارج از git است. +APP_SECRET='not-a-secret-test-env-only' # Test DB: doctrine's when@test config appends the `_test` suffix (see # config/packages/doctrine.yaml), so this base name `db` becomes `db_test`. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index e907df2a..af9b3ccb 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation, useParams } from 'react-router'; import { useAuthStore } from './stores/authStore'; import { usePermissions } from './hooks/usePermissions'; import AdminLayout from './components/layout/AdminLayout'; diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx index e87cb09f..9be63071 100644 --- a/assets/admin/components/AppointmentActions.test.tsx +++ b/assets/admin/components/AppointmentActions.test.tsx @@ -16,8 +16,8 @@ const patch = api.patch as ReturnType; const post = api.post as ReturnType; const navigate = vi.fn(); -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), +vi.mock('react-router', async () => ({ + ...(await vi.importActual('react-router')), useNavigate: () => navigate, })); diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index e8c7deb4..70883c89 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -16,7 +16,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useEffect, useMemo, useRef, useState } from "react"; import ReactDOM from "react-dom"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { toast } from "sonner"; import type { ApiResponse } from "../lib/api"; import { api } from "../lib/api"; diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx index a9f0099c..65b57e14 100644 --- a/assets/admin/components/ClinicDoctorsManager.tsx +++ b/assets/admin/components/ClinicDoctorsManager.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon, diff --git a/assets/admin/components/InvoiceSummaryModal.tsx b/assets/admin/components/InvoiceSummaryModal.tsx index fe2d8d4a..e7fcdcf6 100644 --- a/assets/admin/components/InvoiceSummaryModal.tsx +++ b/assets/admin/components/InvoiceSummaryModal.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; diff --git a/assets/admin/components/ServiceCategoryTab.tsx b/assets/admin/components/ServiceCategoryTab.tsx index b04f0ac7..4c933f1b 100644 --- a/assets/admin/components/ServiceCategoryTab.tsx +++ b/assets/admin/components/ServiceCategoryTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import SearchableSelect from './ui/SearchableSelect'; diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx index 3218f21c..d7dcc8fa 100644 --- a/assets/admin/components/dashboard/NewAppointmentsTable.tsx +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -9,7 +9,7 @@ * and the parent passes the query key to invalidate. */ import React, { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router'; import { toast } from 'sonner'; import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown'; import { findRecordUuid } from '../AppointmentActions'; diff --git a/assets/admin/components/dashboard/TauriDashboardView.tsx b/assets/admin/components/dashboard/TauriDashboardView.tsx index 02625ff1..b008b0d9 100644 --- a/assets/admin/components/dashboard/TauriDashboardView.tsx +++ b/assets/admin/components/dashboard/TauriDashboardView.tsx @@ -5,7 +5,7 @@ * DoctorDashboard feed it their (real-API) data. */ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { TauriStatCards, type DashboardStats } from './TauriStatCards'; import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts'; import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable'; diff --git a/assets/admin/components/holidays/NationalHolidaysCard.tsx b/assets/admin/components/holidays/NationalHolidaysCard.tsx index fbae051a..3681046a 100644 --- a/assets/admin/components/holidays/NationalHolidaysCard.tsx +++ b/assets/admin/components/holidays/NationalHolidaysCard.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { useHolidays } from '../../hooks/useResourceCalendar'; import { formatDate, currentJalaliYear } from '../../lib/utils'; diff --git a/assets/admin/components/layout/AdminLayout.tsx b/assets/admin/components/layout/AdminLayout.tsx index c0ae5523..1f466ace 100644 --- a/assets/admin/components/layout/AdminLayout.tsx +++ b/assets/admin/components/layout/AdminLayout.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet } from 'react-router'; import Sidebar from './Sidebar'; import Topbar from './Topbar'; import { useUiStore, applyBrand } from '../../stores/uiStore'; diff --git a/assets/admin/components/layout/ProfileMenu.test.tsx b/assets/admin/components/layout/ProfileMenu.test.tsx index ec3e97f5..acb21ee3 100644 --- a/assets/admin/components/layout/ProfileMenu.test.tsx +++ b/assets/admin/components/layout/ProfileMenu.test.tsx @@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react'; import { renderWithProviders } from '../../test/utils'; const navigateMock = vi.fn(); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); return { ...actual, useNavigate: () => navigateMock }; }); diff --git a/assets/admin/components/layout/ProfileMenu.tsx b/assets/admin/components/layout/ProfileMenu.tsx index bfae7a30..203ba523 100644 --- a/assets/admin/components/layout/ProfileMenu.tsx +++ b/assets/admin/components/layout/ProfileMenu.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { ChevronDownIcon, UserCircleIcon, diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx index c6a98cec..4adf92ef 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { SearchHeaderP } from '../../pages/subscriptionIcons'; import { useAuthStore } from '../../stores/authStore'; import { usePermissions } from '../../hooks/usePermissions'; diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 918a1bde..ab66ee50 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -31,7 +31,7 @@ import { WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; import { useState } from "react"; -import { NavLink, useLocation, useNavigate } from "react-router-dom"; +import { NavLink, useLocation, useNavigate } from "react-router"; import { usePermissions } from "../../hooks/usePermissions"; import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings"; import { useSubscription } from "../../hooks/useSubscription"; diff --git a/assets/admin/components/layout/Topbar.test.tsx b/assets/admin/components/layout/Topbar.test.tsx index 0bf5ab7f..7d0c5b03 100644 --- a/assets/admin/components/layout/Topbar.test.tsx +++ b/assets/admin/components/layout/Topbar.test.tsx @@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react'; import { renderWithProviders } from '../../test/utils'; const navigateMock = vi.fn(); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); return { ...actual, useNavigate: () => navigateMock }; }); diff --git a/assets/admin/components/layout/Topbar.tsx b/assets/admin/components/layout/Topbar.tsx index 4e2bb9c8..3b7f1f2b 100644 --- a/assets/admin/components/layout/Topbar.tsx +++ b/assets/admin/components/layout/Topbar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { BellIcon, SunIcon, MoonIcon, Bars3Icon, Cog6ToothIcon, MagnifyingGlassIcon, XMarkIcon, CheckIcon, diff --git a/assets/admin/components/resources/ResourcesSubNav.tsx b/assets/admin/components/resources/ResourcesSubNav.tsx index f521af09..80d07976 100644 --- a/assets/admin/components/resources/ResourcesSubNav.tsx +++ b/assets/admin/components/resources/ResourcesSubNav.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router'; const LINKS = [ { to: '/admin/resources', label: 'منابع' }, diff --git a/assets/admin/components/ui/BackButton.test.tsx b/assets/admin/components/ui/BackButton.test.tsx index 033859a9..7807fdc7 100644 --- a/assets/admin/components/ui/BackButton.test.tsx +++ b/assets/admin/components/ui/BackButton.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { screen, fireEvent, render } from '@testing-library/react'; -import { MemoryRouter, Route, Routes, Link } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, Link } from 'react-router'; import BackButton from './BackButton'; import PageHeader from './PageHeader'; diff --git a/assets/admin/components/ui/FeatureGate.tsx b/assets/admin/components/ui/FeatureGate.tsx index 7a7e1241..4b44cce5 100644 --- a/assets/admin/components/ui/FeatureGate.tsx +++ b/assets/admin/components/ui/FeatureGate.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { LockClosedIcon } from '@heroicons/react/24/outline'; import { useSubscription } from '../../hooks/useSubscription'; diff --git a/assets/admin/components/ui/PageHeader.tsx b/assets/admin/components/ui/PageHeader.tsx index 2d720e96..669b6c72 100644 --- a/assets/admin/components/ui/PageHeader.tsx +++ b/assets/admin/components/ui/PageHeader.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import BackButton from './BackButton'; diff --git a/assets/admin/hooks/useGoBack.ts b/assets/admin/hooks/useGoBack.ts index 04ebddaf..0bd6d36b 100644 --- a/assets/admin/hooks/useGoBack.ts +++ b/assets/admin/hooks/useGoBack.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router'; /** * رفتار یکسانِ «بازگشت» در کل پنل: اگر کاربر از صفحهٔ دیگری داخل خود پنل آمده باشد، diff --git a/assets/admin/hooks/useUrlState.test.tsx b/assets/admin/hooks/useUrlState.test.tsx index fb42a45c..b0d9e2bf 100644 --- a/assets/admin/hooks/useUrlState.test.tsx +++ b/assets/admin/hooks/useUrlState.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { useUrlState, pageOf } from './useUrlState'; import BackButton from '../components/ui/BackButton'; diff --git a/assets/admin/hooks/useUrlState.ts b/assets/admin/hooks/useUrlState.ts index 926b584a..061b3db1 100644 --- a/assets/admin/hooks/useUrlState.ts +++ b/assets/admin/hooks/useUrlState.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router'; /** * وضعیت لیست (جستجو، فیلترها، صفحه، تب) در query string زندگی می‌کند، نه در state diff --git a/assets/admin/index.tsx b/assets/admin/index.tsx index 4c7230aa..6df98df8 100644 --- a/assets/admin/index.tsx +++ b/assets/admin/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; +import { BrowserRouter } from 'react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 4838ba18..a0c4c125 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router'; import { PlusIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/AppointmentDetailPage.test.tsx b/assets/admin/pages/AppointmentDetailPage.test.tsx index 66f72915..430d6821 100644 --- a/assets/admin/pages/AppointmentDetailPage.test.tsx +++ b/assets/admin/pages/AppointmentDetailPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index 97f00301..a397fb92 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; diff --git a/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx b/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx index c2d64373..1ea4b932 100644 --- a/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx +++ b/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx index 4a8e7719..cd2cfa23 100644 --- a/assets/admin/pages/AppointmentEditPage.test.tsx +++ b/assets/admin/pages/AppointmentEditPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx index e05acdf0..6619865f 100644 --- a/assets/admin/pages/AppointmentEditPage.tsx +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useNavigate, useParams, Link } from 'react-router'; import { ChevronRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index c6825103..2d5471d7 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon, AdjustmentsHorizontalIcon, diff --git a/assets/admin/pages/BlogFormPage.test.tsx b/assets/admin/pages/BlogFormPage.test.tsx index 188198c4..c7520fe0 100644 --- a/assets/admin/pages/BlogFormPage.test.tsx +++ b/assets/admin/pages/BlogFormPage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '@/test/utils'; vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null })); diff --git a/assets/admin/pages/BlogFormPage.tsx b/assets/admin/pages/BlogFormPage.tsx index d7357590..9f5e7c73 100644 --- a/assets/admin/pages/BlogFormPage.tsx +++ b/assets/admin/pages/BlogFormPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/BlogsPage.tsx b/assets/admin/pages/BlogsPage.tsx index c2d9f328..c8cf1cda 100644 --- a/assets/admin/pages/BlogsPage.tsx +++ b/assets/admin/pages/BlogsPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { EyeIcon, PencilIcon, TrashIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/ClaimPatientDetailPage.tsx b/assets/admin/pages/ClaimPatientDetailPage.tsx index 4c2a1f03..25a15551 100644 --- a/assets/admin/pages/ClaimPatientDetailPage.tsx +++ b/assets/admin/pages/ClaimPatientDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router'; import { toast } from 'sonner'; import { api } from '../lib/api'; import { formatRial, formatNumber, formatDate, formatDateTime } from '../lib/utils'; diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index df67dd23..1f2743df 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import { formatRial, formatNumber, toGregorianDate, todayIso } from '../lib/utils'; diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx index 9a9f88cb..dd627d54 100644 --- a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx +++ b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { CubeIcon, UserGroupIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/ClinicDetailPage.test.tsx b/assets/admin/pages/ClinicDetailPage.test.tsx index fdd8ac00..3fccdbae 100644 --- a/assets/admin/pages/ClinicDetailPage.test.tsx +++ b/assets/admin/pages/ClinicDetailPage.test.tsx @@ -24,7 +24,7 @@ vi.mock('../components/ClinicDoctorsManager', () => ({ default: () =>
, })); -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { api } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import ClinicDetailPage from './ClinicDetailPage'; diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 15367ec7..9a69696f 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/ClinicDoctorsPage.tsx b/assets/admin/pages/ClinicDoctorsPage.tsx index f88cc048..54d3a831 100644 --- a/assets/admin/pages/ClinicDoctorsPage.tsx +++ b/assets/admin/pages/ClinicDoctorsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { PencilIcon } from '@heroicons/react/24/outline'; import { useAuthStore } from '../stores/authStore'; import { usePermissions } from '../hooks/usePermissions'; diff --git a/assets/admin/pages/ClinicFormPage.tsx b/assets/admin/pages/ClinicFormPage.tsx index 48189d8f..4ac1967d 100644 --- a/assets/admin/pages/ClinicFormPage.tsx +++ b/assets/admin/pages/ClinicFormPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index e4fa0d77..95b0a0a2 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PlusIcon, PencilIcon, WrenchScrewdriverIcon, BanknotesIcon, diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index b0c52f3b..93e92076 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { EyeIcon, TrashIcon, diff --git a/assets/admin/pages/DashboardPage.test.tsx b/assets/admin/pages/DashboardPage.test.tsx index 2622bb93..d3abcb59 100644 --- a/assets/admin/pages/DashboardPage.test.tsx +++ b/assets/admin/pages/DashboardPage.test.tsx @@ -8,8 +8,8 @@ vi.mock('../lib/api', () => ({ })); const navigate = vi.fn(); -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), +vi.mock('react-router', async () => ({ + ...(await vi.importActual('react-router')), useNavigate: () => navigate, })); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index a76d7d38..0ffa3172 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 69f97d3f..d0b31187 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { avatarGradient } from '../lib/avatarColors'; import { createPortal } from 'react-dom'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/DoctorFormPage.tsx b/assets/admin/pages/DoctorFormPage.tsx index dfb2bdd6..d0dd15eb 100644 --- a/assets/admin/pages/DoctorFormPage.tsx +++ b/assets/admin/pages/DoctorFormPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useQuery, useMutation } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index 1ca15e0f..5afa32cc 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon, CheckCircleIcon, XCircleIcon, TableCellsIcon, Squares2X2Icon, DevicePhoneMobileIcon, diff --git a/assets/admin/pages/EditSessionPage.tsx b/assets/admin/pages/EditSessionPage.tsx index 06779a75..c397636a 100644 --- a/assets/admin/pages/EditSessionPage.tsx +++ b/assets/admin/pages/EditSessionPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; diff --git a/assets/admin/pages/LoginPage.tsx b/assets/admin/pages/LoginPage.tsx index bf3ffd61..ec6b5a6d 100644 --- a/assets/admin/pages/LoginPage.tsx +++ b/assets/admin/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router'; import { toast } from 'sonner'; import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; import { useAuthStore } from '../stores/authStore'; diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 894d1943..4e546e51 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -32,7 +32,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { toast } from "sonner"; import { z } from "zod"; import FeatureGate from "../components/ui/FeatureGate"; diff --git a/assets/admin/pages/MyPaymentDetailPage.test.tsx b/assets/admin/pages/MyPaymentDetailPage.test.tsx index 875bcf62..bed77896 100644 --- a/assets/admin/pages/MyPaymentDetailPage.test.tsx +++ b/assets/admin/pages/MyPaymentDetailPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent } from '@testing-library/react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/MyPaymentDetailPage.tsx b/assets/admin/pages/MyPaymentDetailPage.tsx index f28e4919..2bacd26a 100644 --- a/assets/admin/pages/MyPaymentDetailPage.tsx +++ b/assets/admin/pages/MyPaymentDetailPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router'; import { ChevronRightIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import PageHeader from '../components/ui/PageHeader'; diff --git a/assets/admin/pages/MyPaymentsPage.test.tsx b/assets/admin/pages/MyPaymentsPage.test.tsx index 366c64fb..6be61dab 100644 --- a/assets/admin/pages/MyPaymentsPage.test.tsx +++ b/assets/admin/pages/MyPaymentsPage.test.tsx @@ -3,8 +3,8 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'; import { renderWithProviders } from '../test/utils'; const navigate = vi.fn(); -vi.mock('react-router-dom', async (orig) => ({ - ...(await orig()), +vi.mock('react-router', async (orig) => ({ + ...(await orig()), useNavigate: () => navigate, })); vi.mock('../lib/api', () => ({ api: { get: vi.fn() }, ApiError: class extends Error {} })); diff --git a/assets/admin/pages/MyPaymentsPage.tsx b/assets/admin/pages/MyPaymentsPage.tsx index d79d5d29..c29f3b7c 100644 --- a/assets/admin/pages/MyPaymentsPage.tsx +++ b/assets/admin/pages/MyPaymentsPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import Pagination from '../components/ui/Pagination'; diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index e203eff6..77e65359 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; +import { Link } from "react-router"; import { toast } from "sonner"; import SettingsLayout from "../components/layout/SettingsLayout"; import ConfirmDialog from "../components/ui/ConfirmDialog"; diff --git a/assets/admin/pages/NewSessionPage.test.tsx b/assets/admin/pages/NewSessionPage.test.tsx index a1eaf700..f7cc3222 100644 --- a/assets/admin/pages/NewSessionPage.test.tsx +++ b/assets/admin/pages/NewSessionPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/NewSessionPage.tsx b/assets/admin/pages/NewSessionPage.tsx index aba2b845..d02d5644 100644 --- a/assets/admin/pages/NewSessionPage.tsx +++ b/assets/admin/pages/NewSessionPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 5da5c113..41fe1cd8 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index f38dfa4a..defc2d74 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'; +import { useParams, useSearchParams, Link, useNavigate } from 'react-router'; import { ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, diff --git a/assets/admin/pages/PatientRecordFormPage.tsx b/assets/admin/pages/PatientRecordFormPage.tsx index ab5117c2..aac0881c 100644 --- a/assets/admin/pages/PatientRecordFormPage.tsx +++ b/assets/admin/pages/PatientRecordFormPage.tsx @@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router'; import { ChevronRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/PatientsListPage.tsx b/assets/admin/pages/PatientsListPage.tsx index c455b67b..7b949d4a 100644 --- a/assets/admin/pages/PatientsListPage.tsx +++ b/assets/admin/pages/PatientsListPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router'; import { PencilIcon, EyeIcon, ExclamationCircleIcon, IdentificationIcon, UserIcon, EllipsisHorizontalIcon, diff --git a/assets/admin/pages/PaymentDetailPage.tsx b/assets/admin/pages/PaymentDetailPage.tsx index 72a9d096..7078adbb 100644 --- a/assets/admin/pages/PaymentDetailPage.tsx +++ b/assets/admin/pages/PaymentDetailPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; diff --git a/assets/admin/pages/PaymentSuccessPage.test.tsx b/assets/admin/pages/PaymentSuccessPage.test.tsx index 4e0ca0e4..8b919083 100644 --- a/assets/admin/pages/PaymentSuccessPage.test.tsx +++ b/assets/admin/pages/PaymentSuccessPage.test.tsx @@ -3,8 +3,8 @@ import { screen } from '@testing-library/react'; import { renderWithProviders } from '../test/utils'; const navSpy = vi.fn(); -vi.mock('react-router-dom', async (orig) => ({ - ...(await orig()), +vi.mock('react-router', async (orig) => ({ + ...(await orig()), useNavigate: () => navSpy, })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/PaymentSuccessPage.tsx b/assets/admin/pages/PaymentSuccessPage.tsx index 7ef65332..af739bc0 100644 --- a/assets/admin/pages/PaymentSuccessPage.tsx +++ b/assets/admin/pages/PaymentSuccessPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { useSearchParams, useNavigate, Link } from 'react-router-dom'; +import { useSearchParams, useNavigate, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { CheckBadgeIcon, SparklesIcon, CalendarDaysIcon, CreditCardIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; diff --git a/assets/admin/pages/PaymentsPage.tsx b/assets/admin/pages/PaymentsPage.tsx index 7f670d23..66d22113 100644 --- a/assets/admin/pages/PaymentsPage.tsx +++ b/assets/admin/pages/PaymentsPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { MagnifyingGlassIcon, EyeIcon, BanknotesIcon, ClockIcon, CreditCardIcon, ArrowDownTrayIcon, diff --git a/assets/admin/pages/RepresentationBlogFormPage.tsx b/assets/admin/pages/RepresentationBlogFormPage.tsx index b8e819b4..c8fb01e2 100644 --- a/assets/admin/pages/RepresentationBlogFormPage.tsx +++ b/assets/admin/pages/RepresentationBlogFormPage.tsx @@ -1,4 +1,4 @@ -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/RepresentationBlogsPage.tsx b/assets/admin/pages/RepresentationBlogsPage.tsx index e2d8472a..d70da887 100644 --- a/assets/admin/pages/RepresentationBlogsPage.tsx +++ b/assets/admin/pages/RepresentationBlogsPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/RepresentationDetailPage.tsx b/assets/admin/pages/RepresentationDetailPage.tsx index 6847aa4e..e799d68d 100644 --- a/assets/admin/pages/RepresentationDetailPage.tsx +++ b/assets/admin/pages/RepresentationDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, UserGroupIcon, CalendarDaysIcon, ClockIcon, ReceiptPercentIcon, PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; diff --git a/assets/admin/pages/RepresentationsPage.tsx b/assets/admin/pages/RepresentationsPage.tsx index e37d9191..5cabd5ec 100644 --- a/assets/admin/pages/RepresentationsPage.tsx +++ b/assets/admin/pages/RepresentationsPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { EyeIcon, NoSymbolIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/ReserveAppointmentsPage.tsx b/assets/admin/pages/ReserveAppointmentsPage.tsx index b1a54135..4c2ee63c 100644 --- a/assets/admin/pages/ReserveAppointmentsPage.tsx +++ b/assets/admin/pages/ReserveAppointmentsPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useUrlState } from '../hooks/useUrlState'; import { useQuery } from '@tanstack/react-query'; import ReactDOM from 'react-dom'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { PlusIcon, EllipsisHorizontalIcon, EyeIcon, PencilIcon, ArrowDownOnSquareIcon, UserCircleIcon, PhoneIcon, diff --git a/assets/admin/pages/ResourceBookingPage.tsx b/assets/admin/pages/ResourceBookingPage.tsx index 6b010e8d..18f4064f 100644 --- a/assets/admin/pages/ResourceBookingPage.tsx +++ b/assets/admin/pages/ResourceBookingPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; import HoldCountdown from '../components/HoldCountdown'; diff --git a/assets/admin/pages/ResourceDetailPage.test.tsx b/assets/admin/pages/ResourceDetailPage.test.tsx index 8cb77a7c..813def1b 100644 --- a/assets/admin/pages/ResourceDetailPage.test.tsx +++ b/assets/admin/pages/ResourceDetailPage.test.tsx @@ -10,7 +10,7 @@ vi.mock('../lib/api', () => ({ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) })); -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { api } from '../lib/api'; import ResourceDetailPage from './ResourceDetailPage'; diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx index a447da5c..4db56c27 100644 --- a/assets/admin/pages/ResourceDetailPage.tsx +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams } from 'react-router'; import { PencilIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import ConfirmDialog from '../components/ui/ConfirmDialog'; diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index 7960c11a..83ba551d 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { PlusIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx index 730bcb48..99883f89 100644 --- a/assets/admin/pages/SecretariesPage.tsx +++ b/assets/admin/pages/SecretariesPage.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { toast } from 'sonner'; import { api } from '../lib/api'; import { useUrlState, pageOf } from '../hooks/useUrlState'; diff --git a/assets/admin/pages/SecretaryDetailPage.test.tsx b/assets/admin/pages/SecretaryDetailPage.test.tsx index dafec64c..c5322a31 100644 --- a/assets/admin/pages/SecretaryDetailPage.test.tsx +++ b/assets/admin/pages/SecretaryDetailPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ diff --git a/assets/admin/pages/SecretaryDetailPage.tsx b/assets/admin/pages/SecretaryDetailPage.tsx index e895e43b..c1116898 100644 --- a/assets/admin/pages/SecretaryDetailPage.tsx +++ b/assets/admin/pages/SecretaryDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router'; import { ChevronRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/SelectContextPage.tsx b/assets/admin/pages/SelectContextPage.tsx index 939b2a19..6ffdd91f 100644 --- a/assets/admin/pages/SelectContextPage.tsx +++ b/assets/admin/pages/SelectContextPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useState } from 'react'; import { useAuthStore, ContextItem } from '../stores/authStore'; diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx index 0a3e4c59..75e20cb7 100644 --- a/assets/admin/pages/ServiceDetailPage.test.tsx +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index f863f1e3..0381f4a6 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { BanknotesIcon, ShieldCheckIcon, ClockIcon, UsersIcon, PencilIcon, diff --git a/assets/admin/pages/SessionPaymentPage.test.tsx b/assets/admin/pages/SessionPaymentPage.test.tsx index 5ded190d..26560237 100644 --- a/assets/admin/pages/SessionPaymentPage.test.tsx +++ b/assets/admin/pages/SessionPaymentPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/assets/admin/pages/SessionPaymentPage.tsx b/assets/admin/pages/SessionPaymentPage.tsx index 46dd9cfd..d379a40b 100644 --- a/assets/admin/pages/SessionPaymentPage.tsx +++ b/assets/admin/pages/SessionPaymentPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; diff --git a/assets/admin/pages/SettingsMenuPage.tsx b/assets/admin/pages/SettingsMenuPage.tsx index 3f2b2e15..86f7e63b 100644 --- a/assets/admin/pages/SettingsMenuPage.tsx +++ b/assets/admin/pages/SettingsMenuPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import { menuForRole } from '../components/layout/SettingsLayout'; import { useAuthStore } from '../stores/authStore'; diff --git a/assets/admin/pages/SettlementDetailPage.tsx b/assets/admin/pages/SettlementDetailPage.tsx index eb87fee8..54b40431 100644 --- a/assets/admin/pages/SettlementDetailPage.tsx +++ b/assets/admin/pages/SettlementDetailPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router'; import { ArrowRightIcon, CheckIcon, XMarkIcon, ArrowUpTrayIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/SettlementsPage.tsx b/assets/admin/pages/SettlementsPage.tsx index a8e4594b..3cb566fe 100644 --- a/assets/admin/pages/SettlementsPage.tsx +++ b/assets/admin/pages/SettlementsPage.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/StaffSessionDetailPage.test.tsx b/assets/admin/pages/StaffSessionDetailPage.test.tsx index e560af8d..b7a7e321 100644 --- a/assets/admin/pages/StaffSessionDetailPage.test.tsx +++ b/assets/admin/pages/StaffSessionDetailPage.test.tsx @@ -7,8 +7,8 @@ vi.mock('../lib/api', () => ({ ApiError: class extends Error {}, })); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); return { ...actual, useParams: () => ({ uuid: 'ses-1' }) }; }); diff --git a/assets/admin/pages/StaffSessionDetailPage.tsx b/assets/admin/pages/StaffSessionDetailPage.tsx index c313cf98..ad70501c 100644 --- a/assets/admin/pages/StaffSessionDetailPage.tsx +++ b/assets/admin/pages/StaffSessionDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api, ApiError, type ApiResponse } from '../lib/api'; diff --git a/assets/admin/pages/StaffTreatmentSessionsPage.tsx b/assets/admin/pages/StaffTreatmentSessionsPage.tsx index 82378786..17471050 100644 --- a/assets/admin/pages/StaffTreatmentSessionsPage.tsx +++ b/assets/admin/pages/StaffTreatmentSessionsPage.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { ChevronLeftIcon, ClipboardDocumentListIcon, CpuChipIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index eb00417d..082d383c 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -5,7 +5,7 @@ import { } from "@heroicons/react/24/outline"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "react-router"; import TreatmentCaseEditModal from "../components/TreatmentCaseEditModal"; import { PatientsCategoryView, diff --git a/assets/admin/pages/UserDetailPage.tsx b/assets/admin/pages/UserDetailPage.tsx index 7b822b3a..3bb842f9 100644 --- a/assets/admin/pages/UserDetailPage.tsx +++ b/assets/admin/pages/UserDetailPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { avatarGradient } from '../lib/avatarColors'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx index cdeb513a..90257dde 100644 --- a/assets/admin/pages/UsersPage.tsx +++ b/assets/admin/pages/UsersPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon, diff --git a/assets/admin/test/utils.tsx b/assets/admin/test/utils.tsx index b4c09e6c..b8fe628a 100644 --- a/assets/admin/test/utils.tsx +++ b/assets/admin/test/utils.tsx @@ -1,7 +1,7 @@ import { ReactElement, ReactNode } from 'react'; import { render, renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; export function makeClient(): QueryClient { return new QueryClient({ diff --git a/composer.json b/composer.json index eb620ef4..f820c6f2 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "symfony/dotenv": "7.4.*", "symfony/flex": "^2", "symfony/framework-bundle": "7.4.*", + "symfony/html-sanitizer": "7.4.*", "symfony/http-client": "7.4.*", "symfony/messenger": "7.4.*", "symfony/property-access": "7.4.*", diff --git a/composer.lock b/composer.lock index 005f00e5..23aea2a2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a224d59e9e6ce93b3f3f78ee13b06bf5", + "content-hash": "00a0f450cb89b5bc25c645de861adbfb", "packages": [ { "name": "altcha-org/altcha", @@ -1241,6 +1241,188 @@ ], "time": "2025-10-17T11:30:53+00:00" }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, { "name": "lexik/jwt-authentication-bundle", "version": "v3.2.0", @@ -1357,6 +1539,73 @@ ], "time": "2025-12-20T17:47:00+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, { "name": "nelmio/api-doc-bundle", "version": "v5.10.3", @@ -2025,6 +2274,114 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -3687,6 +4044,80 @@ ], "time": "2026-06-27T08:31:38+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "c328df69f5b6f44a0d031d757903d955bebb23b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/c328df69f5b6f44a0d031d757903d955bebb23b3", + "reference": "c328df69f5b6f44a0d031d757903d955bebb23b3", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "masterminds/html5": "^2.7.2", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T11:10:32+00:00" + }, { "name": "symfony/http-client", "version": "v7.4.14", @@ -7442,73 +7873,6 @@ } ], "packages-dev": [ - { - "name": "masterminds/html5", - "version": "2.10.1", - "source": { - "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Masterminds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" - } - ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", - "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" - ], - "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" - }, - "time": "2026-06-23T18:43:15+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.13.4", diff --git a/config/packages/html_sanitizer.yaml b/config/packages/html_sanitizer.yaml new file mode 100644 index 00000000..f438694c --- /dev/null +++ b/config/packages/html_sanitizer.yaml @@ -0,0 +1,67 @@ +# Sanitizer for rich text that a human authored in the admin panel and that the +# panel later renders with `dangerouslySetInnerHTML` (BlogReviewPage). +# +# آدیت ۲۰۲۶-۰۸-۰۷: بدنهٔ مقاله همان‌طور که می‌آمد ذخیره و رندر می‌شد. CSP فعلی +# (`script-src 'self'`) اسکریپت تزریقی را اجرا نمی‌کند، ولی هندلرهای inline و +# `javascript:` را کامل نمی‌بندد — و CSP لایهٔ دوم است، نه اولین دفاع. پاک‌سازی +# هنگام **ذخیره** انجام می‌شود نه هنگام نمایش، چون یک نقطهٔ ورود دارد و هر +# مصرف‌کنندهٔ بعدی (پنل، سایت عمومی، فید) خودبه‌خود امن می‌شود. +# +# فهرست عناصر = چیزی که CKEditor 5 واقعاً تولید می‌کند. عنصری که اینجا نیست حذف +# می‌شود ولی متنش می‌ماند، پس محتوای قدیمی نامفهوم نمی‌شود. +framework: + html_sanitizer: + sanitizers: + blog.body: + allow_safe_elements: true + allow_elements: + p: [] + br: [] + hr: [] + strong: [] + b: [] + em: [] + i: [] + u: [] + s: [] + sub: [] + sup: [] + mark: [] + h1: [] + h2: [] + h3: [] + h4: [] + h5: [] + h6: [] + ul: [] + ol: ['start', 'reversed'] + li: [] + blockquote: [] + pre: [] + code: [] + figure: ['class'] + figcaption: [] + span: [] + div: [] + a: ['href', 'title', 'target', 'rel'] + img: ['src', 'alt', 'title', 'width', 'height'] + table: [] + thead: [] + tbody: [] + tfoot: [] + tr: [] + td: ['colspan', 'rowspan'] + th: ['colspan', 'rowspan', 'scope'] + # فقط طرح‌های امن. `javascript:` عمداً نیست. + allowed_link_schemes: ['http', 'https', 'mailto'] + allowed_media_schemes: ['http', 'https', 'data'] + allow_relative_links: true + # لینک بیرونی در تب جدید باز می‌شود؛ بدون این‌ها tabnabbing باز است. + force_attributes: + a: + rel: 'noopener noreferrer' + # `drop` نه `block`: block فقط تگ را برمی‌دارد و متنِ داخلش را نگه + # می‌دارد، یعنی `` به متنِ `alert(1)` تبدیل + # می‌شد. برای این عناصر، خودِ محتوا هم باید برود. + drop_elements: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'noscript'] + max_input_length: 500000 diff --git a/config/reference.php b/config/reference.php index 57422cbe..541d2ac7 100644 --- a/config/reference.php +++ b/config/reference.php @@ -653,7 +653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * time_based_uuid_node?: scalar|Param|null, * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * sanitizers?: array **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نشده. خروجی درایور lead است نه یافته؛ +> leadهایی که پس از خواندن کد «مثبت کاذب» بودند، در بخش «رد شد» با دلیل آمده‌اند. + +--- + +## خلاصهٔ وضعیت + +| # | یافته | شدت | وضعیت | +|---|-------|-----|-------| +| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` می‌توانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد | +| 2 | `react-router` — ۵ advisory از جمله XSS و open redirect | 🟧 High | ⚠️ باز — نیازمند تصمیم | +| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ⚠️ باز (از آدیت قبلی) | +| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفته‌شده — تصمیم ۲۰۲۶-۰۸-۰۷ | +| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ⚠️ باز | +| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ⚠️ باز | +| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | باز (از آدیت قبلی، کم‌اهمیت) | + +طبق تصمیم کاربر پیش از اجرا: Critical و High **همان جلسه** رفع می‌شوند؛ Medium و پایین‌تر فقط +گزارش می‌شوند. یافتهٔ ۱ رفع شد. یافته‌های ۲ و ۳ High هستند ولی رفعشان **ارتقای وابستگی** است نه +تغییر کد این repo، و شکستنِ روتینگ پنل یا ادیتور را در پی دارد — پس تصمیم به کاربر واگذار شد. + +--- + +## ماتریس نقش‌هایی که آدیت با آن اجرا شد + +آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس authz ناقص مانده چون فقط `admin` و +`doctor` در DB بودند. آن محدودیت اینجا برطرف شد. DB از آن زمان دوباره seed شده، پس جدول قبلی +بی‌اعتبار بود و از نو ساخته شد: + +``` +admin 09120671756 QaTest@1234 ROLE_USER,ROLE_ADMIN +clinic 09390039833 QaTest@1234 ROLE_USER,ROLE_CLINIC (مالک کلینیک ۳) +secretary 0912000109 QaTest@1234 ROLE_USER,ROLE_SECRETARY +doctor 0912000101 QaTest@1234 ROLE_USER,ROLE_DOCTOR +representation 09124000001 QaTest@1234 ROLE_USER,ROLE_REPRESENTATION +staff 09128726723 09128726723 ROLE_USER,ROLE_STAFF (پرسنل کلینیک ۳) +multirole 0912000201 QaTest@1234 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC (مالک کلینیک ۱) +``` + +اکانت‌های `09127000000` و `09123456778` که در گزارش قبلی بودند دیگر در DB وجود ندارند، و +`09390039833` که «doctor» ثبت شده بود حالا `ROLE_CLINIC` است. ماتریس در هر دو درایور +(`symfony-security-audit` و `qa-clinicpro`) اصلاح و همگام شد. هیچ کاربری ساخته نشد و هیچ پسوردی +عوض نشد — همه از قبل موجود بودند. + +**جفت tenant برای تست IDOR:** مهاجم = `multirole` (مالک کلینیک ۱)، قربانی = کلینیک ۳ که تنها +tenant دارای دادهٔ `Treatment` است (۴ پرونده، ۳ جلسه، ۱ پروتکل). + +--- + +## یافته‌ها + +### 1. 🟧 HIGH — پروتکل درمان بدون هیچ گِیت مجوزی + +**۱. فایل:** `src/Treatment/Controller/TreatmentProtocolController.php` (خطوط ۳۷، ۵۷، ۷۲ نسخهٔ قبل) + +**۲. ریسک:** هر کاربرِ احرازشده داخل یک tenant — از جمله منشی‌ای که توگل «سرویس‌ها» برایش کاملاً +بسته است — می‌توانست پروتکل درمانِ هر سرویس همان tenant را بخواند، کل آن را بازنویسی کند، یا +حذفش کند. حذف پروتکل یعنی سرویس چندجلسه‌ای به تک‌جلسه‌ای برمی‌گردد: برنامهٔ درمان بیمار، فاصلهٔ +جلسات و فهرست پرسنل مجاز از بین می‌رود. + +**۳. شرح:** `TreatmentCaseController` گیت درست دارد و هر متدش +`denyUnlessGranted($user, 'treatment', $action)` صدا می‌زند. `TreatmentProtocolController` — که در +همان دامنه و همان کامیت‌ها ساخته شده — این کار را **نمی‌کرد**. تنها دفاعش `requireItem()` بود که +فقط مالکیتِ tenant را می‌سنجد: + +```php +private function requireItem(User $user, string $uuid): ServiceItem +{ + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; +} +``` + +مالکیت ≠ مجوز. عبور از این تابع فقط ثابت می‌کند سرویس مالِ همین محیط است، نه اینکه این کاربر +اجازهٔ دست‌زدن به آن را دارد. + +**۴. کشف چطور شد:** ناهمگونی بین دو کنترلرِ یک دامنه در بازرسی کد دیده شد، بعد با پروب واقعی +تأیید شد. درایور آن را نداده بود — `guards` فقط route بدون `#[IsGranted]` را می‌بیند و این کنترلر +`#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح-کلاس داشت. + +**۵. بازتولید (قبل از رفع):** منشیِ کلینیک ۱ (`0912000209`) که در DB +`services: {view:false, create:false, update:false, delete:false}` دارد، روی سرویسِ **همان کلینیک**: + +``` +sec services:false | GET /api/v1/service-item//treatment-protocol | 200 | +sec services:false | PUT /api/v1/service-item//treatment-protocol | 422 | ERR_VALIDATION_001 +sec services:false | DELETE /api/v1/service-item//treatment-protocol | 200 | +sec services:false | GET /api/v1/service-items | 403 | ERR_FORBIDDEN_001 +``` + +خط آخر شاهدِ ماجراست: همان مجوز روی فهرست سرویس‌ها **کار می‌کند** و ۴۰۳ می‌دهد. یعنی مشکل +پیکربندی مجوزِ این منشی نیست، نبودِ گیت در این کنترلر است. + +`422` روی `PUT` مهم‌تر از `200` است: کد خطا `ERR_VALIDATION_001` از داخل `TreatmentProtocolWriter` +می‌آید، یعنی درخواست از لایهٔ authorization **عبور کرده** و فقط سر اعتبارسنجیِ فیلد افتاده. با یک +بدنهٔ معتبر، نوشتن انجام می‌شد. + +**۶. رفع اعمال‌شده:** همان الگوی `ServiceCatalogController` و `ClinicServiceController` — + +```php +private function denyUnlessGranted(User $user, string $action): void +{ + $this->secretaryAccess->denyUnlessGranted($user, 'services', $action); + $this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action); +} +``` + +و صدا زدنش در ابتدای هر سه متد: `show` → `view`، `replace` → `update`، `delete` → `update`. + +**۷. علت انتخاب راه‌حل:** + +- **منبع `services` نه `treatment`:** پروتکل یک خاصیتِ `ServiceItem` است. صفحه‌اش هم داخل + کاتالوگ سرویس‌هاست، نه در پروندهٔ درمان. اگر `treatment` می‌گرفت، منشی‌ای که فقط اجازهٔ دیدن + دورهٔ درمان دارد می‌توانست تعریفِ سرویس را عوض کند. +- **`delete` با `update` نه `delete`:** سرویس حذف نمی‌شود؛ فقط سوییچِ «طول درمان» روی همان سرویس + خاموش می‌شود. اگر `services.delete` می‌گرفت، مدیری که اجازهٔ ویرایش سرویس دارد ولی اجازهٔ حذفش + را ندارد نمی‌توانست سوییچی را که خودش روشن کرده خاموش کند. +- **گیت پیش از `requireItem`:** ترتیب عمدی است. اگر بعد از آن بود، uuid ناموجود ۴۰۴ می‌داد و همین + تفاوت ۴۰۳/۴۰۴ به کاربرِ بدون مجوز می‌گفت کدام uuidها در این tenant وجود دارند. +- **مکانیزم موازی نساختیم:** همان دو checker موجود تزریق شدند، نه یک voter تازه. + +**۸. تأیید رفع — هر سه سناریوی معیار پذیرش:** + +``` +❌ sec services:false | GET | 403 | ERR_FORBIDDEN_001 +❌ sec services:false | PUT | 403 | ERR_FORBIDDEN_001 +❌ sec services:false | DELETE | 403 | ERR_FORBIDDEN_001 +✅ owner (clinic A) | GET | 200 | +✅ owner (clinic B) | GET | 200 | +⚠️ A→B cross-tenant | GET | 404 | ERR_NOT_FOUND_001 +``` + +مسیر مجاز نشکست و تفکیک tenant همچنان ۴۰۴ می‌دهد نه ۴۰۳. + +**۹. تست رگرسیون:** چهار تست در `tests/Secretary/SecretaryResourceEnforcementTest.php` — +`testTreatmentProtocolReadDeniedByDefault`، `testTreatmentProtocolReadAllowedWhenServicesGranted`، +`testTreatmentProtocolWriteNeedsServicesUpdate`، +`testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted`. کل فایل: ۳۳ تست، ۴۸ assertion، سبز. + +**۱۰. مستندات:** `docs/api/treatment.md` — هر سه اندپوینت با مجوز جدید و دلیل انتخابش. + +--- + +### 2. 🟧 HIGH — `react-router` با پنج advisory باز + +**۱. فایل:** `package.json` → `react-router` (range آسیب‌پذیر: `6.0.0 - 8.2.0`) + +**۲. ریسک:** XSS، open redirect و DoS در لایهٔ روتینگِ پنل ادمین. + +**۳. شرح:** این یافته در آدیت ۲۰۲۶-۰۷-۱۹ **نبود** — آنجا فقط یک High (lodash) گزارش شده بود. +پنج advisory: + +- `GHSA-wrjc-x8rr-h8h6` — open redirect با بک‌اسلش در `` و `useNavigate` (دور زدن CVE-2025-68470) +- `GHSA-h8fp-f39c-q6mh` — `RSCErrorHandler` بدون اعتبارسنجی protocol (XSS) +- `GHSA-337j-9hxr-rhxg` — تزریق constructor دلخواه در `deserializeErrors()` +- `GHSA-chx6-hx7r-mcp5` — DoS احرازنشده با route matching ناکارآمد +- `GHSA-qwww-vcr4-c8h2` — دور زدن CSRF در حالت RSC + +**۴. بازتولید:** + +```bash +npm audit --omit=dev --json | python3 -c "import json,sys; print(json.load(sys.stdin)['metadata']['vulnerabilities'])" +# → {'info': 0, 'low': 0, 'moderate': 62, 'high': 2, 'critical': 0, 'total': 64} +``` + +**۵. وضعیت:** باز. پروژه روی React Router v7 است و رفع یعنی bump به نسخه‌ای خارج از range. سه +advisory (RSC، SSR hydration، CSRF مود RSC) به این پنل که کلاینت‌ساید محض است مربوط نمی‌شوند؛ ولی +open redirect و DoS مربوط‌اند. **رفع نشد** چون ارتقای major روتینگِ ۵۰ صفحهٔ پنل تست دستی +می‌خواهد و در یک پاس امنیتی ریسکش بیشتر از خودِ یافته است. تسک جدا پیشنهاد می‌شود. + +--- + +### 3. 🟧 HIGH — `lodash-es` هنوز آسیب‌پذیر + +**۱. فایل:** `package.json` → `lodash-es` (`<=4.17.23`) + +**۲. شرح:** در آدیت ۲۰۲۶-۰۷-۱۹ «نیمه‌رفع» ثبت شده بود. هنوز باز است، و حالا دو advisory دیگر هم +اضافه شده: prototype pollution در `_.unset` و `_.omit`. + +**۳. بازتولید:** همان `npm audit` بالا — `high=2` که یکی react-router است و یکی lodash-es. + +**۴. وضعیت:** باز. transitive است؛ باید ردیابی شود کدام پکیج آن را می‌کشد. + +--- + +### 4. 🟨 MEDIUM — CKEditor (risk پذیرفته‌شده) + +`@ckeditor/ckeditor5-build-classic@44.3.0` آخرین نسخهٔ آن پکیج است و **deprecated**؛ ۶۲ moderate +دارد (۶۱ در گزارش قبلی، حالا ۶۲). رفع واقعی یعنی مهاجرت به پکیج umbrella `ckeditor5` v45+. + +**تصمیم ۲۰۲۶-۰۸-۰۷:** عمداً خارج از محدودهٔ این آدیت. دلیل: refactor ادیتور بلاگ ریسک شکستن دارد +و ربطی به کد سه هفتهٔ اخیر ندارد. این XSSها moderate و admin-only هستند. تسک جدا لازم است. + +--- + +### 5. 🟨 MEDIUM — `dangerouslySetInnerHTML` روی بدنهٔ بلاگ + +**۱. فایل:** `assets/admin/pages/BlogReviewPage.tsx:205` + +```tsx +dangerouslySetInnerHTML={{ __html: full.body }} +``` + +**۲. ریسک:** HTML ذخیره‌شدهٔ پست بلاگ بدون sanitize رندر می‌شود. زنجیرهٔ واقعیِ سوءاستفاده همان +CKEditor بالاست: نویسنده‌ای که HTML مخرب paste کند، آن را روی صفحهٔ بازبینی به ادمین می‌رساند. + +**۳. بازتولید:** `grep -rn "dangerouslySetInnerHTML" assets/admin/` → تنها یک hit، همین خط. + +**۴. وضعیت:** باز طبق سیاست (Medium بدون تأیید رفع نمی‌شود). CSP فعلی `script-src 'self'` است، پس +`', + ]); + $this->assertSame(201, $this->responseCode()); + + $body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']); + $this->assertStringNotContainsString('assertStringNotContainsString('alert(1)', $body); + $this->assertStringContainsString('سلام', $body, 'متن سالم نباید حذف شود'); + } + + public function testInlineHandlerAndJavascriptUrlAreStripped(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $res = $this->authJson('POST', '/api/v1/blog', $admin, [ + 'title' => 'مقالهٔ تست', + 'body' => '

متن

لینک' + . '', + ]); + $this->assertSame(201, $this->responseCode()); + + $body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']); + $this->assertStringNotContainsString('onclick', $body); + $this->assertStringNotContainsString('onerror', $body); + $this->assertStringNotContainsString('javascript:', $body); + $this->assertStringContainsString('متن', $body); + } + + public function testSafeRichTextSurvives(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $html = '

عنوان

پررنگ و کج

' + . '
  • یک
  • دو
' + . 'پیوند' + . '
خانه
'; + + $res = $this->authJson('POST', '/api/v1/blog', $admin, [ + 'title' => 'مقالهٔ تست', + 'body' => $html, + ]); + $this->assertSame(201, $this->responseCode()); + + $body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']); + foreach (['assertStringContainsString($tag, $body, "عنصر مجاز {$tag} نباید حذف شود"); + } + $this->assertStringContainsString('https://example.com', $body); + // لینک باید rel امن بگیرد، وگرنه tabnabbing باز می‌ماند. + $this->assertStringContainsString('noopener', $body); + } + + public function testUpdatePathIsSanitizedToo(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $res = $this->authJson('POST', '/api/v1/blog', $admin, [ + 'title' => 'مقالهٔ تست', + 'body' => '

اولیه

', + ]); + $uuid = $res['data']['data']['uuid'] ?? $res['data']['uuid']; + + $this->authJson('PATCH', "/api/v1/blog/{$uuid}", $admin, [ + 'body' => '

ویرایش

', + ]); + $this->assertSame(200, $this->responseCode()); + + $body = $this->storedBody($uuid); + $this->assertStringNotContainsString('assertStringContainsString('ویرایش', $body); + } + + /** بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی است → ۴۲۲، نه ذخیره. */ + public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $this->authJson('POST', '/api/v1/blog', $admin, [ + 'title' => 'مقالهٔ تست', + 'body' => '', + ]); + $this->assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index c79763fc..232beb97 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -415,4 +415,65 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $this->assertSame(403, $this->responseCode()); } + // ── پروتکل درمان ───────────────────────────────────────────────────────── + // + // آدیت ۲۰۲۶-۰۸-۰۷: TreatmentProtocolController هیچ گِیت مجوزی نداشت و فقط + // مالکیتِ tenant را می‌سنجید، پس منشیِ `services:false` می‌توانست پروتکل را + // بخواند، بازنویسی کند و حذف کند. پروتکل خاصیتِ سرویس است، پس مجوزش `services` + // است. uuidِ ناموجود عمدی است: گیت پیش از واکشیِ سرویس اجرا می‌شود، پس ۴۰۳ + // در برابر ۴۰۴ دقیقاً همان چیزی را جدا می‌کند که این تست‌ها می‌سنجند. + + private const ABSENT_SERVICE = '00000000-0000-0000-0000-000000000000'; + + public function testTreatmentProtocolReadDeniedByDefault(): void + { + // DEFAULT_PERMISSIONS: services.* = false + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testTreatmentProtocolReadAllowedWhenServicesGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['services' => ['view' => true]]]); + $this->em->flush(); + + // گیت عبور می‌کند و به «سرویس یافت نشد» می‌رسد — نه ۴۰۳. + $this->authJson('GET', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary); + $this->assertSame(404, $this->responseCode()); + } + + public function testTreatmentProtocolWriteNeedsServicesUpdate(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['services' => ['view' => true, 'update' => false]]]); + $this->em->flush(); + + $path = '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol'; + + // خواندن مجاز است… + $this->authJson('GET', $path, $secretary); + $this->assertSame(404, $this->responseCode()); + + // …ولی بازنویسی و خاموش‌کردنِ سوییچ نه. + $this->authJson('PUT', $path, $secretary, ['steps' => []]); + $this->assertSame(403, $this->responseCode(), 'بازنویسی پروتکل باید services.update بخواهد'); + + $this->authJson('DELETE', $path, $secretary); + $this->assertSame(403, $this->responseCode(), 'حذف پروتکل باید services.update بخواهد'); + } + + public function testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['services' => ['view' => true, 'update' => true]]]); + $this->em->flush(); + + $this->authJson('DELETE', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary); + $this->assertSame(404, $this->responseCode()); + } + } diff --git a/tests/Shared/ApiLeastPrivilegeTest.php b/tests/Shared/ApiLeastPrivilegeTest.php new file mode 100644 index 00000000..80253969 --- /dev/null +++ b/tests/Shared/ApiLeastPrivilegeTest.php @@ -0,0 +1,196 @@ + 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', + ]; + + /** منشی‌ای که هیچ مجوزی ندارد — همهٔ منابع رجیستری خاموش. */ + 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), + )); + } +} diff --git a/yarn.lock b/yarn.lock index 81ecff34..95ca89c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3974,10 +3974,10 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz" - integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== +cookie-es@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz" + integrity sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg== core-js-compat@^3.48.0: version "3.49.0" @@ -5236,10 +5236,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21: - version "4.17.21" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.18.1: + version "4.18.1" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.debounce@^4.0.8: version "4.0.8" @@ -6246,7 +6246,7 @@ punycode@^2.3.1: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -"react-dom@^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18.0 || ^19.0", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react-dom@^19.0.0, react-dom@>=16, react-dom@>=16.4.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=18: +"react-dom@^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18.0 || ^19.0", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react-dom@^19.0.0, react-dom@>=16, react-dom@>=16.4.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=19.2.7: version "19.2.7" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz" integrity sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ== @@ -6303,20 +6303,12 @@ react-refresh@^0.17.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz" integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== -react-router-dom@^7.0.0: - version "7.17.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz" - integrity sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw== +react-router@^8.3.0: + version "8.3.0" + resolved "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz" + integrity sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ== dependencies: - react-router "7.17.0" - -react-router@7.17.0: - version "7.17.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz" - integrity sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ== - dependencies: - cookie "^1.0.1" - set-cookie-parser "^2.6.0" + cookie-es "^3.1.1" react-select@^5.0.0: version "5.10.2" @@ -6343,7 +6335,7 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" -"react@^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0 || ^19.0", "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.0.0, react@^19.2.7, "react@>= 16 || ^19.0.0-rc", react@>=16, react@>=16.4.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18, react@>=18.0.0: +"react@^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0 || ^19.0", "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.0.0, react@^19.2.7, "react@>= 16 || ^19.0.0-rc", react@>=16, react@>=16.4.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18.0.0, react@>=19.2.7: version "19.2.7" resolved "https://registry.npmjs.org/react/-/react-19.2.7.tgz" integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== @@ -6684,11 +6676,6 @@ serialize-javascript@^7.0.3: resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz" integrity sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw== -set-cookie-parser@^2.6.0: - version "2.7.2" - resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz" - integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== - shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz"