diff --git a/.claude/prompt/representation-edit-owned-doctor-clinic.md b/.claude/prompt/representation-edit-owned-doctor-clinic.md new file mode 100644 index 00000000..d269d9b3 --- /dev/null +++ b/.claude/prompt/representation-edit-owned-doctor-clinic.md @@ -0,0 +1,510 @@ +# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبت‌کننده + +## پروژه + +`clinicpro` — بک‌اند Symfony و پنل ادمین React. + +تک‌ریپو است. `nobat724_front` فقط سه فراخوانی داشبورد نماینده دارد +(`services/response.js` خطوط ۲۰۲ تا ۲۱۱) و به هیچ‌کدام از اندپوینت‌های این تسک دست نمی‌زند. + +## زمینه + +نماینده امروز می‌تواند پزشک و کلینیک بسازد. هنگام ساخت، +`representation_id` روی رکورد ست می‌شود: + +```php +// src/Representation/Controller/RepresentationActionController.php:299 +$rep = $this->representationRepo->findByUser($user); +if ($rep !== null) { + $doctor->setRepresentationId($rep->getId()); +} +``` + +ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد. +نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس. +عملاً onboarding نیمه‌کاره می‌ماند و پزشک تازه‌ساخته روی سایت عمومی +یک رکورد خالی است. + +## مشکل / هدف + +نماینده باید روی هر پزشک و کلینیکی که `representation_id` آن به او اشاره دارد، +فیلدهای **محتوایی و ظاهری** را ویرایش کند — و فقط همان‌ها. + +مجوز **دائمی** است و به `representation_id` گره می‌خورد. +هیچ فیلد جدید، هیچ migration، هیچ state تازه‌ای لازم نیست. + +## معیار پذیرش + +- ✅ موفق: نماینده‌ای که پزشک X را ساخته، + `PATCH /api/v1/doctor/{X.uuid}` با بدنهٔ `{"info": "متن جدید", "images": [...]}` می‌فرستد + → `200` و رکورد ذخیره می‌شود. + همین برای `PATCH /api/v1/clinic/{uuid}` با `{"clinic_logo": "...", "info": "..."}`. +- ✅ موفق: `GET /api/v1/doctor/{X.uuid}` با توکن همان نماینده → `can_edit: true`. + همان درخواست با توکن نمایندهٔ دیگر → `can_edit: false`. + بدون توکن → `can_edit: false` و بقیهٔ پاسخ مثل قبل. +- ❌ خطا: نمایندهٔ **دیگری** (که این پزشک را نساخته) همان PATCH را بفرستد + → `403` با `ERR_AUTH_006`. +- ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد + (`medical_system_code` یا `active` برای پزشک، `doctors` برای کلینیک) + → `403` با نام همان فیلد در `errors[0].field`. **هیچ چیزی ذخیره نمی‌شود.** +- ⚠️ مرزی: کاربری با `ROLE_REPRESENTATION` که ردیف `Representation` ندارد + → `403`، نه `500`. +- ⚠️ مرزی: پزشکی که `representation_id` آن `null` است + → هیچ نماینده‌ای اجازه ندارد؛ `403`. +- ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین **دقیقاً مثل قبل** رفتار می‌کنند — + whitelist روی آن‌ها اعمال نمی‌شود و همچنان می‌توانند `medical_system_code` + و `doctors` را عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمی‌کند. +- ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف `AppLog` می‌سازد. + PATCH مالک یا ادمین هیچ ردیفی نمی‌سازد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Representation/Security/RepresentationEditPolicy.php` | **جدید** — مالکیت و whitelist | +| `src/Doctor/Controller/DoctorController.php` | `update`، `show`، سه اکشن آدرس پزشک | +| `src/Clinic/Controller/ClinicController.php` | `update`، `show`، سه اکشن آدرس کلینیک | +| `src/Representation/Repository/RepresentationRepository.php` | `findByUser()` موجود است | +| `src/Shared/Logging/AppLog.php` | entity لاگ موجود | +| `assets/admin/pages/DoctorDetailPage.tsx` | `isReadOnly` خط ۱۱۲۵ | +| `assets/admin/pages/ClinicDetailPage.tsx` | `isReadOnly` خط ۳۶۴ | +| `docs/api/doctor.md` · `docs/api/clinic.md` | سند اندپوینت‌ها | + +## وضعیت فعلی + +### دروازهٔ پزشک — فقط خود پزشک یا ادمین + +```php +// src/Doctor/Controller/DoctorController.php:340 +#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse +{ + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title'])); + + $this->hydrateDoctor($doctor, $data); + $this->doctorRepo->save($doctor); + + return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]); +} +``` + +### دروازهٔ کلینیک — از checker موجود رد می‌شود + +```php +// src/Clinic/Controller/ClinicController.php:223 +#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse +{ + $this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update'); + + $clinic = $this->clinicRepo->findByUuid($uuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + // مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update + if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (($err = $this->validateGallerySize($data)) !== null) { + return $err; + } + $this->hydrateClinic($clinic, $data); + $this->clinicRepo->save($clinic); + + [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic); + + return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]); +} +``` + +`ClinicDoctorPermissionChecker::can()` نقش نماینده را نمی‌شناسد: + +```php +// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44 +public function can(User $user, Clinic $clinic, string $resource, string $action): bool +{ + if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) { + return true; + } + + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null || !$clinic->hasDoctor($doctor)) { + return false; + } + + return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action); +} +``` + +### آدرس‌ها — دروازهٔ جدا + +```php +// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک +if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} +``` + +```php +// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱) +if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} +``` + +```php +// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک +$doctor = $this->doctorRepo->findByUser($user); +if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); +} +``` + +### UI — عمداً قفل است + +```tsx +// assets/admin/pages/DoctorDetailPage.tsx:1124 +// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست. +const isReadOnly = primaryRole === 'representation'; +``` + +```tsx +// assets/admin/pages/ClinicDetailPage.tsx:363 +// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست. +const isReadOnly = primaryRole === 'representation'; +``` + +### GET جزئیات — هنوز کاربر جاری را نمی‌گیرد + +```php +// src/Doctor/Controller/DoctorController.php:150 +#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] +public function show(string $uuid): JsonResponse +``` + +```php +// src/Clinic/Controller/ClinicController.php:159 +#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])] +public function show(string $uuid): JsonResponse +``` + +هر دو عمومی‌اند و `#[CurrentUser]` ندارند. + +## وظایف + +### ۱. کلاس سیاست — `RepresentationEditPolicy` + +فایل جدید: `src/Representation/Security/RepresentationEditPolicy.php` + +تنها تصمیم‌گیرندهٔ «این نماینده روی این رکورد چه اجازه‌ای دارد». +هیچ controllerی نباید `representation_id` را دستی مقایسه کند. + +```php +matches($user, $doctor->getRepresentationId()); + } + + public function ownsClinic(User $user, Clinic $clinic): bool + { + return $this->matches($user, $clinic->getRepresentationId()); + } + + /** + * اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند. + * + * @param list $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS + */ + public function firstForbiddenField(array $data, array $allowed): ?string + { + foreach (array_keys($data) as $key) { + if (!in_array($key, $allowed, true)) { + return (string) $key; + } + } + return null; + } + + private function matches(User $user, ?int $representationId): bool + { + if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) { + return false; + } + + $rep = $this->repRepo->findByUser($user); + + return $rep !== null && $rep->getId() === $representationId; + } +} +``` + +نکته‌های اجباری: + +- `matches()` وقتی `ROLE_REPRESENTATION` نیست، **بدون کوئری** برمی‌گردد. + این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم + به `false` می‌بندد، نه به exception. +- کلاس هیچ HTTP نمی‌شناسد. پاسخ ۴۰۳ کارِ controller است. + +**نحوه تست:** unit test خالص با entityهای ساختگی — +`tests/Representation/RepresentationEditPolicyTest.php`. +سناریوها: مالکِ درست `true`؛ نمایندهٔ دیگر `false`؛ `representation_id === null` → `false`؛ +کاربر بدون `ROLE_REPRESENTATION` → `false`؛ `firstForbiddenField` روی +`['info' => 'x', 'doctors' => []]` با `CLINIC_FIELDS` باید `'doctors'` بدهد و +روی `['info' => 'x']` باید `null` بدهد. + +### ۲. لاگ‌کردن ویرایشِ نماینده + +سرویس کوچک کنار سیاست: `src/Representation/Security/RepresentationEditLogger.php` + +از entity موجود `App\Shared\Logging\AppLog` استفاده کن — هیچ جدول جدیدی نساز. +سازندهٔ آن `(level, message, context, channel, path)` می‌گیرد. + +```php +$this->em->persist(new AppLog( + 'info', + sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid), + json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE), + 'representation_edit', + $request->getPathInfo(), +)); +``` + +فقط وقتی لاگ بنویس که ویرایش‌کننده **نماینده** باشد. +مالک و ادمین هیچ ردیفی نمی‌سازند — وگرنه `/admin/logs` پر از نویز می‌شود. + +**نحوه تست:** بعد از یک PATCH موفقِ نماینده، +`SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'` باید یکی زیاد شده باشد. +بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند. + +### ۳. باز کردن `PATCH /api/v1/doctor/{uuid}` + +در `DoctorController::update` شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار. +ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate. + +```php +$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); +$isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor); + +if (!$isOwnerOrAdmin && !$isRepOwner) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} + +$data = json_decode($request->getContent(), true) ?? []; + +if ($isRepOwner) { + $bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS); + if ($bad !== null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad); + } +} +``` + +whitelist **فقط** روی `$isRepOwner` اجرا می‌شود. مسیر مالک و ادمین دست‌نخورده می‌ماند. + +**نحوه تست:** تست فانکشنال با `ApiTestCase` — +`tests/Representation/RepresentationProfileEditTest.php`. +یک نماینده و پزشکش بساز (از `POST /api/v1/representation/doctor` استفاده کن، نه fixture دستی)، +بعد PATCH با `info` بزن و `200` بگیر، بعد PATCH با `medical_system_code` بزن و `403` بگیر +و مطمئن شو مقدار قبلی در دیتابیس عوض نشده. + +### ۴. باز کردن `PATCH /api/v1/clinic/{uuid}` + +همان الگو. ولی اینجا `permChecker` جلوی راه است. + +**آن را تغییر نده.** `ClinicDoctorPermissionChecker` دربارهٔ عضویت پزشک در کلینیک است +و نماینده اصلاً پزشکِ عضو نیست؛ اضافه‌کردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا می‌کند. +به‌جایش در controller کنارش بگذار: + +```php +$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic); + +if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} +``` + +حواست به خط ۲۳۰ باشد: + +```php +$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update'); +``` + +این پیش‌چکِ منشی است و پیش از واکشی رکورد اجرا می‌شود. +بررسی کن که برای کاربرِ `ROLE_REPRESENTATION` (که منشی نیست) throw نکند. +اگر throw می‌کند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن. + +whitelist با `RepresentationEditPolicy::CLINIC_FIELDS`. + +**نحوه تست:** نماینده و کلینیکش را با `POST /api/v1/representation/clinic` بساز. +`PATCH` با `{"clinic_logo": "https://x/y.png", "info": "..."}` → `200`. +`PATCH` با `{"doctors": [1]}` → `403` و `errors[0].field === 'doctors'`. +یک نمایندهٔ دوم بساز و همان PATCH را بزن → `403`. + +### ۵. آدرس‌ها — شش اکشن + +همان سیاست را در این‌ها هم صدا بزن: + +- `POST /api/v1/clinic-pro/doctor-address` — نماینده باید `doctor_uuid` بفرستد، + دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود. +- `PATCH /api/v1/clinic-pro/doctor-address/{id}` +- `DELETE /api/v1/clinic-pro/doctor-address/{id}` +- `POST /api/v1/clinic/{clinicUuid}/address` +- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` +- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` + +روی آدرس‌ها whitelist لازم نیست — کل رکورد آدرس محتوایی است. +شرط `TYPE_PERSONAL` در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید +آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند. + +**نحوه تست:** نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه `200`. +نمایندهٔ دوم روی همان آدرس `403` بگیرد. + +### ۶. فلگ `can_edit` در پاسخ GET جزئیات + +هر دو `show` را طوری عوض کن که کاربر جاری را اختیاری بگیرند: + +```php +public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse +``` + +و در آرایهٔ خروجی: + +```php +'can_edit' => $user !== null && ( + $doctor->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN') + || $this->editPolicy->ownsDoctor($user, $doctor) +), +``` + +برای کلینیک همان با `permChecker->can(...) || editPolicy->ownsClinic(...)`. + +هر دو اندپوینت عمومی‌اند. بدون توکن باید `can_edit: false` بدهند و +هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همین‌ها را مصرف می‌کند. + +**نحوه تست:** سه بار `GET /api/v1/doctor/{uuid}` — بدون توکن، با توکن نمایندهٔ مالک، +با توکن نمایندهٔ دیگر. مقادیر `false` و `true` و `false`. + +### ۷. باز کردن UI پنل + +در `DoctorDetailPage.tsx` و `ClinicDetailPage.tsx` این خط را بردار: + +```tsx +const isReadOnly = primaryRole === 'representation'; +``` + +و جایش از پاسخ سرور بخوان: + +```tsx +const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit; +``` + +هیچ منطق مجوزی را در فرانت بازنویسی نکن. `can_edit` تنها منبع حقیقت است. + +فیلدهای بیرون از whitelist باید برای نماینده در فرم **مخفی یا disabled** باشند، +نه اینکه ارسال شوند و ۴۰۳ بگیرند: + +- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال +- کلینیک: مدیریت پزشکان کلینیک + +نماینده همچنان می‌تواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش: +`POST /api/v1/representation/doctors/{uuid}/status`. +اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به `PATCH`. + +تایپ‌ها را در `assets/admin/types/index.ts` به‌روز کن: `can_edit?: boolean`. + +**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` سبز، +`ddev exec yarn dev` بدون خطا، و ورود دستی با یک کاربر نماینده در +`https://clinic-pro.ddev.site/admin/doctors/` — دکمهٔ ویرایش و آپلود لگو دیده شود +و کد نظام پزشکی دیده نشود. + +### ۸. مستندات + +- `docs/api/doctor.md` — `PATCH /api/v1/doctor/{uuid}`: نقش نماینده، فهرست فیلدهای مجاز، + و ۴۰۳ فیلد ممنوع. `GET`: فیلد `can_edit`. +- `docs/api/clinic.md` — همان برای کلینیک. +- اندپوینت‌های آدرس در هر دو سند. +- JSON نمونه باید **خروجی اجرای واقعی** باشد، نه دست‌ساز. + +## نکات مهم + +- **الگو: Policy Object.** یک کلاس، یک سؤال: «این نماینده چه اجازه‌ای دارد». + دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار می‌شود؛ اگر inline بنویسی، + فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا می‌ماند. + `ClinicDoctorPermissionChecker` را گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است، + و پزشکِ نماینده اصلاً عضو نیست. +- **این تسک هیچ دسترسی موجودی را تنگ نمی‌کند.** فقط باز می‌کند. + اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده. +- **`active` عمداً بیرون است.** نماینده اندپوینت اختصاصی دارد: + `POST /api/v1/representation/doctors/{uuid}/status` (خط ۶۳۲ همان controller). + دو مسیر برای یک کار نساز. +- **اندپوینت‌های آپلود دست‌نخورده می‌مانند.** + `/file/upload/clinic_pro/doctor/field_image` و `.../clinic/field_clinic_logo` + الان هم برای هر کاربر لاگین‌شده بازند و فقط URL برمی‌گردانند؛ + دروازهٔ واقعی همان PATCH است که URL را ذخیره می‌کند. +- **کلید ممنوع = ۴۰۳، نه حذف بی‌صدا.** حذف بی‌صدا یعنی نماینده فکر می‌کند ذخیره شده + و تا مدت‌ها کسی نمی‌فهمد. `$this->error(..., 403, $fieldName)` امضای فیلد را هم می‌گیرد. +- **`مالکیت` را از `representation_id` بخوان، نه از نقش.** + هر کاربری می‌تواند `ROLE_REPRESENTATION` داشته باشد؛ آنچه مهم است اینکه + رکورد `Representation` او همان id باشد که روی پزشک/کلینیک نشسته. +- **قرارداد API عوض نمی‌شود، فقط گسترده می‌شود.** `can_edit` فیلد جدید و اختیاری است. + ولی `GET /api/v1/doctor/{uuid}` و `GET /api/v1/clinic/{uuid}` را + `nobat724_front` هم مصرف می‌کند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را + دستی باز کن و مطمئن شو چیزی نشکسته. build آن‌ها خطا نمی‌دهد. +- تست‌ها زیر `tests/Representation/` بروند. دیتابیس تست هرگز reset نمی‌شود، + پس دادهٔ هر تست را با مقدار یکتا بساز (`uniqid()`) تا اجرای دوم هم سبز بماند. diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 9a69696f..3ed9f2f5 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -360,8 +360,7 @@ export default function ClinicDetailPage() { const dbUuid = useAuthStore(s => s.dbUuid); const authToken = useAuthStore(s => s.token); const isOwner = primaryRole === 'clinic' && dbUuid === uuid; - // نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست. - const isReadOnly = primaryRole === 'representation'; + const isRepresentative = primaryRole === 'representation'; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -450,6 +449,12 @@ export default function ClinicDetailPage() { return (raw as any)?.data ?? raw; }, [data]); + // نماینده فقط کلینیکی را ویرایش می‌کند که خودش ثبت کرده. تصمیم با سرور است — + // `can_edit` را همان چک‌هایی می‌سازند که دروازهٔ PATCH را نگه می‌دارند. + const isReadOnly = isRepresentative && !clinic?.can_edit; + // مدیریت آدرس‌ها برای نمایندهٔ مالک هم باز است؛ آدرس محتواست، نه عضویت. + const canManageAddresses = isOwner || primaryRole === 'admin' || (isRepresentative && !!clinic?.can_edit); + const openEdit = () => setEditOpen(true); const toggleMut = useMutation({ @@ -674,8 +679,9 @@ export default function ClinicDetailPage() { )} - {/* Doctors + Invitations — shared manager */} - + {/* Doctors + Invitations — shared manager. عضویت پزشکان تصمیم مالک است، + نه نماینده؛ `doctors` بیرون از whitelist است و PATCH ردش می‌کند. */} + {/* Gallery */}
@@ -800,13 +806,13 @@ export default function ClinicDetailPage() { )} {/* Clinic Addresses Section */} - {(isOwner || primaryRole === 'admin' || isReadOnly) && ( + {(isOwner || primaryRole === 'admin' || isRepresentative) && (
آدرس‌های کلینیک ({formatNumber(clinicAddresses.length)})
- {(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && ( + {canManageAddresses && clinicAddresses.length === 0 && (
)}
- {(isOwner || primaryRole === 'admin') && ( + {canManageAddresses && (
)} - {/* Toggle active / Delete — فقط ادمین */} - {!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && ( + {/* Toggle active / Delete — فقط ادمین. نماینده حتی روی پزشکِ خودش هم + اینجا را نمی‌بیند: `active` بیرون از whitelist است و PATCH ردش می‌کند. + فعال/غیرفعال کردن از اندپوینت اختصاصیِ نماینده انجام می‌شود. */} + {!isOwnProfile && primaryRole !== 'clinic' && !isRepresentative && ( <>
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 0635ebf0..dfb560a3 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -77,6 +77,8 @@ export interface ClinicDetail { map: { latitude: string | null; longitude: string | null }; "24_7": boolean; field_working_days: string | null; + /** آیا کاربر جاری اجازهٔ ویرایش دارد؟ سرور تصمیم می‌گیرد، نه کلاینت. */ + can_edit?: boolean; } export type AppointmentStatus = diff --git a/docs/api/clinic.md b/docs/api/clinic.md index b5637a1f..aec2239b 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -109,13 +109,23 @@ unreachable data — the request is rejected instead. Get clinic detail. -**Permission:** `PUBLIC` +**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`. ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Clinic UUID | +### `can_edit` + +The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this clinic. It is `true` +for the owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`, and the registering +representative; `false` for everyone else and always `false` without a token. It is computed by the +same checks the `PATCH` gate uses, so clients must read it rather than re-deriving the rule. + +It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still +limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`. + ### Response `200` ```json { @@ -185,13 +195,39 @@ Get clinic detail. Update a clinic. -**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**) +**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update` +(see **Clinic Doctor Permissions**), or the **registering representative** (a `ROLE_REPRESENTATION` +user whose `Representation.id` equals the clinic's `representation_id`). + +The representative's grant is permanent for as long as `representation_id` points at them, but it is +restricted to content fields — see *Representative field whitelist* below. Owner, admin and member-doctor +access is unchanged and unrestricted. The representative path deliberately bypasses +`ClinicDoctorPermissionChecker`: that class answers "is this doctor a member of this clinic", and a +representative is not a member at all. ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Clinic UUID | +### Representative field whitelist + +A representative may send only these keys. Any other key aborts the whole request with `403` +and **nothing is saved** — the payload is not silently filtered. + +`name` · `info` · `address` · `telephone` · `working_days` · `24_7` · `latitude` · `longitude` · +`practice_domain_uuid` · `state` · `city` · `social_media` · `image_clinic` · `clinic_logo` · +`specialties` · `doctor_services` · `insurance` + +Notably excluded: `doctors`. Which doctors belong to a clinic is a membership decision for the owner, +not for the representative who registered it. `specialties` / `doctor_services` / `insurance` **are** +allowed — they are the clinic's public-facing catalogue, the mirror of the same keys on the doctor +whitelist, and are not membership. + +Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`, +recording the representative id, the target uuid, and the **names** of the changed fields (never +their values). Edits by the owner, a member doctor, or an admin write no such row. + ### Request Body Same fields as POST — all optional — plus: @@ -211,10 +247,29 @@ Updated clinic object (same structure as GET). Carries `practice_domain` — the | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the owner | -| `ERR_NOT_FOUND_001` | 404 | Clinic not found | +| `ERR_FORBIDDEN_001` | 403 | Secretary without `clinic_info.update` — thrown by the pre-check, before the clinic is even loaded | +| `ERR_AUTH_006` | 403 | Not the owner, not an admin, not a permitted member doctor, and not the registering representative | +| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` | +| `ERR_VALIDATION_002` | 404 | Clinic not found | +| `ERR_VALIDATION_001` | 422 | `image_clinic` بیش از ۵ عکس دارد | | `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزه‌ای اشاره نمی‌کند | +Real response for a whitelist violation (captured from a functional test run, not hand-written): + +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "ERR_AUTH_006", + "message": "نماینده اجازهٔ تغییر این فیلد را ندارد", + "field": "doctors" + } + ] +} +``` + --- ## GET `/api/v1/clinics` @@ -622,10 +677,12 @@ Returns all addresses registered for a clinic (type=clinic entries). ### `POST /api/v1/clinic/{clinicUuid}/address` -**Permission:** Clinic owner or `ROLE_ADMIN` +**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic. +> A clinic may hold only one address — posting a second one returns `409` (`ERR_CONFLICT_001`). Use PATCH to change it. + #### Request ```json { @@ -664,9 +721,10 @@ Creates a new address for the clinic. The address will appear in `available-loca ### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` -**Permission:** Clinic owner or `ROLE_ADMIN` +**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative -Updates an existing clinic address. Same body fields as POST (all optional). +Updates an existing clinic address. Same body fields as POST (all optional). The address is content, +not membership, so no field whitelist applies to representatives here. #### Response `200` ```json @@ -677,18 +735,15 @@ Updates an existing clinic address. Same body fields as POST (all optional). ### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` -**Permission:** Clinic owner or `ROLE_ADMIN` +**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative -Deletes a clinic address. - -> A clinic must retain at least one address — attempting to delete the last address returns `409`. +Deletes a clinic address. There is no "must keep at least one" guard — the last address can be deleted. #### Errors | Code | HTTP | Description | |------|------|-------------| -| `ERR_CONFLICT_001` | 409 | Cannot delete the last address | | `ERR_VALIDATION_002` | 404 | Address or clinic not found | -| `ERR_AUTH_006` | 403 | Not the clinic owner | +| `ERR_AUTH_006` | 403 | Not the owner, not an admin, and not the registering representative | --- diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 00c6bc55..0a38c161 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -84,13 +84,23 @@ Create a doctor profile for the authenticated user. Get doctor detail with clinics. -**Permission:** `PUBLIC` +**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`. ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Doctor UUID | +### `can_edit` + +The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this doctor. It is `true` +for the doctor themselves, `ROLE_ADMIN`, and the registering representative; `false` for everyone else +and always `false` without a token. It is computed by the same policy the `PATCH` gate uses, so clients +must read it rather than re-deriving the rule; a client that recomputes will drift. + +It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still +limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`. + ### Response `200` ```json { @@ -314,13 +324,35 @@ php bin/console app:audit-polluted-records --force # خارج‌کردن از Update doctor profile. -**Permission:** `AUTH` — must be the owner (or `ROLE_ADMIN`) +**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the **registering representative** +(a `ROLE_REPRESENTATION` user whose `Representation.id` equals the doctor's `representation_id`). + +The representative's grant is permanent for as long as `representation_id` points at them, but it is +restricted to content fields — see *Representative field whitelist* below. The doctor's and the +admin's own access is unchanged and unrestricted. ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Doctor UUID | +### Representative field whitelist + +A representative may send only these keys. Any other key aborts the whole request with `403` +and **nothing is saved** — the payload is not silently filtered. + +`title` · `gender` · `degree` · `info` · `detail` · `mobile_number` · `activity_time` · +`images` · `image_data` · `social_media` · `specialties` · `doctor_services` · `expertise` · +`states` · `cities` + +Notably excluded: `medical_system_code` (professional credential) and `active`. A representative +activates or deactivates their own doctor through `POST /api/v1/representation/doctors/{uuid}/status` +instead. + +Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`, +recording the representative id, the target uuid, and the **names** of the changed fields (never +their values). Edits by the doctor or an admin write no such row. + ### Request Body (`application/json`) Same fields as POST (all optional), plus: @@ -341,14 +373,41 @@ Same fields as POST (all optional), plus: ``` ### Response `200` -Updated doctor object (same structure as GET single). +Updated doctor object (same structure as GET single), wrapped as `{ "success": true, "data": { ... } }`. ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the owner | -| `ERR_NOT_FOUND_001` | 404 | Doctor not found | +| `ERR_AUTH_006` | 403 | Not the doctor, not an admin, and not the registering representative | +| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` | +| `ERR_VALIDATION_002` | 404 | Doctor not found | + +Real responses (captured from a functional test run, not hand-written): + +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "ERR_AUTH_006", + "message": "نماینده اجازهٔ تغییر این فیلد را ندارد", + "field": "medical_system_code" + } + ] +} +``` + +```json +{ + "success": false, + "data": null, + "errors": [ + { "code": "ERR_AUTH_006", "message": "دسترسی ممنوع" } + ] +} +``` --- @@ -487,11 +546,24 @@ Get all practice addresses for a doctor, including addresses of clinics the doct Add a new practice address. -**Permission:** `AUTH` — must own the doctor profile +**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative. + +**Target resolution.** When `doctor_uuid` is present it is the target, and the caller must be that +doctor, an admin, or that doctor's registering representative. When it is absent the target is the +caller's own doctor profile. A representative who also happens to have a doctor profile therefore no +longer silently writes the address onto their own profile — the explicit uuid always wins. + +| Caller | `doctor_uuid` present | `doctor_uuid` absent | +|---|---|---| +| The doctor | must be their own profile, else `403` | their own profile | +| `ROLE_ADMIN` | any doctor | `422` — `doctor_uuid` required | +| Registering representative | their own doctors only, else `403` | `422` — `doctor_uuid` required | +| Anyone else | `403` | `403` | ### Request Body ```json { + "doctor_uuid": "9d5f94ba-322d-4735-88e1-b15e8447a0fa", "name": "مطب تهران", "address": "تهران، خیابان ولیعصر", "telephone": "02112345678", @@ -504,6 +576,7 @@ Add a new practice address. | Field | Type | Required | |-------|------|----------| +| `doctor_uuid` | string (UUID) | ✅ for admins and representatives; optional for the doctor themselves | | `name` | string | ❌ | | `address` | string | ✅ (frontend validation) | | `telephone` | string | ✅ (frontend validation) | @@ -540,7 +613,10 @@ Add a new practice address. Update a practice address. -**Permission:** `AUTH` — must own the doctor profile +**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative. + +Only `type = personal` addresses are reachable here. A clinic address returns `403` regardless of +caller, including the representative — clinic addresses are edited through the clinic routes. ### Path Parameters | Param | Type | Description | @@ -548,17 +624,17 @@ Update a practice address. | `id` | integer | Address ID | ### Request Body -Same fields as POST — all optional. +Same fields as POST — all optional. `doctor_uuid` is ignored; the target comes from the address itself. ### Response `200` -Updated address object. +Updated address object, double-wrapped as `{ "success": true, "data": { "data": { … } } }`. ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the owner | -| `ERR_NOT_FOUND_001` | 404 | Address not found | +| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative | +| `ERR_VALIDATION_002` | 404 | Address not found | --- @@ -566,19 +642,20 @@ Updated address object. Delete a practice address. -**Permission:** `AUTH` — must own the doctor profile +**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative. +Clinic addresses return `403` here, same as PATCH. ### Response `200` ```json -{ "success": true, "data": { "message": "آدرس حذف شد" } } +{ "success": true, "data": { "message": "آدرس با موفقیت حذف شد" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the owner | -| `ERR_NOT_FOUND_001` | 404 | Address not found | +| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative | +| `ERR_VALIDATION_002` | 404 | Address not found | --- diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 3c2540f3..8adac218 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -27,6 +27,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use App\Representation\Security\RepresentationEditPolicy; use Symfony\Component\Uid\Uuid; #[OA\Tag(name: 'Clinics')] @@ -51,6 +52,8 @@ class ClinicController extends BaseController private readonly FileValidatorService $fileValidator, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, + private readonly \App\Representation\Security\RepresentationEditPolicy $editPolicy, + private readonly \App\Representation\Security\RepresentationEditLogger $editLogger, private readonly string $projectDir, ) {} @@ -157,7 +160,7 @@ class ClinicController extends BaseController ] )] #[Route('/api/v1/clinic/{uuid}', methods: ['GET'])] - public function show(string $uuid): JsonResponse + public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse { $clinic = $this->clinicRepo->findByUuid($uuid); if ($clinic === null) { @@ -166,7 +169,17 @@ class ClinicController extends BaseController [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic); - return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]); + // can_edit تنها منبع حقیقتِ پنل است تا قاعدهٔ مجوز در فرانت بازنویسی نشود. + // اندپوینت عمومی است؛ بدون توکن همیشه false. + $canEdit = $user !== null && ( + $this->permChecker->can($user, $clinic, 'clinic_info', 'update') + || $this->editPolicy->ownsClinic($user, $clinic) + ); + + return $this->success(['data' => array_merge( + $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone), + ['can_edit' => $canEdit], + )]); } #[OA\Patch( @@ -234,18 +247,34 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } - // مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update - if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) { + // مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update؛ و نمایندهٔ + // ثبت‌کننده فقط روی فیلدهای محتوایی. نقش نماینده عمداً وارد permChecker نشد — + // آن کلاس دربارهٔ عضویتِ پزشک در کلینیک است و نماینده اصلاً عضو نیست. + $isRepOwner = $this->editPolicy->ownsClinic($user, $clinic); + + if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; + + if ($isRepOwner) { + $bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::CLINIC_FIELDS); + if ($bad !== null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad); + } + } + if (($err = $this->validateGallerySize($data)) !== null) { return $err; } $this->hydrateClinic($clinic, $data); $this->clinicRepo->save($clinic); + if ($isRepOwner) { + $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); + } + [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic); return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]); @@ -518,6 +547,17 @@ class ClinicController extends BaseController // ── Helpers ─────────────────────────────────────────────────────────────── + /** + * آیا این کاربر مجاز به تغییر آدرس‌های این کلینیک است؟ مالک، ادمین، یا نمایندهٔ + * ثبت‌کننده. آدرس بخشی از محتوای پروفایل است، پس whitelist ندارد. + */ + private function mayTouchClinicAddress(User $user, Clinic $clinic): bool + { + return $clinic->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN') + || $this->editPolicy->ownsClinic($user, $clinic); + } + private function validateGallerySize(array $data): ?JsonResponse { if (array_key_exists('image_clinic', $data) @@ -738,7 +778,7 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } - if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchClinicAddress($user, $clinic)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -751,6 +791,10 @@ class ClinicController extends BaseController $this->hydrateClinicAddress($address, $data); $this->addressRepo->save($address); + if ($this->editPolicy->ownsClinic($user, $clinic)) { + $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); + } + return $this->success(['data' => $address->toArray()], 201); } @@ -763,7 +807,7 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } - if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchClinicAddress($user, $clinic)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -776,6 +820,10 @@ class ClinicController extends BaseController $this->hydrateClinicAddress($address, $data); $this->addressRepo->save($address); + if ($this->editPolicy->ownsClinic($user, $clinic)) { + $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); + } + return $this->success(['data' => $address->toArray()]); } @@ -788,7 +836,7 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } - if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchClinicAddress($user, $clinic)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -799,6 +847,10 @@ class ClinicController extends BaseController $this->addressRepo->remove($address); + if ($this->editPolicy->ownsClinic($user, $clinic)) { + $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), ['address_deleted' => $addressUuid]); + } + return $this->success(['message' => 'آدرس با موفقیت حذف شد']); } diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 48d161cd..6c9d17a6 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use App\Representation\Security\RepresentationEditPolicy; use App\Shared\Util\PersianText; #[OA\Tag(name: 'Doctors')] @@ -46,6 +47,8 @@ class DoctorController extends BaseController private readonly TenantInsuranceCleanupService $insuranceCleanup, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, private readonly \App\Representation\Repository\RepresentationRepository $representationRepo, + private readonly \App\Representation\Security\RepresentationEditPolicy $editPolicy, + private readonly \App\Representation\Security\RepresentationEditLogger $editLogger, private readonly string $projectDir, ) {} @@ -148,7 +151,7 @@ class DoctorController extends BaseController ] )] #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] - public function show(string $uuid): JsonResponse + public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse { $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor === null) { @@ -176,10 +179,19 @@ class DoctorController extends BaseController ? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()] : null; + // can_edit تنها منبع حقیقتِ پنل است تا قاعدهٔ مجوز در فرانت بازنویسی نشود. + // اندپوینت عمومی است؛ بدون توکن همیشه false. + $canEdit = $user !== null && ( + $doctor->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN') + || $this->editPolicy->ownsDoctor($user, $doctor) + ); + $schedules = $this->scheduleRepo->findAllByDoctor($doctor); return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [ 'clinics' => $clinicData, 'representation' => $representation, + 'can_edit' => $canEdit, ])]); } @@ -346,16 +358,33 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + // نمایندهٔ ثبت‌کننده هم ویرایش می‌کند، اما فقط فیلدهای محتوایی. مسیر پزشک و + // ادمین دست‌نخورده می‌ماند — whitelist تنها روی شاخهٔ نماینده اعمال می‌شود. + $isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); + $isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor); + + if (!$isOwnerOrAdmin && !$isRepOwner) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; + + if ($isRepOwner) { + $bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS); + if ($bad !== null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad); + } + } + if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title'])); $this->hydrateDoctor($doctor, $data); $this->doctorRepo->save($doctor); + if ($isRepOwner) { + $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); + } + return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]); } @@ -541,29 +570,41 @@ class DoctorController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse { - $doctor = $this->doctorRepo->findByUser($user); - if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); - } + $data = json_decode($request->getContent(), true) ?? []; + $doctorUuid = $data['doctor_uuid'] ?? null; - $data = json_decode($request->getContent(), true) ?? []; - - // Admin can specify doctor_id/doctor_uuid - if ($doctor === null && $user->hasRole('ROLE_ADMIN')) { - $doctorUuid = $data['doctor_uuid'] ?? null; - if (!$doctorUuid) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422); - } - $doctor = $this->doctorRepo->findByUuid($doctorUuid); + // وقتی doctor_uuid آمده باشد، هدف صریح است و همان معیار قرار می‌گیرد — حتی + // اگر فرستنده خودش پزشک باشد. نماینده‌ای که پزشک هم هست وگرنه بی‌صدا آدرس را + // روی پروفایل خودش می‌ساخت، نه روی پزشکِ زیرمجموعه. + if ($doctorUuid !== null && $doctorUuid !== '') { + $doctor = $this->doctorRepo->findByUuid((string) $doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } + + $isSelfOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); + if (!$isSelfOrAdmin && !$this->editPolicy->ownsDoctor($user, $doctor)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + } else { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null) { + // ادمین و نماینده پروفایل پزشک ندارند؛ برایشان نبودِ doctor_uuid خطای + // ورودی است، نه نداشتن دسترسی. + return $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_REPRESENTATION') + ? $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422, 'doctor_uuid') + : $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); + } } $address = DoctorAddress::forDoctor($doctor); $this->hydrateAddress($address, $data); $this->addressRepo->save($address); + if ($this->editPolicy->ownsDoctor($user, $doctor)) { + $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); + } + return $this->success(['data' => $address->toArray()], 201); } @@ -598,7 +639,7 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } - if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchAddress($user, $address)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -653,7 +694,7 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک ویرایش می‌شود', 403); } - if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchAddress($user, $address)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -661,6 +702,11 @@ class DoctorController extends BaseController $this->hydrateAddress($address, $data); $this->addressRepo->save($address); + $doctor = $address->getDoctor(); + if ($doctor !== null && $this->editPolicy->ownsDoctor($user, $doctor)) { + $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); + } + return $this->success(['data' => $address->toArray()]); } @@ -702,11 +748,20 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک حذف می‌شود', 403); } - if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + if (!$this->mayTouchAddress($user, $address)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } + $doctor = $address->getDoctor(); + $logRep = $doctor !== null && $this->editPolicy->ownsDoctor($user, $doctor); + $uuid = $doctor?->getUuid(); + $this->addressRepo->remove($address); + + if ($logRep) { + $this->editLogger->logEdit($user, 'doctor', (string) $uuid, ['address_deleted' => $id]); + } + return $this->success(['message' => 'آدرس با موفقیت حذف شد']); } @@ -828,6 +883,28 @@ class DoctorController extends BaseController } } + /** + * آیا این کاربر مجاز به دیدن/تغییر این آدرس است؟ خودِ پزشک، ادمین، یا نمایندهٔ + * ثبت‌کنندهٔ همان پزشک. + * + * ادمین پیش از هر چیز مجاز است تا رفتار قبلی روی آدرسِ بدون پزشک (آدرس کلینیک) + * دست‌نخورده بماند. + */ + private function mayTouchAddress(User $user, DoctorAddress $address): bool + { + if ($user->hasRole('ROLE_ADMIN')) { + return true; + } + + $doctor = $address->getDoctor(); + if ($doctor === null) { + return false; + } + + return $doctor->getUser()->getId() === $user->getId() + || $this->editPolicy->ownsDoctor($user, $doctor); + } + private function hydrateAddress(DoctorAddress $address, array $data): void { if (array_key_exists('name', $data)) $address->setName($data['name']); diff --git a/src/Representation/Security/RepresentationEditLogger.php b/src/Representation/Security/RepresentationEditLogger.php new file mode 100644 index 00000000..238b8e4c --- /dev/null +++ b/src/Representation/Security/RepresentationEditLogger.php @@ -0,0 +1,60 @@ + $data بدنهٔ درخواست؛ فقط کلیدهایش ثبت می‌شود، نه مقادیر + */ + public function logEdit(User $user, string $entityType, string $uuid, array $data): void + { + $repId = $this->repRepo->findByUser($user)?->getId(); + if ($repId === null) { + return; + } + + $label = $entityType === 'clinic' ? 'کلینیک' : 'پزشک'; + + try { + $this->conn->insert('app_log', [ + 'level' => 'info', + 'message' => sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $label, $uuid), + 'context' => json_encode([ + 'representation_id' => $repId, + 'entity_type' => $entityType, + 'uuid' => $uuid, + 'fields' => array_keys($data), + ], JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR), + 'channel' => 'representation_edit', + 'path' => $this->requestStack->getCurrentRequest()?->getPathInfo(), + 'created_at' => time(), + ]); + } catch (\Throwable) { + // ویرایش انجام شده؛ نبودِ لاگ نباید آن را به خطا تبدیل کند. + } + } +} diff --git a/src/Representation/Security/RepresentationEditPolicy.php b/src/Representation/Security/RepresentationEditPolicy.php new file mode 100644 index 00000000..989ab73e --- /dev/null +++ b/src/Representation/Security/RepresentationEditPolicy.php @@ -0,0 +1,89 @@ +matches($user, $doctor->getRepresentationId()); + } + + public function ownsClinic(User $user, Clinic $clinic): bool + { + return $this->matches($user, $clinic->getRepresentationId()); + } + + /** + * اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند. + * + * @param array $data + * @param list $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS + */ + public function firstForbiddenField(array $data, array $allowed): ?string + { + foreach (array_keys($data) as $key) { + if (!in_array((string) $key, $allowed, true)) { + return (string) $key; + } + } + + return null; + } + + /** + * کاربرِ بدون نقش نماینده بدون کوئری رد می‌شود؛ همین مسیر، کاربرِ دارای نقش + * ولی بدون ردیف Representation را هم به false می‌بندد، نه به exception. + */ + private function matches(User $user, ?int $representationId): bool + { + if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) { + return false; + } + + $rep = $this->repRepo->findByUser($user); + + return $rep !== null && $rep->getId() === $representationId; + } +} diff --git a/tests/Representation/ProfileCanEditFlagTest.php b/tests/Representation/ProfileCanEditFlagTest.php new file mode 100644 index 00000000..e0584a4b --- /dev/null +++ b/tests/Representation/ProfileCanEditFlagTest.php @@ -0,0 +1,161 @@ +createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + $this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid())); + $this->em->flush(); + + return $user; + } + + private function doctorCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [ + 'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'دکتر فلگ ' . uniqid(), + ]); + self::assertSame(201, $this->responseCode()); + + return $body['data']['uuid']; + } + + private function clinicCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [ + 'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'کلینیک فلگ ' . uniqid(), + ]); + self::assertSame(200, $this->responseCode()); + + return $body['data']['uuid']; + } + + /** GET بدون هیچ توکنی. */ + private function anonymousGet(string $uri): array + { + $this->client->request('GET', $uri); + + return json_decode($this->client->getResponse()->getContent(), true) ?? []; + } + + /** پاسخ جزئیات دولایه است: کنترلر `success(['data' => …])` می‌دهد. */ + private function payload(array $body): array + { + return $body['data']['data']; + } + + private function canEdit(array $body): bool + { + self::assertArrayHasKey('can_edit', $this->payload($body), 'پاسخ باید همیشه can_edit داشته باشد'); + + return $this->payload($body)['can_edit']; + } + + // ── پزشک ────────────────────────────────────────────────────────────────── + + public function testDoctorFlagIsTrueForTheOwningRepresentativeOnly(): void + { + $owner = $this->newRepresentative(); + $stranger = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($owner); + + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner))); + self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $stranger))); + } + + public function testDoctorFlagIsFalseWithoutAToken(): void + { + $owner = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($owner); + + $body = $this->anonymousGet('/api/v1/doctor/' . $uuid); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertFalse($this->canEdit($body)); + // بقیهٔ پاسخ نباید عوض شده باشد — سایت عمومی همین را مصرف می‌کند. + self::assertArrayHasKey('uuid', $this->payload($body)); + self::assertArrayHasKey('clinics', $this->payload($body)); + } + + public function testDoctorFlagIsTrueForTheDoctorAndForAnAdmin(): void + { + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'پزشک فلگ'); + $this->em->persist($doctor); + $this->em->flush(); + $uuid = $doctor->getUuid(); + + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $doctorUser))); + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_ADMIN'])))); + self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_USER'])))); + } + + // ── کلینیک ──────────────────────────────────────────────────────────────── + + public function testClinicFlagIsTrueForTheOwningRepresentativeOnly(): void + { + $owner = $this->newRepresentative(); + $stranger = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($owner); + + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $owner))); + self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $stranger))); + } + + public function testClinicFlagIsFalseWithoutAToken(): void + { + $owner = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($owner); + + $body = $this->anonymousGet('/api/v1/clinic/' . $uuid); + + self::assertSame(200, $this->client->getResponse()->getStatusCode()); + self::assertFalse($this->canEdit($body)); + self::assertArrayHasKey('uuid', $this->payload($body)); + } + + public function testClinicFlagIsTrueForTheOwnerAndForAnAdmin(): void + { + $ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($ownerUser); + $clinic->setName('کلینیک فلگ'); + $this->em->persist($clinic); + $this->em->flush(); + $uuid = $clinic->getUuid(); + + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $ownerUser))); + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_ADMIN'])))); + self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_USER'])))); + } + + // ── مرزی ────────────────────────────────────────────────────────────────── + + public function testFlagMatchesWhatThePatchActuallyAllows(): void + { + // اگر فلگ true بدهد ولی PATCH ۴۰۳ کند، پنل دکمهٔ مرده نشان می‌دهد. + $owner = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($owner); + + self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner))); + + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $owner, ['info' => 'x']); + self::assertSame(200, $this->responseCode()); + } +} diff --git a/tests/Representation/RepresentationAddressEditTest.php b/tests/Representation/RepresentationAddressEditTest.php new file mode 100644 index 00000000..43f52dd5 --- /dev/null +++ b/tests/Representation/RepresentationAddressEditTest.php @@ -0,0 +1,264 @@ + …])` می‌دهد و + * BaseController خودش یک لایهٔ `data` دیگر می‌گذارد. + */ + private function createdAddress(array $body): array + { + return $body['data']['data']; + } + + private function createdAddressId(array $body): int + { + return (int) $this->createdAddress($body)['id']; + } + + private function createdAddressUuid(array $body): string + { + return $this->createdAddress($body)['uuid']; + } + + private function newRepresentative(): User + { + $user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + $this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid())); + $this->em->flush(); + + return $user; + } + + private function doctorCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [ + 'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'دکتر آدرس ' . uniqid(), + ]); + self::assertSame(201, $this->responseCode()); + + return $body['data']['uuid']; + } + + private function clinicCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [ + 'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'کلینیک آدرس ' . uniqid(), + ]); + self::assertSame(200, $this->responseCode()); + + return $body['data']['uuid']; + } + + // ── آدرس پزشک ───────────────────────────────────────────────────────────── + + public function testRepresentativeCreatesUpdatesAndDeletesADoctorAddress(): void + { + $repUser = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + + $created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [ + 'doctor_uuid' => $uuid, + 'name' => 'مطب مرکزی', + 'address' => 'یزد، خیابان آزمون', + 'telephone' => '03512222222', + ]); + self::assertSame(201, $this->responseCode()); + $addressId = $this->createdAddressId($created); + + $this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser, [ + 'address' => 'یزد، خیابان تازه', + ]); + self::assertSame(200, $this->responseCode()); + + $this->em->clear(); + self::assertSame( + 'یزد، خیابان تازه', + $this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(), + ); + + $this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser); + self::assertSame(200, $this->responseCode()); + + $this->em->clear(); + self::assertNull($this->em->getRepository(DoctorAddress::class)->find($addressId)); + } + + public function testAnotherRepresentativeCannotTouchTheAddress(): void + { + $owner = $this->newRepresentative(); + $stranger = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($owner); + + $created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $owner, [ + 'doctor_uuid' => $uuid, + 'address' => 'اصلی', + ]); + $addressId = $this->createdAddressId($created); + + $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $stranger, [ + 'doctor_uuid' => $uuid, + 'address' => 'نفوذی', + ]); + self::assertSame(403, $this->responseCode()); + + $this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger, ['address' => 'نفوذی']); + self::assertSame(403, $this->responseCode()); + + $this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger); + self::assertSame(403, $this->responseCode()); + + $this->em->clear(); + self::assertSame( + 'اصلی', + $this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(), + ); + } + + public function testDoctorUuidWinsOverTheSenderOwnProfile(): void + { + // نماینده‌ای که خودش پزشک هم هست: آدرس باید روی پزشکِ زیرمجموعه بنشیند، + // نه روی پروفایل خودش. + $repUser = $this->newRepresentative(); + $selfDoc = new Doctor($repUser, 'پزشکِ خودِ نماینده'); + $this->em->persist($selfDoc); + $this->em->flush(); + $selfDocId = $selfDoc->getId(); + + $targetUuid = $this->doctorCreatedBy($repUser); + + $created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [ + 'doctor_uuid' => $targetUuid, + 'address' => 'باید روی پزشک زیرمجموعه بنشیند', + ]); + self::assertSame(201, $this->responseCode()); + + $this->em->clear(); + $address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created)); + $target = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $targetUuid]); + + self::assertSame($target->getId(), $address->getDoctor()->getId()); + self::assertNotSame($selfDocId, $address->getDoctor()->getId()); + } + + public function testRepresentativeWithoutDoctorUuidGetsAValidationError(): void + { + $repUser = $this->newRepresentative(); + + $body = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, ['address' => 'بی‌هدف']); + + self::assertSame(422, $this->responseCode()); + self::assertSame('doctor_uuid', $body['errors'][0]['field']); + } + + public function testPlainUserStillGetsForbidden(): void + { + $plain = $this->createUser(['ROLE_USER']); + + $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $plain, ['address' => 'x']); + + self::assertSame(403, $this->responseCode()); + } + + public function testDoctorCanStillCreateTheirOwnAddressWithoutUuid(): void + { + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'پزشک خودگردان'); + $this->em->persist($doctor); + $this->em->flush(); + $doctorId = $doctor->getId(); + + $created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $doctorUser, ['address' => 'مطب خودم']); + + self::assertSame(201, $this->responseCode()); + + $this->em->clear(); + $address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created)); + self::assertSame($doctorId, $address->getDoctor()->getId()); + } + + public function testClinicAddressIsStillUnreachableThroughTheDoctorAddressRoute(): void + { + $repUser = $this->newRepresentative(); + $clinicUuid = $this->clinicCreatedBy($repUser); + + $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $clinicUuid]); + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setAddress('آدرس کلینیک'); + $this->em->persist($address); + $this->em->flush(); + + $this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $address->getId(), $repUser, ['address' => 'x']); + + self::assertSame(403, $this->responseCode()); + } + + // ── آدرس کلینیک ─────────────────────────────────────────────────────────── + + public function testRepresentativeCreatesUpdatesAndDeletesAClinicAddress(): void + { + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + + $created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $repUser, [ + 'address' => 'یزد، بلوار آزمون', + 'telephone' => '03513333333', + ]); + self::assertSame(201, $this->responseCode()); + $addressUuid = $this->createdAddressUuid($created); + + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser, [ + 'address' => 'یزد، بلوار تازه', + ]); + self::assertSame(200, $this->responseCode()); + + $this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser); + self::assertSame(200, $this->responseCode()); + } + + public function testAnotherRepresentativeCannotTouchTheClinicAddress(): void + { + $owner = $this->newRepresentative(); + $stranger = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($owner); + + $created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $owner, ['address' => 'اصلی']); + $addressUuid = $this->createdAddressUuid($created); + + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger, ['address' => 'نفوذی']); + self::assertSame(403, $this->responseCode()); + + $this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger); + self::assertSame(403, $this->responseCode()); + } + + public function testClinicOwnerIsUnaffected(): void + { + $ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($ownerUser); + $clinic->setName('کلینیک خودگردان'); + $this->em->persist($clinic); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/clinic/' . $clinic->getUuid() . '/address', $ownerUser, ['address' => 'مال خودم']); + + self::assertSame(201, $this->responseCode()); + } +} diff --git a/tests/Representation/RepresentationClinicEditTest.php b/tests/Representation/RepresentationClinicEditTest.php new file mode 100644 index 00000000..b00dbe7d --- /dev/null +++ b/tests/Representation/RepresentationClinicEditTest.php @@ -0,0 +1,199 @@ +createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + $this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid())); + $this->em->flush(); + + return $user; + } + + /** کلینیکی که همان نماینده ثبتش کرده؛ uuid برمی‌گردد. */ + private function clinicCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [ + 'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'کلینیک آزمون ' . uniqid(), + ]); + + self::assertSame(200, $this->responseCode(), 'ساخت کلینیک توسط نماینده باید ۲۰۰ بدهد'); + + return $body['data']['uuid']; + } + + private function editLogCount(): int + { + return (int) static::getContainer()->get(Connection::class) + ->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'"); + } + + private function reloadClinic(string $uuid): Clinic + { + $this->em->clear(); + + return $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); + } + + // ── مسیر موفق ───────────────────────────────────────────────────────────── + + public function testOwningRepresentativeCanEditLogoAndDescription(): void + { + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + $before = $this->editLogCount(); + + $body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [ + 'clinic_logo' => 'https://example.test/logo.png', + 'info' => 'معرفی تازهٔ کلینیک', + 'telephone' => '03511111111', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertTrue($body['success']); + + $clinic = $this->reloadClinic($uuid); + self::assertSame('https://example.test/logo.png', $clinic->getClinicLogo()); + self::assertSame('معرفی تازهٔ کلینیک', $clinic->getInfo()); + self::assertSame($before + 1, $this->editLogCount()); + } + + public function testTheWholePayloadTheEditFormSendsIsAccepted(): void + { + // فرم ویرایش کلینیک همیشه specialties و doctor_services و insurance را + // می‌فرستد؛ اگر بیرون از whitelist باشند هر ذخیره‌ای ۴۰۳ می‌شود. + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [ + 'name' => 'کلینیک با نام تازه', + 'telephone' => '03514444444', + 'info' => 'توضیحات', + '24_7' => true, + 'specialties' => [], + 'insurance' => [], + 'doctor_services' => [], + 'social_media' => [ + 'instagram' => 'https://instagram.com/test', + 'telegram' => null, + 'aparat' => null, + 'youtube' => null, + 'linkedin' => null, + ], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame('کلینیک با نام تازه', $this->reloadClinic($uuid)->getName()); + } + + public function testSecretaryPreCheckDoesNotBlockARepresentative(): void + { + // denyUnlessGranted پیش از واکشی رکورد اجرا می‌شود؛ این تست تثبیت می‌کند که + // نقشِ غیرمنشی از آن رد می‌شود و ۴۰۳ زودهنگام نمی‌گیرد. + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, ['info' => 'x']); + + self::assertSame(200, $this->responseCode()); + } + + // ── مسیر خطا ────────────────────────────────────────────────────────────── + + public function testAnotherRepresentativeIsForbidden(): void + { + $owner = $this->newRepresentative(); + $stranger = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($owner); + + $body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $stranger, ['info' => 'نباید ذخیره شود']); + + self::assertSame(403, $this->responseCode()); + self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']); + self::assertNotSame('نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo()); + } + + public function testChangingClinicMembershipIsForbiddenForRepresentative(): void + { + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + + $body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [ + 'info' => 'این هم نباید ذخیره شود', + 'doctors' => [1], + ]); + + self::assertSame(403, $this->responseCode()); + self::assertSame('doctors', $body['errors'][0]['field']); + self::assertNotSame('این هم نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo()); + } + + // ── مرزی ────────────────────────────────────────────────────────────────── + + public function testClinicWithoutARepresentationIsNotEditableByAnyRepresentative(): void + { + $repUser = $this->newRepresentative(); + + $ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $orphan = new Clinic($ownerUser); + $orphan->setName('کلینیک بی‌نماینده'); + $this->em->persist($orphan); + $this->em->flush(); + + $this->authJson('PATCH', '/api/v1/clinic/' . $orphan->getUuid(), $repUser, ['info' => 'x']); + + self::assertSame(403, $this->responseCode()); + } + + public function testClinicOwnerIsUnaffectedByTheWhitelist(): void + { + $ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($ownerUser); + $clinic->setName('کلینیک خودگردان'); + $this->em->persist($clinic); + $this->em->flush(); + $uuid = $clinic->getUuid(); + $before = $this->editLogCount(); + + // `doctors` برای نماینده ممنوع است اما برای مالک نه؛ آرایهٔ خالی یعنی «پاک کن». + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $ownerUser, [ + 'doctors' => [], + 'info' => 'مالک آزادانه ویرایش می‌کند', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame('مالک آزادانه ویرایش می‌کند', $this->reloadClinic($uuid)->getInfo()); + self::assertSame($before, $this->editLogCount(), 'ویرایش مالک نباید لاگ نماینده بسازد'); + } + + public function testAdminIsUnaffectedByTheWhitelist(): void + { + $repUser = $this->newRepresentative(); + $uuid = $this->clinicCreatedBy($repUser); + $admin = $this->createUser(['ROLE_ADMIN']); + $before = $this->editLogCount(); + + $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $admin, ['doctors' => [], 'info' => 'ادمین']); + + self::assertSame(200, $this->responseCode()); + self::assertSame('ادمین', $this->reloadClinic($uuid)->getInfo()); + self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد'); + } +} diff --git a/tests/Representation/RepresentationEditLoggerTest.php b/tests/Representation/RepresentationEditLoggerTest.php new file mode 100644 index 00000000..d88a76f4 --- /dev/null +++ b/tests/Representation/RepresentationEditLoggerTest.php @@ -0,0 +1,87 @@ +get(Connection::class), + $this->em->getRepository(Representation::class), + new RequestStack(), + ); + } + + private function editLogCount(): int + { + return (int) static::getContainer()->get(Connection::class) + ->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'"); + } + + private function newRepresentative(): User + { + $user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + $this->em->persist(new Representation($user, 'نمایندهٔ لاگ')); + $this->em->flush(); + + return $user; + } + + public function testEditByRepresentativeWritesOneRow(): void + { + $user = $this->newRepresentative(); + $before = $this->editLogCount(); + + $this->logger()->logEdit($user, 'doctor', 'uuid-under-test', ['info' => 'x', 'images' => []]); + + self::assertSame($before + 1, $this->editLogCount()); + } + + public function testTheRowNamesTheRepresentativeAndTheChangedFields(): void + { + $user = $this->newRepresentative(); + $uuid = 'uuid-' . uniqid(); + + $this->logger()->logEdit($user, 'clinic', $uuid, ['clinic_logo' => 'https://a/b.png', 'info' => 'y']); + + $row = static::getContainer()->get(Connection::class)->fetchAssociative( + "SELECT message, context, level FROM app_log WHERE channel = 'representation_edit' ORDER BY id DESC LIMIT 1" + ); + + self::assertSame('info', $row['level']); + self::assertStringContainsString($uuid, $row['message']); + + $context = json_decode((string) $row['context'], true); + self::assertSame('clinic', $context['entity_type']); + self::assertSame(['clinic_logo', 'info'], $context['fields']); + // فقط کلیدها ثبت می‌شوند، نه مقادیر. + self::assertStringNotContainsString('https://a/b.png', (string) $row['context']); + } + + public function testUserWithoutARepresentationRowWritesNothing(): void + { + $user = $this->createUser(['ROLE_USER']); + $before = $this->editLogCount(); + + $this->logger()->logEdit($user, 'doctor', 'uuid-x', ['info' => 'x']); + + self::assertSame($before, $this->editLogCount()); + } +} diff --git a/tests/Representation/RepresentationEditPolicyTest.php b/tests/Representation/RepresentationEditPolicyTest.php new file mode 100644 index 00000000..05970aeb --- /dev/null +++ b/tests/Representation/RepresentationEditPolicyTest.php @@ -0,0 +1,167 @@ +setAccessible(true); + $ref->setValue($rep, $id); + + return $rep; + } + + private function policyReturning(?Representation $rep): RepresentationEditPolicy + { + $repo = $this->createStub(RepresentationRepository::class); + $repo->method('findByUser')->willReturn($rep); + + return new RepresentationEditPolicy($repo); + } + + private function doctorOwnedBy(?int $representationId): Doctor + { + $doctor = new Doctor(new User('09120000001'), 'پزشک آزمون'); + $doctor->setRepresentationId($representationId); + + return $doctor; + } + + private function clinicOwnedBy(?int $representationId): Clinic + { + $clinic = new Clinic(new User('09120000002')); + $clinic->setRepresentationId($representationId); + + return $clinic; + } + + // ── مالکیت ──────────────────────────────────────────────────────────────── + + public function testRepresentationOwningTheDoctorIsAllowed(): void + { + $user = new User('09120000003'); + $user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $policy = $this->policyReturning($this->repWithId($user, 7)); + + self::assertTrue($policy->ownsDoctor($user, $this->doctorOwnedBy(7))); + } + + public function testAnotherRepresentationIsRejected(): void + { + $user = new User('09120000004'); + $user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $policy = $this->policyReturning($this->repWithId($user, 7)); + + self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(8))); + } + + public function testDoctorWithoutRepresentationIsNeverOwned(): void + { + $user = new User('09120000005'); + $user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $policy = $this->policyReturning($this->repWithId($user, 7)); + + self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(null))); + } + + public function testUserWithoutTheRoleIsRejectedWithoutHittingTheRepository(): void + { + $user = new User('09120000006'); + $user->setRoles(['ROLE_USER']); + + $repo = $this->createMock(RepresentationRepository::class); + $repo->expects(self::never())->method('findByUser'); + + $policy = new RepresentationEditPolicy($repo); + + self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7))); + } + + public function testRoleWithoutRepresentationRowIsRejectedNotFatal(): void + { + $user = new User('09120000007'); + $user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $policy = $this->policyReturning(null); + + self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7))); + } + + public function testClinicOwnershipFollowsTheSameRule(): void + { + $user = new User('09120000008'); + $user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $policy = $this->policyReturning($this->repWithId($user, 3)); + + self::assertTrue($policy->ownsClinic($user, $this->clinicOwnedBy(3))); + self::assertFalse($policy->ownsClinic($user, $this->clinicOwnedBy(4))); + } + + // ── whitelist ───────────────────────────────────────────────────────────── + + public function testForbiddenClinicFieldIsNamed(): void + { + $policy = $this->policyReturning(null); + + self::assertSame( + 'doctors', + $policy->firstForbiddenField(['info' => 'x', 'doctors' => []], RepresentationEditPolicy::CLINIC_FIELDS), + ); + } + + public function testAllowedClinicPayloadPasses(): void + { + $policy = $this->policyReturning(null); + + self::assertNull( + $policy->firstForbiddenField( + ['info' => 'x', 'clinic_logo' => 'https://a/b.png', '24_7' => true], + RepresentationEditPolicy::CLINIC_FIELDS, + ), + ); + } + + public function testForbiddenDoctorFieldsAreNamed(): void + { + $policy = $this->policyReturning(null); + + self::assertSame( + 'medical_system_code', + $policy->firstForbiddenField(['medical_system_code' => '123'], RepresentationEditPolicy::DOCTOR_FIELDS), + ); + self::assertSame( + 'active', + $policy->firstForbiddenField(['info' => 'x', 'active' => true], RepresentationEditPolicy::DOCTOR_FIELDS), + ); + } + + public function testEmptyPayloadHasNoForbiddenField(): void + { + $policy = $this->policyReturning(null); + + self::assertNull($policy->firstForbiddenField([], RepresentationEditPolicy::DOCTOR_FIELDS)); + } +} diff --git a/tests/Representation/RepresentationProfileEditTest.php b/tests/Representation/RepresentationProfileEditTest.php new file mode 100644 index 00000000..d8c39b45 --- /dev/null +++ b/tests/Representation/RepresentationProfileEditTest.php @@ -0,0 +1,221 @@ +createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + $rep = new Representation($user, 'نمایندهٔ ' . uniqid()); + $this->em->persist($rep); + $this->em->flush(); + + return [$user, $rep]; + } + + /** پزشکی که همان نماینده ثبتش کرده؛ uuid برمی‌گردد. */ + private function doctorCreatedBy(User $repUser): string + { + $body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [ + 'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'name' => 'دکتر آزمون ' . uniqid(), + ]); + + self::assertSame(201, $this->responseCode(), 'ساخت پزشک توسط نماینده باید ۲۰۱ بدهد'); + + return $body['data']['uuid']; + } + + private function editLogCount(): int + { + return (int) static::getContainer()->get(Connection::class) + ->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'"); + } + + private function reloadDoctor(string $uuid): Doctor + { + $this->em->clear(); + + return $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); + } + + // ── مسیر موفق ───────────────────────────────────────────────────────────── + + public function testOwningRepresentativeCanEditContentFields(): void + { + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + + $body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [ + 'info' => 'متن معرفی تازه', + 'degree' => 'متخصص پوست', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertTrue($body['success']); + self::assertSame('متن معرفی تازه', $this->reloadDoctor($uuid)->getInfo()); + } + + public function testTheWholePayloadTheEditFormSendsIsAccepted(): void + { + // همان کلیدهایی که DoctorDetailPage در حالت نماینده می‌فرستد؛ اگر یکی + // بیرون از whitelist بماند، هر ذخیره‌ای ۴۰۳ می‌شود. + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [ + 'title' => 'دکتر نام تازه', + 'gender' => 'man', + 'degree' => 'specialist', + 'mobile_number' => '09121111111', + 'info' => 'توضیحات', + 'activity_time' => 1600000000, + 'specialties' => [], + 'doctor_services' => [], + 'social_media' => [ + 'instagram' => 'https://instagram.com/test', + 'telegram' => null, + 'aparat' => null, + 'youtube' => null, + 'linkedin' => null, + ], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame('نام تازه', $this->reloadDoctor($uuid)->getName()); + } + + public function testASuccessfulEditWritesExactlyOneLogRow(): void + { + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + $before = $this->editLogCount(); + + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['info' => 'x']); + + self::assertSame(200, $this->responseCode()); + self::assertSame($before + 1, $this->editLogCount()); + } + + // ── مسیر خطا ────────────────────────────────────────────────────────────── + + public function testAnotherRepresentativeIsForbidden(): void + { + [$ownerUser] = $this->newRepresentative(); + [$strangerUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($ownerUser); + + $body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $strangerUser, ['info' => 'نباید ذخیره شود']); + + self::assertSame(403, $this->responseCode()); + self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']); + self::assertNotSame('نباید ذخیره شود', $this->reloadDoctor($uuid)->getInfo()); + } + + public function testForbiddenFieldIsRejectedAndNothingIsSaved(): void + { + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + $before = $this->reloadDoctor($uuid)->getMedicalSystemCode(); + + $body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [ + 'info' => 'این هم نباید ذخیره شود', + 'medical_system_code' => '999999', + ]); + + self::assertSame(403, $this->responseCode()); + self::assertSame('medical_system_code', $body['errors'][0]['field']); + + $doctor = $this->reloadDoctor($uuid); + self::assertSame($before, $doctor->getMedicalSystemCode()); + self::assertNotSame('این هم نباید ذخیره شود', $doctor->getInfo()); + } + + public function testTogglingActiveThroughPatchIsForbiddenForRepresentative(): void + { + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + + $body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['active' => true]); + + self::assertSame(403, $this->responseCode()); + self::assertSame('active', $body['errors'][0]['field']); + } + + // ── مرزی ────────────────────────────────────────────────────────────────── + + public function testDoctorWithoutARepresentationIsNotEditableByAnyRepresentative(): void + { + [$repUser] = $this->newRepresentative(); + + $orphanUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $orphan = new Doctor($orphanUser, 'پزشک بی‌نماینده'); + $this->em->persist($orphan); + $this->em->flush(); + + $this->authJson('PATCH', '/api/v1/doctor/' . $orphan->getUuid(), $repUser, ['info' => 'x']); + + self::assertSame(403, $this->responseCode()); + } + + public function testRoleWithoutARepresentationRowIsForbiddenNotFatal(): void + { + [$ownerUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($ownerUser); + + $rowless = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']); + + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $rowless, ['info' => 'x']); + + self::assertSame(403, $this->responseCode()); + } + + public function testDoctorEditingOwnProfileIsUnaffectedByTheWhitelist(): void + { + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'پزشک خودگردان'); + $this->em->persist($doctor); + $this->em->flush(); + $uuid = $doctor->getUuid(); + + $before = $this->editLogCount(); + // یکتا per-run: doctors.source_code ایندکس یکتا دارد و db_test هرگز پاک نمی‌شود. + $code = 'mc' . substr(uniqid(), -8); + + // کد نظام پزشکی برای نماینده ممنوع است اما برای خودِ پزشک نه. + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $doctorUser, ['medical_system_code' => $code]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode()); + self::assertSame($before, $this->editLogCount(), 'ویرایش خودِ پزشک نباید لاگ نماینده بسازد'); + } + + public function testAdminIsUnaffectedByTheWhitelist(): void + { + [$repUser] = $this->newRepresentative(); + $uuid = $this->doctorCreatedBy($repUser); + $admin = $this->createUser(['ROLE_ADMIN']); + $before = $this->editLogCount(); + $code = 'ac' . substr(uniqid(), -8); + + $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $admin, ['medical_system_code' => $code]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode()); + self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد'); + } +}