feat(permissions): single registry for secretary and clinic-doctor permissions

The list of permissionable resources was duplicated in six places that had
already diverged: both permission entities, three admin UI files and the
SecretaryPermissions TypeScript interface. Adding a resource meant editing all
of them, so new pages borrowed an unrelated resource instead — five resource
pages sat on appointment_settings.view and treatment-cases on appointments.view.

PermissionCatalog is now the only place that says which resources and actions
exist. Each entity keeps its own DEFAULT_PERMISSIONS, but as role policy only;
a test asserts those defaults never name a resource the registry doesn't have.

getPermissions() merges the stored JSON over the role defaults, so a resource
added to the registry later resolves to the role default instead of silently
false for every existing row. Explicitly stored values are never overwritten,
and no data migration is needed.

Two asymmetries fixed along the way:
- ClinicDoctorPermission validated writes against its own DEFAULT_PERMISSIONS,
  so services.create/delete could never be stored for an invited doctor.
- DoctorSecretary had no validation at all and would store any key, and it only
  read $patch['resources'] — the admin SecretariesPage sends a flat map, so its
  permission edit silently did nothing. Both entities now accept either shape
  and filter through the registry.

New resources 'resources' and 'treatment' are registered with defaults chosen to
preserve today's effective access, since both pages are currently gated on a
borrowed resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 17:32:28 +03:30
co-authored by Claude Opus 5
parent 0360a5e46f
commit 1d1efd7a85
7 changed files with 954 additions and 22 deletions
+4 -1
View File
@@ -7,6 +7,7 @@ use App\Clinic\Entity\Clinic;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Shared\Security\PermissionCatalog;
use App\Tests\ApiTestCase;
/**
@@ -46,8 +47,10 @@ class ClinicDoctorPermissionTest extends ApiTestCase
self::assertSame(200, $this->responseCode());
self::assertTrue($res['data']['active']);
// ترتیب و مجموعهٔ کلیدها را PermissionCatalog تعیین می‌کند، نه ترتیبِ
// نوشتنِ DEFAULT_PERMISSIONS؛ مقادیر همان پیش‌فرضِ نقش می‌مانند.
self::assertSame(
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
PermissionCatalog::merge([], ClinicDoctorPermission::DEFAULT_PERMISSIONS)['resources'],
$res['data']['permissions']['resources'],
'a member added before this feature gets defaults on first read',
);
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\Shared;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Security\PermissionCatalog;
use PHPUnit\Framework\TestCase;
class PermissionCatalogTest extends TestCase
{
public function testBlankCoversEveryResourceAndActionAsFalse(): void
{
$blank = PermissionCatalog::blank();
$this->assertSame(
array_keys(PermissionCatalog::RESOURCES),
array_keys($blank['resources']),
'blank() باید دقیقاً همان منابع رجیستری را داشته باشد',
);
foreach (PermissionCatalog::RESOURCES as $resource => $meta) {
foreach (array_keys($meta['actions']) as $action) {
$this->assertFalse(
$blank['resources'][$resource][$action],
"{$resource}.{$action} باید در blank خاموش باشد",
);
}
}
}
public function testMergeKeepsStoredValuesUntouched(): void
{
$stored = ['version' => 1, 'resources' => [
'appointments' => ['view' => true, 'create' => false],
]];
$defaults = ['version' => 1, 'resources' => [
'appointments' => ['view' => false, 'create' => true],
]];
$merged = PermissionCatalog::merge($stored, $defaults);
$this->assertTrue($merged['resources']['appointments']['view']);
$this->assertFalse(
$merged['resources']['appointments']['create'],
'مقدارِ صریحِ false در stored نباید با پیش‌فرضِ true بازنویسی شود',
);
}
public function testMergeFillsResourceMissingFromStoredWithRoleDefault(): void
{
$stored = ['version' => 1, 'resources' => ['appointments' => ['view' => true]]];
$defaults = ['version' => 1, 'resources' => ['treatment' => ['view' => true, 'update' => false]]];
$merged = PermissionCatalog::merge($stored, $defaults);
$this->assertTrue(
$merged['resources']['treatment']['view'],
'منبعی که بعد از ساختِ ردیف به رجیستری اضافه شده باید پیش‌فرضِ نقش را بگیرد',
);
$this->assertFalse($merged['resources']['treatment']['update']);
}
public function testMergeFallsBackToFalseWhenNeitherStoredNorDefaultHasIt(): void
{
$merged = PermissionCatalog::merge([], []);
foreach (PermissionCatalog::RESOURCES as $resource => $meta) {
foreach (array_keys($meta['actions']) as $action) {
$this->assertFalse($merged['resources'][$resource][$action]);
}
}
}
public function testFilterPatchDropsUnknownResourcesAndActions(): void
{
$clean = PermissionCatalog::filterPatch([
'appointments' => ['view' => true, 'teleport' => true],
'unknown_thing' => ['view' => true],
'patients' => 'not-an-array',
]);
$this->assertSame(['appointments' => ['view' => true]], $clean);
}
/** پیش‌فرضِ هر نقش فقط حق دارد از منابعِ رجیستری حرف بزند. */
public function testRoleDefaultsUseOnlyKnownResourcesAndActions(): void
{
$roles = [
'secretary' => DoctorSecretary::DEFAULT_PERMISSIONS,
'clinic_doctor' => ClinicDoctorPermission::DEFAULT_PERMISSIONS,
];
foreach ($roles as $role => $defaults) {
foreach ($defaults['resources'] as $resource => $actions) {
$this->assertTrue(
PermissionCatalog::hasResource($resource),
"منبع «{$resource}» در پیش‌فرضِ {$role} هست ولی در رجیستری نیست",
);
foreach (array_keys($actions) as $action) {
$this->assertTrue(
PermissionCatalog::hasAction($resource, $action),
"اکشن «{$resource}.{$action}» در پیش‌فرضِ {$role} هست ولی در رجیستری نیست",
);
}
}
}
}
public function testApiArrayKeepsRegistryOrder(): void
{
$api = PermissionCatalog::toApiArray();
$this->assertSame(
array_keys(PermissionCatalog::RESOURCES),
array_column($api, 'key'),
);
$this->assertTrue(
$api[array_search('clinic_doctors', array_column($api, 'key'), true)]['clinic_only'],
);
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Tests\Shared;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Security\PermissionCatalog;
use PHPUnit\Framework\TestCase;
/**
* رفتارِ هر دو Entity مجوز بعد از اتصال به رجیستری واحد.
*
* هر دو بدون constructor ساخته می‌شوند تا تست به Clinic/Doctor/User و پایگاه‌داده
* وابسته نشود؛ فقط همان دو فیلدی که منطقِ مجوز می‌خواند مقدار می‌گیرند.
*/
class PermissionRegistryEntityTest extends TestCase
{
private function clinicPermission(): ClinicDoctorPermission
{
$perm = (new \ReflectionClass(ClinicDoctorPermission::class))->newInstanceWithoutConstructor();
(new \ReflectionProperty(ClinicDoctorPermission::class, 'permissions'))
->setValue($perm, ClinicDoctorPermission::DEFAULT_PERMISSIONS);
return $perm;
}
private function secretary(): DoctorSecretary
{
$ref = new \ReflectionClass(DoctorSecretary::class);
return $ref->newInstanceWithoutConstructor();
}
// ── پزشکِ عضو کلینیک ──────────────────────────────────────────────────
/** تا پیش از این services فقط view/update داشت و create بی‌صدا دور ریخته می‌شد. */
public function testClinicDoctorCanNowStoreServicesCreate(): void
{
$perm = $this->clinicPermission();
$perm->mergePermissions(['resources' => ['services' => ['create' => true, 'delete' => true]]]);
$this->assertTrue($perm->can('services', 'create'));
$this->assertTrue($perm->can('services', 'delete'));
}
public function testClinicDoctorDropsUnknownResourceWithoutTouchingTheRest(): void
{
$perm = $this->clinicPermission();
$perm->mergePermissions(['resources' => [
'not_a_resource' => ['view' => true],
'patients' => ['delete' => true],
]]);
$this->assertArrayNotHasKey('not_a_resource', $perm->getPermissions()['resources']);
$this->assertTrue($perm->can('patients', 'delete'));
}
public function testClinicDoctorPermissionsAlwaysExposeFullRegistry(): void
{
$resources = $this->clinicPermission()->getPermissions()['resources'];
$this->assertSame(array_keys(PermissionCatalog::RESOURCES), array_keys($resources));
}
// ── منشی ─────────────────────────────────────────────────────────────
/** صفحهٔ ادمین نقشهٔ تخت می‌فرستد؛ تا پیش از این کل patch نادیده می‌رفت. */
public function testSecretaryAcceptsFlatPatchWithoutEnvelope(): void
{
$secretary = $this->secretary();
$secretary->mergePermissions(['patients' => ['update' => true]]);
$this->assertTrue($secretary->getPermissions()['resources']['patients']['update']);
}
public function testSecretaryAcceptsEnvelopePatch(): void
{
$secretary = $this->secretary();
$secretary->mergePermissions(['version' => 1, 'resources' => ['sms' => ['view' => true]]]);
$this->assertTrue($secretary->getPermissions()['resources']['sms']['view']);
}
/** تا پیش از این منشی هیچ اعتبارسنجی نداشت و هر کلیدی ذخیره می‌شد. */
public function testSecretaryDropsUnknownKeys(): void
{
$secretary = $this->secretary();
$secretary->mergePermissions(['resources' => [
'ghost_resource' => ['view' => true],
'patients' => ['teleport' => true],
]]);
$resources = $secretary->getPermissions()['resources'];
$this->assertArrayNotHasKey('ghost_resource', $resources);
$this->assertArrayNotHasKey('teleport', $resources['patients']);
}
/**
* ردیفِ ساخته‌شده پیش از افزودن treatment به رجیستری — نباید null یا خطا بدهد
* و باید پیش‌فرضِ نقش را بگیرد.
*/
public function testLegacyStoredJsonGetsRegistryDefaultsForNewResources(): void
{
$secretary = $this->secretary();
$legacy = new \ReflectionProperty(DoctorSecretary::class, 'permissions');
$legacy->setValue($secretary, ['version' => 1, 'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
]]);
$resources = $secretary->getPermissions()['resources'];
$this->assertTrue($resources['treatment']['view'], 'treatment باید پیش‌فرضِ نقش منشی را بگیرد');
$this->assertFalse($resources['resources']['view']);
$this->assertTrue($resources['appointments']['view'], 'مقدارِ ذخیره‌شده نباید عوض شود');
}
public function testExplicitlyDisabledStoredValueSurvivesMerge(): void
{
$secretary = $this->secretary();
$stored = new \ReflectionProperty(DoctorSecretary::class, 'permissions');
$stored->setValue($secretary, ['version' => 1, 'resources' => [
'appointments' => ['view' => false],
]]);
$this->assertFalse(
$secretary->getPermissions()['resources']['appointments']['view'],
'خاموشیِ صریح نباید با پیش‌فرضِ روشنِ نقش بازنویسی شود',
);
}
}