Files
clinicpro/tests/Representation/RepresentationEditPolicyTest.php
hamedandClaude Opus 5 fb1cb20c11 feat(representation): let registering reps edit their doctors and clinics
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>
2026-08-08 15:50:17 +03:30

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));
}
}