A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.
Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.
ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.
Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.
Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.
Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.
Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
5.7 KiB
PHP
168 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Representation;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Representation\Entity\Representation;
|
|
use App\Representation\Repository\RepresentationRepository;
|
|
use App\Representation\Security\RepresentationEditPolicy;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* سیاست ویرایش نماینده — بدون دیتابیس و بدون kernel.
|
|
*
|
|
* تنها قاعدهای که این کلاس نگه میدارد: مالکیت از representation_id میآید،
|
|
* نه از نقش. داشتن ROLE_REPRESENTATION بهتنهایی هیچ اجازهای نمیدهد.
|
|
*/
|
|
class RepresentationEditPolicyTest extends TestCase
|
|
{
|
|
/** Representation با id مشخص — id در entity خصوصی و بدون setter است. */
|
|
private function repWithId(User $user, int $id): Representation
|
|
{
|
|
$rep = new Representation($user, 'نمایندهٔ آزمون');
|
|
|
|
$ref = new \ReflectionProperty(Representation::class, 'id');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($rep, $id);
|
|
|
|
return $rep;
|
|
}
|
|
|
|
private function policyReturning(?Representation $rep): RepresentationEditPolicy
|
|
{
|
|
$repo = $this->createStub(RepresentationRepository::class);
|
|
$repo->method('findByUser')->willReturn($rep);
|
|
|
|
return new RepresentationEditPolicy($repo);
|
|
}
|
|
|
|
private function doctorOwnedBy(?int $representationId): Doctor
|
|
{
|
|
$doctor = new Doctor(new User('09120000001'), 'پزشک آزمون');
|
|
$doctor->setRepresentationId($representationId);
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
private function clinicOwnedBy(?int $representationId): Clinic
|
|
{
|
|
$clinic = new Clinic(new User('09120000002'));
|
|
$clinic->setRepresentationId($representationId);
|
|
|
|
return $clinic;
|
|
}
|
|
|
|
// ── مالکیت ────────────────────────────────────────────────────────────────
|
|
|
|
public function testRepresentationOwningTheDoctorIsAllowed(): void
|
|
{
|
|
$user = new User('09120000003');
|
|
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
|
|
$policy = $this->policyReturning($this->repWithId($user, 7));
|
|
|
|
self::assertTrue($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
|
}
|
|
|
|
public function testAnotherRepresentationIsRejected(): void
|
|
{
|
|
$user = new User('09120000004');
|
|
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
|
|
$policy = $this->policyReturning($this->repWithId($user, 7));
|
|
|
|
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(8)));
|
|
}
|
|
|
|
public function testDoctorWithoutRepresentationIsNeverOwned(): void
|
|
{
|
|
$user = new User('09120000005');
|
|
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
|
|
$policy = $this->policyReturning($this->repWithId($user, 7));
|
|
|
|
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(null)));
|
|
}
|
|
|
|
public function testUserWithoutTheRoleIsRejectedWithoutHittingTheRepository(): void
|
|
{
|
|
$user = new User('09120000006');
|
|
$user->setRoles(['ROLE_USER']);
|
|
|
|
$repo = $this->createMock(RepresentationRepository::class);
|
|
$repo->expects(self::never())->method('findByUser');
|
|
|
|
$policy = new RepresentationEditPolicy($repo);
|
|
|
|
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
|
}
|
|
|
|
public function testRoleWithoutRepresentationRowIsRejectedNotFatal(): void
|
|
{
|
|
$user = new User('09120000007');
|
|
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
|
|
$policy = $this->policyReturning(null);
|
|
|
|
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
|
}
|
|
|
|
public function testClinicOwnershipFollowsTheSameRule(): void
|
|
{
|
|
$user = new User('09120000008');
|
|
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
|
|
$policy = $this->policyReturning($this->repWithId($user, 3));
|
|
|
|
self::assertTrue($policy->ownsClinic($user, $this->clinicOwnedBy(3)));
|
|
self::assertFalse($policy->ownsClinic($user, $this->clinicOwnedBy(4)));
|
|
}
|
|
|
|
// ── whitelist ─────────────────────────────────────────────────────────────
|
|
|
|
public function testForbiddenClinicFieldIsNamed(): void
|
|
{
|
|
$policy = $this->policyReturning(null);
|
|
|
|
self::assertSame(
|
|
'doctors',
|
|
$policy->firstForbiddenField(['info' => 'x', 'doctors' => []], RepresentationEditPolicy::CLINIC_FIELDS),
|
|
);
|
|
}
|
|
|
|
public function testAllowedClinicPayloadPasses(): void
|
|
{
|
|
$policy = $this->policyReturning(null);
|
|
|
|
self::assertNull(
|
|
$policy->firstForbiddenField(
|
|
['info' => 'x', 'clinic_logo' => 'https://a/b.png', '24_7' => true],
|
|
RepresentationEditPolicy::CLINIC_FIELDS,
|
|
),
|
|
);
|
|
}
|
|
|
|
public function testForbiddenDoctorFieldsAreNamed(): void
|
|
{
|
|
$policy = $this->policyReturning(null);
|
|
|
|
self::assertSame(
|
|
'medical_system_code',
|
|
$policy->firstForbiddenField(['medical_system_code' => '123'], RepresentationEditPolicy::DOCTOR_FIELDS),
|
|
);
|
|
self::assertSame(
|
|
'active',
|
|
$policy->firstForbiddenField(['info' => 'x', 'active' => true], RepresentationEditPolicy::DOCTOR_FIELDS),
|
|
);
|
|
}
|
|
|
|
public function testEmptyPayloadHasNoForbiddenField(): void
|
|
{
|
|
$policy = $this->policyReturning(null);
|
|
|
|
self::assertNull($policy->firstForbiddenField([], RepresentationEditPolicy::DOCTOR_FIELDS));
|
|
}
|
|
}
|