feat(secretary): add services permission resource + panel gating (phase A)
Secretaries could reach neither the services module (EntityContextResolver does not recognise a secretary as clinic owner, so they resolved to `unknown` → 403) nor had any toggle to grant it. Add `services` as a first-class secretary permission resource, enforced end-to-end. Backend - DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny). - SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor) resolution from the secretary's active context, for controllers whose data is fetched by [entityType, entityId] and whose generic resolver is not secretary-aware. - ClinicServiceController: resolveEntity() is now secretary-aware; every action (sections, items, tariffs — 13 total) guards with `services` view/create/ update/delete via denyUnlessGranted, ahead of the subscription gate. Frontend - SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services` section so owners can grant it. - Sidebar (secretary branch): services / inventory / tags menu items gated by can(resource, 'view'). - RoleRoute: a secretary now needs the page's `permission` to open it (direct URL entry included); clinic-services, inventory, tags-settings routes accept secretary + permission gate. Tests - SecretaryResourceEnforcementTest: services denied-by-default, allowed-when- granted, create-denied-while-view-granted. - Sidebar.test: secretary menu gating for services/inventory/tags. Docs: secretary.md + clinic-services.md updated with the `services` resource and the resolveOwnerEntity note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
# Secretary Permissions — Full Coverage & Panel Enforcement (Phase 2 / completion)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend Symfony + پنل ادمین React). تکپروژه، cross-repo نیست.
|
||||
|
||||
> پیش از هر گرِپ/خواندن، طبق قانون پروژه اول `graphify query "..."` بزن. بعد از هر تغییر کد (پس از commit): `graphify update .`.
|
||||
|
||||
## زمینه — چه چیزی از قبل انجام شده (تأییدشده در کد)
|
||||
|
||||
فاز اول ([secretary-permissions-coverage-and-panel-enforcement.md](secretary-permissions-coverage-and-panel-enforcement.md)) بخش زیادی را ساخته است. **دوباره نساز، فقط شکافها را کامل کن.**
|
||||
|
||||
آنچه **قبلاً هست**:
|
||||
|
||||
- **مدل مجوز:** `DoctorSecretary::DEFAULT_PERMISSIONS['resources']` با ۸ منبع:
|
||||
`appointments, patients, payments, insurances, addresses, clinic_info, inventory, tags`
|
||||
(`src/Secretary/Entity/DoctorSecretary.php:20`).
|
||||
- **Enforcement مشترک:** `src/Secretary/Security/SecretaryAccessChecker.php` — نقطهٔ واحد
|
||||
(`can()`, `canOrNonSecretary()`, `denyUnlessGranted()`). صدا زده میشود در:
|
||||
`PaymentController`, `PatientController`, `InventoryController`, `TenantTagController`,
|
||||
`InsuranceController`, `PaymentMethodController`, و `AppointmentAccessChecker`.
|
||||
- **اسکوپ per-doctor نوبتها:** `MyAppointmentsController::resolveSecretaryFilter()` (L316) لیست را
|
||||
به `doctorIds` مجاز (ردیفهای `DoctorSecretary`) محدود میکند؛ `AppointmentRepository::findByUserAndDoctorIds()`
|
||||
و `PatientRecordScopeResolver::forSecretary()` (L87) هم بر همان مجموعه پزشکان فیلتر میکنند.
|
||||
- **Frontend gating primitive:** `assets/admin/hooks/usePermissions.ts` → `can(resource, action)`
|
||||
(خالیبودن `permissions` = محیط شخصی، آزاد). Sidebar از آن استفاده میکند.
|
||||
- **UI مجوزها:** `assets/admin/pages/MySecretariesPage.tsx` → `PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS`
|
||||
همان ۸ منبع را نمایش میدهد. `SecretaryPermissions` type در `assets/admin/types/index.ts:458`.
|
||||
|
||||
## مشکل / هدف — شکافهای باقیمانده
|
||||
|
||||
سه شکاف مشخص، مطابق پلن ۱۰-پرامپتی کاربر:
|
||||
|
||||
1. **منابع مجوزِ جاندادهشده (Coverage gap).** این صفحات/ماژولها منشی به آنها دسترسی دارد یا در
|
||||
منوی تنظیمات دیده میشوند ولی **هیچ toggle مجوز ندارند** و enforce نمیشوند:
|
||||
`services` (سرویسها)، `discounts` (تخفیفها)، `sms` (پیامکها)، `staff` (پرسنل)،
|
||||
`clinic_doctors` (مدیریت پزشکان کلینیک — فقط حالت کلینیک)، `appointment_settings` (تنظیمات نوبتدهی)،
|
||||
`subscription` (خرید اشتراک/پرداختها).
|
||||
2. **اعمال ناقص در Frontend (Panel gap).** `usePermissions().can` فقط برای
|
||||
`appointments/patients/payments/insurances` در Sidebar استفاده شده
|
||||
(`assets/admin/components/layout/Sidebar.tsx:365-388`). `inventory`, `tags` و منابع جدید
|
||||
نه در Sidebar گِیت میشوند، نه Route آنها در `App.tsx` گارد دارد، نه دکمههای CRUD صفحه پنهان میشوند.
|
||||
3. **حساب کاربری (Account) نباید هیچ مجوزی داشته باشد** — باید همیشه برای منشی باز باشد؛ مطمئن شو
|
||||
هیچ گارد اشتباهی رویش نیست (Prompt 3).
|
||||
|
||||
هدف نهایی: مالک (پزشک مستقل یا کلینیک) دقیقاً تعیین کند هر منشی به کدام صفحه/عملیات دسترسی دارد،
|
||||
و منشی فقط همانها را در Sidebar/Route/دکمهها ببیند و API هم خارج از مجوز ۴۰۳ بدهد. تست برای **دو حالت:
|
||||
پزشک مستقل** و **کلینیک چند-پزشکه**.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش | کار لازم |
|
||||
|------|-----|----------|
|
||||
| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` | افزودن منابع جدید |
|
||||
| `src/Secretary/Security/SecretaryAccessChecker.php` | نقطهٔ واحد enforcement | بدون تغییر ساختار؛ فقط صدا زدن در کنترلرهای جدید |
|
||||
| `src/DoctorService/Controller/DoctorServiceController.php` · `src/ClinicService/Controller/ClinicServiceController.php` | سرویسها | گارد `services` |
|
||||
| `src/Discount/Controller/DiscountController.php` | تخفیفها | گارد `discounts` |
|
||||
| `src/Sms/Controller/{SmsController,SmsMessageController,SmsWalletController}.php` | پیامکها | گارد `sms` |
|
||||
| `src/Staff/Controller/StaffController.php` | پرسنل | گارد `staff` |
|
||||
| `src/Clinic/Controller/ClinicController.php` (بخش پزشکان کلینیک) | مدیریت پزشکان کلینیک | گارد `clinic_doctors` — فقط clinic |
|
||||
| `src/Appointment/Controller/AppointmentSettingsController.php` | تنظیمات نوبتدهی | گارد `appointment_settings` |
|
||||
| `src/Subscription/Controller/SubscriptionController.php` | اشتراک | گارد `subscription` (یا حذف `ROLE_SECRETARY` اگر نباید ببیند) |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز | افزودن `PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS` منابع جدید |
|
||||
| `assets/admin/types/index.ts` (`SecretaryPermissions` L458) | type | افزودن کلیدهای جدید |
|
||||
| `assets/admin/hooks/usePermissions.ts` | `can()` | بدون تغییر — استفاده گستردهتر |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` (بخش `primaryRole === "secretary"` L361) | منوی منشی | گِیت هر آیتم با `can(resource,'view')` |
|
||||
| `assets/admin/App.tsx` | جدول Route | گارد Route هر صفحهٔ منشی |
|
||||
| صفحات پنل هر منبع (Services/Discounts/Sms/Staff/…Page.tsx) | دکمههای CRUD | پنهانکردن دکمه create/edit/delete با `can()` |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
منبع حقیقت مجوز:
|
||||
|
||||
```php
|
||||
// src/Secretary/Entity/DoctorSecretary.php:20
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
|
||||
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
// ← منابع جدید اینجا اضافه میشوند
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
نقطهٔ واحد enforcement (از این الگو در کنترلرهای جدید استفاده کن — چیز جدید نساز):
|
||||
|
||||
```php
|
||||
// src/Secretary/Security/SecretaryAccessChecker.php:73
|
||||
public function denyUnlessGranted(User $user, string $resource, string $action): void
|
||||
{
|
||||
if (!$this->canOrNonSecretary($user, $resource, $action)) {
|
||||
// → ۴۰۳ استاندارد
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Frontend فقط ۴ منبع را در Sidebar گِیت کرده:
|
||||
|
||||
```tsx
|
||||
// assets/admin/components/layout/Sidebar.tsx (primaryRole === "secretary")
|
||||
if (can("appointments", "view")) { ... }
|
||||
if (can("patients", "view")) { ... }
|
||||
if (can("payments", "view")) { ... }
|
||||
if (can("insurances", "view")) { ... }
|
||||
// ← inventory / tags / services / discounts / sms / staff / clinic_doctors / appointment_settings گِیت نشدهاند
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
> قانون هر منبع: **همزمان در هر ۳ جا** اضافه شود وگرنه merge/نمایش میشکند —
|
||||
> (۱) `DoctorSecretary::DEFAULT_PERMISSIONS`، (۲) `MySecretariesPage.tsx` (`EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS`)،
|
||||
> (۳) `SecretaryPermissions` type. سپس backend enforce + frontend gate.
|
||||
> بعد از هر تغییر endpoint: `docs/api/*` همان session بهروز شود. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
|
||||
|
||||
### ۰. ممیزی و تصمیم (اول این)
|
||||
|
||||
1. `grep -rln ROLE_SECRETARY src/` را با فهرست صفحاتی که منشی در Sidebar/پنل میبیند تطبیق بده.
|
||||
جدول بساز: **منبع | کنترلر(ها) | toggle دارد؟ | backend enforce؟ | sidebar gate؟ | route guard؟ | CRUD gate؟**.
|
||||
2. برای هر ماژول تصمیم بگیر: **الف)** منشی میتواند دسترسی داشته باشد → toggle اضافه کن؛
|
||||
**ب)** هرگز نباید ببیند → `ROLE_SECRETARY` را از کنترلر بردار و در پرامپت دلیل را بنویس. (مثلاً `subscription`
|
||||
احتمالاً باید فقط برای owner باشد؛ اگر منشی نباید بخرد، بهجای toggle نقش را حذف کن.)
|
||||
|
||||
### ۱. افزودن منابع مجوز جدید (Prompt 2 + 4 + 9)
|
||||
|
||||
برای هر منبعِ زیر که در ممیزی «باید toggle داشته باشد»، در هر ۳ جا اضافه کن:
|
||||
|
||||
```php
|
||||
// DoctorSecretary::DEFAULT_PERMISSIONS['resources'] — همه پیشفرض false (اصل least-privilege)
|
||||
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'discounts' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'appointment_settings' => ['view' => false, 'update' => false],
|
||||
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], // فقط clinic
|
||||
```
|
||||
|
||||
- `mergePermissions` عمیق merge میکند؛ منشیهای موجود نمیشکنند. اما یک data migration اختیاری برای
|
||||
backfill کلیدهای جدید روی ردیفهای قدیمی در نظر بگیر (یا در `getPermissions()` با `DEFAULT_PERMISSIONS` ادغام کن).
|
||||
- `clinic_doctors` فقط در حالت `OWNER_CLINIC` معنا دارد: در `MySecretariesPage.tsx` این section را
|
||||
فقط وقتی نمایش بده که owner کلینیک است (پزشک مستقل نباید این toggle را ببیند — Prompt 9).
|
||||
|
||||
### ۲. Enforcement در Backend (Prompt 6)
|
||||
|
||||
در هر کنترلر جدید، **در ابتدای هر اکشن** با نقطهٔ واحد گارد بگذار (چیز جدید نساز):
|
||||
|
||||
```php
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); // GET
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); // POST
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); // PUT/PATCH
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); // DELETE
|
||||
```
|
||||
|
||||
- `canOrNonSecretary` تضمین میکند برای owner/پزشک/ادمین رفتار تغییر نکند (فقط منشی محدود شود).
|
||||
- نبود مجوز → همان ۴۰۳ استاندارد `AccessChecker` (نه ۲۰۰ با لیست خالی، نه ۵۰۰).
|
||||
- کنترلرهای: `DoctorServiceController`/`ClinicServiceController` (`services`)، `DiscountController` (`discounts`)،
|
||||
`Sms*Controller` (`sms`)، `StaffController` (`staff`)، `AppointmentSettingsController` (`appointment_settings`)،
|
||||
بخش پزشکانِ `ClinicController` (`clinic_doctors`).
|
||||
|
||||
### ۳. Enforcement در Frontend — Sidebar + Route + CRUD (Prompt 5 + 7)
|
||||
|
||||
1. **Sidebar** (`Sidebar.tsx`، بخش `secretary`): هر آیتم منو را با `can(resource,'view')` بپیچ —
|
||||
شامل `inventory`, `tags` و همهٔ منابع جدید. منشی نباید هیچ منوی بدونمجوز ببیند و نباید هیچ منوی اضافی بماند.
|
||||
2. **Route guard** (`App.tsx`): هر Route صفحهٔ منشی را گارد کن؛ ورود مستقیم با URL بدون مجوز →
|
||||
redirect یا صفحهٔ «دسترسی ندارید» فارسی استاندارد (نه صفحهٔ سفید). اگر helper مشترکی نیست،
|
||||
یک `<RequirePermission resource action>` سبک بساز که از `usePermissions().can` استفاده کند
|
||||
(از `FeatureGate` موجود الگو بگیر).
|
||||
3. **CRUD/Action buttons** در هر صفحه: دکمههای create/edit/delete/import/export و
|
||||
FloatingAction/ContextMenu/QuickAction را با `can(resource, action)` شرطی کن. نبودِ مجوز = پنهان، نه disable.
|
||||
4. Breadcrumb صفحهٔ بدونمجوز هم نباید ساخته شود.
|
||||
|
||||
### ۴. حساب کاربری بدون مجوز (Prompt 3)
|
||||
|
||||
- صفحهٔ Account/پروفایل کاربری باید برای منشی همیشه باز باشد. بررسی کن هیچ `ROLE_*` سختگیرانه یا
|
||||
`denyUnlessGranted` اشتباه رویش نیست و هیچ toggle برایش تعریف نشده. اگر endpoint اکانت
|
||||
`IS_AUTHENTICATED_FULLY` دارد کافی است — دست نزن.
|
||||
|
||||
### ۵. اسکوپ per-doctor نوبتها — تکمیل پوشش عملیات (Prompt 8)
|
||||
|
||||
اسکوپ لیست از قبل هست (`resolveSecretaryFilter`)؛ فقط مطمئن شو **همهٔ** عملیات نوبت هم به مجموعهٔ
|
||||
پزشکان مجاز محدودند، نه فقط GET لیست:
|
||||
|
||||
- create / update / cancel / update_status / قطعیکردن / پرداخت / فاکتور / تغییر وضعیت / مشاهدهٔ پروندهٔ بیمار.
|
||||
- در هر اکشن، پیش از عمل چک کن `doctor` هدف در `allowed doctorIds` منشی باشد (از `DoctorSecretaryRepository`)؛
|
||||
در غیر این صورت ۴۰۳ — حتی اگر `appointments.<action>=true`. (مجوز action و اسکوپ doctor **هر دو** لازماند.)
|
||||
- تست هر دو سناریو: پزشک مستقل (فقط نوبتهای خودش) و کلینیک با منشیِ محدود به زیرمجموعهای از پزشکان.
|
||||
|
||||
### ۶. تست نهایی و ماتریس (Prompt 10)
|
||||
|
||||
- Backend (`ddev exec php bin/phpunit`): برای هر منبع تازه، تست منشی مجاز (۲۰۰) و غیرمجاز (۴۰۳)،
|
||||
و برای نوبتها تست دسترسی به پزشک خارج از اسکوپ (۴۰۳).
|
||||
- Frontend (`yarn test`): Sidebar با permissionهای مختلف فقط آیتمهای مجاز را رندر کند؛
|
||||
دکمههای CRUD بدون مجوز رندر نشوند.
|
||||
- دستی با منشیِ واقعی (`clinicpro-QA accounts`) در دو حالت پزشک مستقل و کلینیک.
|
||||
- جدول نهایی را در خروجی بده و فقط وقتی «تمام» اعلام کن که همه PASS باشند:
|
||||
|
||||
```
|
||||
Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested
|
||||
appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
|
||||
patients | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
|
||||
payments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
|
||||
insurances | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
|
||||
addresses | ✓ | … | … | ✓ | … | ?
|
||||
clinic_info | ✓ | … | … | ✓ | … | ?
|
||||
inventory | ✓ | ? | ? | ✓ | ? | ?
|
||||
tags | ✓ | ? | ? | ✓ | ? | ?
|
||||
services | + | + | + | + | + | ?
|
||||
discounts | + | + | + | + | + | ?
|
||||
sms | + | + | + | + | + | ?
|
||||
staff | + | + | + | + | + | ?
|
||||
appointment_settings | + | + | + | + | + | ?
|
||||
clinic_doctors(clinic)| + | + | + | + | + | ?
|
||||
```
|
||||
(`+` = این پرامپت باید بسازد، `…`/`?` = ممیزی وظیفهٔ ۰ وضعیت واقعی را پر کند.)
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **منبع حقیقت = `DoctorSecretary.permissions` JSON.** enforcement جدید همین را بخواند، نه
|
||||
`ClinicDoctorPermissionChecker` (که مال پزشکِ عضو کلینیک است، نه منشی).
|
||||
- نقطهٔ واحد enforcement از قبل هست: `SecretaryAccessChecker`. **کنترلر/checker موازی نساز** — فقط `denyUnlessGranted` صدا بزن (اصل ۲ CLAUDE.md: اول موجود را استفاده کن).
|
||||
- هر منبع همزمان در سهگانهٔ Entity default / UI section / TS type اضافه شود.
|
||||
- `clinic_doctors` فقط حالت کلینیک؛ برای پزشک مستقل نه toggle نه منو.
|
||||
- least-privilege: منابع جدید پیشفرض همه `false`.
|
||||
- تاریخها Unix timestamp؛ پاسخها `$this->success()/$this->error()`؛ لیستها array-hydration.
|
||||
- رشتههای UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفتوگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیادهسازی.
|
||||
- بعد از تغییر endpointها، این فایلهای `docs/api/` بهروز: `secretary.md`, `service.md`/`clinic-service.md`, `discount.md`, `sms.md`, `staff.md`, `clinic.md`, `appointment.md`, `subscription.md`.
|
||||
+13
-5
@@ -121,7 +121,11 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
roles: string[];
|
||||
blockClinicScope?: boolean;
|
||||
/** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا میشود. */
|
||||
/**
|
||||
* [resource, action] — گیت مجوز صفحه.
|
||||
* • پزشکِ مهمان در محیط کلینیک با داشتن این مجوز از blockClinicScope مستثنا میشود.
|
||||
* • منشی فقط با داشتن این مجوز به صفحه دسترسی دارد (همراستا با enforcement سمت API).
|
||||
*/
|
||||
permission?: [string, string];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -130,6 +134,10 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
const { can } = usePermissions();
|
||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
// منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند.
|
||||
if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
// پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
|
||||
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
||||
if (!permission || !can(permission[0], permission[1])) {
|
||||
@@ -243,14 +251,14 @@ export default function App() {
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><StaffPage /></RoleRoute>} />
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><TagsSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
|
||||
<Route path="appointment-settings" element={<RoleRoute roles={['doctor']} blockClinicScope><AppointmentSettingsPage /></RoleRoute>} />
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><DiscountsPage /></RoleRoute>} />
|
||||
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><PaymentSuccessPage /></RoleRoute>} />
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InventoryPage /></RoleRoute>} />
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
|
||||
|
||||
@@ -76,3 +76,44 @@ describe("Sidebar — خدمات در منوی اصلی (نه تنظیمات)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => {
|
||||
const setSecretary = (resources: Record<string, Record<string, boolean>>) =>
|
||||
useAuthStore.setState({
|
||||
primaryRole: "secretary",
|
||||
dbUuid: "c1",
|
||||
userName: "منشی",
|
||||
availableContexts: [],
|
||||
context: { scope: "clinic", permissions: { resources } },
|
||||
} as any);
|
||||
|
||||
it("منشیِ بدون مجوز، سرویسها/انبار/تگها را نمیبیند", () => {
|
||||
setSecretary({ appointments: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.queryByText("سرویس ها")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("انبارداری")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("تگها")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("با مجوز services.view، آیتم «سرویس ها» به /admin/clinic-services میرود", () => {
|
||||
setSecretary({ services: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.getByText("سرویس ها").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/admin/clinic-services",
|
||||
);
|
||||
});
|
||||
|
||||
it("با مجوز inventory.view و tags.view، همان آیتمها نمایش داده میشوند", () => {
|
||||
setSecretary({ inventory: { view: true }, tags: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.getByText("انبارداری").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/admin/inventory",
|
||||
);
|
||||
expect(screen.getByText("تگها").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/admin/tags-settings",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,6 +401,27 @@ function buildSections(
|
||||
},
|
||||
);
|
||||
}
|
||||
if (can("services", "view")) {
|
||||
items.push({
|
||||
to: "/admin/clinic-services",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
});
|
||||
}
|
||||
if (can("inventory", "view")) {
|
||||
items.push({
|
||||
to: "/admin/inventory",
|
||||
icon: ArchiveBoxIcon,
|
||||
label: "انبارداری",
|
||||
});
|
||||
}
|
||||
if (can("tags", "view")) {
|
||||
items.push({
|
||||
to: "/admin/tags-settings",
|
||||
icon: TagIcon,
|
||||
label: "تگها",
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -75,6 +75,7 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = {
|
||||
clinic_info: { view: false, update: false },
|
||||
inventory: { view: false, create: false, update: false, delete: false },
|
||||
tags: { view: false, create: false, update: false, delete: false },
|
||||
services: { view: false, create: false, update: false, delete: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
@@ -162,6 +163,16 @@ const PERMISSION_SECTIONS: {
|
||||
{ key: "delete", label: "حذف تگ" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "services",
|
||||
title: "خدمات و تعرفهها",
|
||||
items: [
|
||||
{ key: "view", label: "مشاهده خدمات" },
|
||||
{ key: "create", label: "ایجاد خدمت" },
|
||||
{ key: "update", label: "ویرایش خدمت" },
|
||||
{ key: "delete", label: "حذف خدمت" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function PermissionAccordions({
|
||||
|
||||
@@ -21,6 +21,7 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||
clinic_info: { view: true, update: false },
|
||||
inventory: { view: false, create: false, update: false, delete: false },
|
||||
tags: { view: false, create: false, update: false, delete: false },
|
||||
services: { view: false, create: false, update: false, delete: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
@@ -96,6 +97,15 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
services: {
|
||||
label: 'خدمات و تعرفهها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function PermissionsMatrix({
|
||||
|
||||
@@ -502,6 +502,12 @@ export interface SecretaryPermissions {
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
services: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Specialty {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**نیاز به پنل:** Basic یا بالاتر (`ERR_SUBSCRIPTION_REQUIRED` اگر نداشت)
|
||||
|
||||
**دسترسیِ منشی:** همهٔ endpointها با مجوزِ منشیِ `services` گِیت میشوند (`view`/`create`/`update`/`delete`)؛ نبودِ مجوز → `403 ERR_FORBIDDEN_001` و این گیت **پیش از** گیتِ اشتراک اجرا میشود. owner از محیطِ فعالِ منشی با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود (چون `EntityContextResolver` منشی را مالک نمیشناسد). نقشهای owner/پزشک/ادمین بدون تغییر عبور میکنند. جزئیات: [secretary.md](secretary.md).
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/service-sections
|
||||
|
||||
@@ -81,6 +81,12 @@ Create a secretary for a doctor.
|
||||
"create": false,
|
||||
"update": false,
|
||||
"delete": false
|
||||
},
|
||||
"services": {
|
||||
"view": false,
|
||||
"create": false,
|
||||
"update": false,
|
||||
"delete": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +125,7 @@ Create a secretary for a doctor.
|
||||
|
||||
**Permissions Structure:**
|
||||
|
||||
مجموعهٔ منابع (resources) بر اساس صفحات و ماژولهای در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`. `mergePermissions` هر منبع/اکشن ارسالشده را deep-merge میکند. منابع `inventory` و `tags` بهصورت پیشفرض همه `false`اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`.
|
||||
مجموعهٔ منابع (resources) بر اساس صفحات و ماژولهای در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`, `services`. `mergePermissions` هر منبع/اکشن ارسالشده را deep-merge میکند. منابع `inventory`, `tags`, `services` بهصورت پیشفرض همه `false`اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`.
|
||||
|
||||
**اعمال (enforcement):** همهٔ منابع در بکاند enforce میشوند، نه فقط `appointments`. منبعِ حقیقت، ستون JSON `permission` روی ردیفِ فعالِ `DoctorSecretary` در محیطِ فعالِ کاربر (`UserActiveContext.db_uuid`) است؛ نقطهٔ مرکزی `App\Secretary\Security\SecretaryAccessChecker` (`can` / `canOrNonSecretary` / `denyUnlessGranted`). نبودِ مجوز → `403 ERR_FORBIDDEN_001`. نقشه:
|
||||
|
||||
@@ -131,6 +137,7 @@ Create a secretary for a doctor.
|
||||
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
|
||||
| `inventory` | `InventoryController` (items + packages) | view/create/update/delete |
|
||||
| `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتنها با `tags.*`) | view/create/update/delete |
|
||||
| `services` | `ClinicServiceController` (sections + items + tariffs). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||||
|
||||
نقشهای غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور میکنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همهچیز `403`.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
@@ -49,6 +50,7 @@ class ClinicServiceController extends BaseController
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -138,6 +140,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
public function listSections(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -155,6 +158,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section', methods: ['POST'])]
|
||||
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -174,6 +178,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -198,6 +203,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -217,6 +223,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||
@@ -235,6 +242,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
@@ -248,6 +256,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
|
||||
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -262,6 +271,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])]
|
||||
public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -278,6 +288,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -332,6 +343,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -386,6 +398,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -416,6 +429,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -436,6 +450,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -499,6 +514,12 @@ class ClinicServiceController extends BaseController
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
// EntityContextResolver منشی را مالکِ محیط نمیشناسد؛ محیطِ فعالِ او را
|
||||
// جداگانه به owner (کلینیک/پزشک) حل میکنیم. مجوز با denyUnlessGranted جداست.
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
return $this->secretaryAccess->resolveOwnerEntity($user);
|
||||
}
|
||||
|
||||
return $this->contextResolver->resolve($user, $this->requestedClinicUuid())->toEntityPair();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DoctorSecretary
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@ class SecretaryAccessChecker
|
||||
return $relation !== null && $this->permissions->can($relation, $resource, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک.
|
||||
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی میکنند و resolverِ
|
||||
* عمومی (EntityContextResolver) منشی را نمیشناسد. مجوز جدا با
|
||||
* denyUnlessGranted بررسی میشود؛ این متد فقط owner را حل میکند.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} ['clinic'|'doctor'|'unknown', id|null]
|
||||
*/
|
||||
public function resolveOwnerEntity(User $user): array
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقشها true.
|
||||
*/
|
||||
|
||||
@@ -77,4 +77,41 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $secretary, ['name' => 'گاز استریل']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
// service-items (listAllItems) فقط توگلِ permission را میسنجد — برخلاف
|
||||
// service-sections که پیش از آن، گیتِ اشتراک (assertServicesGate) هم دارد.
|
||||
public function testServicesDeniedByDefault(): void
|
||||
{
|
||||
// DEFAULT_PERMISSIONS: services.* = false
|
||||
[$secretary] = $this->makeClinicSecretary();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-items', $secretary);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testServicesAllowedWhenGranted(): void
|
||||
{
|
||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||
$rel->mergePermissions(['resources' => ['services' => ['view' => true]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-items', $secretary);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testServicesCreateDeniedButViewGranted(): void
|
||||
{
|
||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||
$rel->mergePermissions(['resources' => ['services' => ['view' => true, 'create' => false]]]);
|
||||
$this->em->flush();
|
||||
|
||||
// مشاهده مجاز
|
||||
$this->authJson('GET', '/api/v1/service-items', $secretary);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
// ایجاد ممنوع — گیتِ permission پیش از گیتِ اشتراک اجرا میشود.
|
||||
$this->authJson('POST', '/api/v1/service-section', $secretary, ['name' => 'بخش تست']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user