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>
188 lines
7.3 KiB
PHP
188 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Clinic;
|
|
|
|
use App\Auth\Entity\User;
|
|
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;
|
|
|
|
/**
|
|
* Per-doctor permissions inside a clinic: owner-only management, deep-merge
|
|
* semantics, lazy provisioning for pre-existing members, and context exposure.
|
|
*/
|
|
class ClinicDoctorPermissionTest extends ApiTestCase
|
|
{
|
|
private function createClinicWithDoctor(): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$clinic = new Clinic($owner);
|
|
$clinic->setName('کلینیک تست');
|
|
|
|
$docUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($docUser, 'دکتر عضو');
|
|
$doctor->setMobileNumber($docUser->getMobileNumber());
|
|
|
|
$this->em->persist($doctor);
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
return [$owner, $clinic, $doctor, $docUser];
|
|
}
|
|
|
|
private function permRepo(): ClinicDoctorPermissionRepository
|
|
{
|
|
return static::getContainer()->get(ClinicDoctorPermissionRepository::class);
|
|
}
|
|
|
|
public function testOwnerReadsLazilyProvisionedDefaults(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
|
|
$res = $this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions", $owner);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertTrue($res['data']['active']);
|
|
// ترتیب و مجموعهٔ کلیدها را PermissionCatalog تعیین میکند، نه ترتیبِ
|
|
// نوشتنِ DEFAULT_PERMISSIONS؛ مقادیر همان پیشفرضِ نقش میمانند.
|
|
self::assertSame(
|
|
PermissionCatalog::merge([], ClinicDoctorPermission::DEFAULT_PERMISSIONS)['resources'],
|
|
$res['data']['permissions']['resources'],
|
|
'a member added before this feature gets defaults on first read',
|
|
);
|
|
}
|
|
|
|
public function testPatchOnlyTouchesProvidedKeys(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
|
|
$res = $this->authJson(
|
|
'PATCH',
|
|
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
|
$owner,
|
|
['permissions' => ['resources' => ['payments' => ['create' => true]]]],
|
|
);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
$resources = $res['data']['permissions']['resources'];
|
|
self::assertTrue($resources['payments']['create']);
|
|
self::assertFalse($resources['payments']['delete'], 'untouched actions keep their value');
|
|
self::assertTrue($resources['appointments']['view'], 'untouched resources keep their value');
|
|
}
|
|
|
|
public function testUnknownResourceAndActionAreIgnored(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
|
|
$res = $this->authJson(
|
|
'PATCH',
|
|
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
|
$owner,
|
|
['permissions' => ['resources' => ['bogus' => ['view' => true], 'payments' => ['fly' => true]]]],
|
|
);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertArrayNotHasKey('bogus', $res['data']['permissions']['resources']);
|
|
self::assertArrayNotHasKey('fly', $res['data']['permissions']['resources']['payments']);
|
|
}
|
|
|
|
public function testDeactivationRevokesEverything(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
|
|
$this->authJson(
|
|
'PATCH',
|
|
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
|
$owner,
|
|
['active' => false],
|
|
);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$perm = $this->permRepo()->findOneFor(
|
|
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
|
|
$this->em->getRepository(Doctor::class)->find($doctor->getId()),
|
|
);
|
|
self::assertFalse($perm->isActive());
|
|
self::assertFalse($perm->can('appointments', 'view'), 'inactive membership grants nothing');
|
|
}
|
|
|
|
public function testMemberDoctorCannotEditOwnPermissions(): void
|
|
{
|
|
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
|
|
|
$this->authJson(
|
|
'PATCH',
|
|
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
|
$docUser,
|
|
['permissions' => ['resources' => ['payments' => ['delete' => true]]]],
|
|
);
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorOfAnotherClinicIsNotFound(): void
|
|
{
|
|
[$owner, $clinic] = $this->createClinicWithDoctor();
|
|
|
|
$strangerUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$stranger = new Doctor($strangerUser, 'دکتر بیرونی');
|
|
$this->em->persist($stranger);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$stranger->getUuid()}/permissions", $owner);
|
|
|
|
self::assertSame(404, $this->responseCode());
|
|
}
|
|
|
|
public function testOwnerIsNeverRestrictedByPermissions(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
|
|
$checker = static::getContainer()->get(\App\Clinic\Security\ClinicDoctorPermissionChecker::class);
|
|
$perm = $this->permRepo()->getOrCreate($clinic, $doctor);
|
|
$perm->setActive(false);
|
|
$this->em->flush();
|
|
|
|
self::assertTrue($checker->can($owner, $clinic, 'clinic_info', 'update'));
|
|
}
|
|
|
|
public function testMemberContextCarriesPermissionsAndOwnPracticeDoesNot(): void
|
|
{
|
|
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
|
|
|
$res = $this->authJson('GET', '/oauth/userinfo', $docUser);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$contexts = $res['data']['available_contexts'];
|
|
$personal = array_values(array_filter($contexts, fn($c) => $c['type'] === 'doctor'));
|
|
$member = array_values(array_filter($contexts, fn($c) => $c['type'] === 'clinic'));
|
|
|
|
self::assertNotEmpty($personal);
|
|
self::assertNotEmpty($member);
|
|
self::assertNull($personal[0]['permissions'] ?? null, 'own practice is unrestricted');
|
|
self::assertSame(
|
|
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
|
|
$member[0]['permissions']['resources'],
|
|
);
|
|
}
|
|
|
|
public function testDetachingDoctorRemovesPermissionRow(): void
|
|
{
|
|
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
|
$this->permRepo()->getOrCreate($clinic, $doctor);
|
|
|
|
$this->authJson('DELETE', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}", $owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$reloadedClinic = $this->em->getRepository(Clinic::class)->find($clinic->getId());
|
|
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
|
self::assertNull($this->permRepo()->findOneFor($reloadedClinic, $reloadedDoctor));
|
|
}
|
|
}
|