PatientController::resolveScope and TenantTagController::guardTagView only ever checked the secretary, while every write in both controllers already ran through both checkers. So an invited clinic doctor with patients.view off got 200 with an empty list where a secretary got 403 — one permission, two behaviours. No data was exposed either way; tenant scoping emptied the result. The fix is not canOrNonMember. That collapses two different situations: a membership row switched to active=false means the collaboration ended, and ClinicDoctorPermission::can() returns false for everything in that case too. Routing it through the permission gate turned the existing 404 on a single record into a 403, which confirms the record exists to someone who just lost access. ClinicRecordAccessTest caught it. isActiveMemberDenied() answers the narrower question — active member, permission off — and leaves a deactivated row to the data scope, which closes it with a 404 and discloses nothing. A test now pins that distinction so it cannot be collapsed again. Tags keep the tags.view OR patients.view rule, now for both roles. Verified live in three states: active with both off 403/403, deactivated not 403, active with patients.view on 200/200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
6.9 KiB
PHP
178 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Clinic;
|
|
|
|
use App\Auth\Entity\UserActiveContext;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Clinic\Entity\ClinicDoctorPermission;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* مجوزهای پزشکِ عضوِ کلینیک (ClinicDoctorPermission) باید مثل منشی در API enforce
|
|
* شوند: منبعی که کلینیک به پزشکِ عضو نداده ۴۰۳؛ منبعِ دادهشده ۲۰۰. پزشکِ مستقل
|
|
* (بدون محیطِ کلینیک) هرگز محدود نمیشود.
|
|
*/
|
|
class ClinicDoctorPermissionEnforcementTest extends ApiTestCase
|
|
{
|
|
/** @return array{0: \App\Auth\Entity\User, 1: ClinicDoctorPermission} */
|
|
private function makeMemberDoctor(): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_CLINIC']);
|
|
$clinic = new Clinic($owner);
|
|
$this->em->persist($clinic);
|
|
|
|
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
|
$doctor = new Doctor($doctorUser, 'دکتر عضو');
|
|
$this->em->persist($doctor);
|
|
$clinic->getDoctors()->add($doctor);
|
|
|
|
$perm = new ClinicDoctorPermission($clinic, $doctor);
|
|
$this->em->persist($perm);
|
|
// محیطِ فعالِ پزشک = کلینیک، تا memberClinicId او را به کلینیک ببرد.
|
|
$this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid(), 'clinic'));
|
|
|
|
return [$doctorUser, $perm];
|
|
}
|
|
|
|
public function testInventoryDeniedByDefault(): void
|
|
{
|
|
// DEFAULT_PERMISSIONS: inventory.* = false
|
|
[$doctorUser] = $this->makeMemberDoctor();
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testInventoryAllowedWhenGranted(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => ['inventory' => ['view' => true]]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testStaffDeniedByDefault(): void
|
|
{
|
|
[$doctorUser] = $this->makeMemberDoctor();
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testStaffCreateDeniedButViewGranted(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => ['staff' => ['view' => true, 'create' => false]]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
|
|
$this->authJson('POST', '/api/v1/staff', $doctorUser, ['full_name' => 'خانم تست']);
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testIndependentDoctorIsNotRestricted(): void
|
|
{
|
|
// پزشکِ مستقل: نه عضوِ کلینیک، نه محیطِ کلینیک → روی دادهٔ شخصیِ خودش آزاد.
|
|
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
|
$this->em->persist(new Doctor($doctorUser, 'دکتر مستقل'));
|
|
$this->em->flush();
|
|
|
|
// inventory برای منابعِ ClinicDoctorPermission پیشفرض false است، اما این پزشک
|
|
// اصلاً عضوِ کلینیک نیست، پس ClinicDoctorAccessChecker او را محدود نمیکند.
|
|
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
}
|
|
// ── خواندنِ پرونده و تگ — قرینهٔ منشی ────────────────────────────────────
|
|
|
|
/**
|
|
* تا پیش از این فقط منشی در PatientController::resolveScope بررسی میشد، پس
|
|
* پزشکِ عضو با `patients.view` خاموش بهجای ۴۰۳، ۲۰۰ با فهرست خالی میگرفت.
|
|
*/
|
|
public function testPatientListDeniedWhenPatientsViewOff(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => ['patients' => ['view' => false]]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/patients', $doctorUser);
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
/** پیشفرضِ پزشکِ عضو `patients.view = true` است. */
|
|
public function testPatientListAllowedByDefault(): void
|
|
{
|
|
[$doctorUser] = $this->makeMemberDoctor();
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/patients', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
/** تگها با tags.view یا patients.view باز میشوند — همان قاعدهٔ منشی. */
|
|
public function testTagListAllowedViaPatientsViewEvenWhenTagsViewOff(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => [
|
|
'patients' => ['view' => true],
|
|
'tags' => ['view' => false],
|
|
]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testTagListDeniedWhenNeitherTagsNorPatientsViewGranted(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => [
|
|
'patients' => ['view' => false],
|
|
'tags' => ['view' => false],
|
|
]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testTagListAllowedWithTagsViewAlone(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->mergePermissions(['resources' => [
|
|
'patients' => ['view' => false],
|
|
'tags' => ['view' => true],
|
|
]]);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
|
|
$this->assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
/**
|
|
* «پایان همکاری» با «مجوز خاموش» یکی نیست: ردیفِ غیرفعال نباید ۴۰۳ بدهد،
|
|
* وگرنه وجودِ پرونده لو میرود. دامنهٔ داده خودش آن را میبندد.
|
|
*/
|
|
public function testDeactivatedMemberIsNotAnswered403OnTheList(): void
|
|
{
|
|
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
|
$perm->setActive(false);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('GET', '/api/v1/patients', $doctorUser);
|
|
|
|
$this->assertNotSame(
|
|
403,
|
|
$this->responseCode(),
|
|
'ردیفِ غیرفعال باید از مسیرِ دامنهٔ داده بسته شود، نه با ۴۰۳ مجوز',
|
|
);
|
|
}
|
|
|
|
}
|