Files
clinicpro/tests/Shared/PermissionCatalogTest.php
T
hamedandClaude Opus 5 294ca19a46 fix(permissions): make the addresses resource real instead of decorative
A full role-by-role sweep (9 roles x 18 endpoints against the running app) showed
the addresses toggles in the owner's permission form controlled nothing. Grep
confirms it: no gate anywhere referenced 'addresses'. The panel's address list was
gated on appointment_settings.view instead — the same borrowed-permission pattern
already fixed for resources and treatment.

GET /api/v1/addresses now gates on addresses.view.

The resource drops to view-only. Creating, updating and deleting an address in
ClinicController is explicitly owner-or-admin
($clinic->getUser()->getId() !== $user->getId()), so those three actions could
never be delegated to a secretary or an invited doctor no matter what the form
said. Both role defaults narrow to ['view' => true] to match, and stored JSON
keeps its old keys harmlessly since merge only reads registry keys.

This widens secretary access: addresses.view defaults to true while
appointment_settings.view defaults to false, so secretaries who could not list
addresses now can. That is deliberate and costs no confidentiality — the same
addresses are already served anonymously from
GET /api/v1/clinic/{uuid}/addresses, which is whitelisted in security.yaml.

Verified live in three states: default 200, addresses.view off 403, and
addresses off with appointment_settings on still 403, proving the borrow is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:16:02 +03:30

135 lines
5.0 KiB
PHP

<?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'],
);
}
/**
* ساخت/ویرایش/حذفِ آدرس در ClinicController صریحاً owner-or-admin است و قابل
* واگذاری نیست؛ پس رجیستری نباید توگلی نشان دهد که هیچ‌وقت اثر ندارد.
*/
public function testAddressesExposesOnlyView(): void
{
$this->assertSame(
['view'],
array_keys(PermissionCatalog::RESOURCES['addresses']['actions']),
);
}
}