Files
clinicpro/tests/Clinic/ClinicDoctorPermissionTest.php
T
hamedandClaude Opus 5 5211b34d0e feat(permissions): expose the registry over GET /api/v1/permission-catalog
Both permission forms in the admin panel can now render from the backend
registry instead of their own hardcoded lists. Resources come back as an array
so display order is part of the contract, each carrying its Persian label, its
actions, and the clinic_only flag that used to live in the frontend.

contextPermissions() normalizes the no-row branch through the registry too, so
a doctor whose permission row was never provisioned sees the same shape as one
who has it.

Two existing assertions compared the API response against DEFAULT_PERMISSIONS
by identity. The values are unchanged; only key order moved to the registry's,
so both now compare through PermissionCatalog::merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:39:45 +03:30

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(
PermissionCatalog::merge([], 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));
}
}