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>
88 lines
3.2 KiB
PHP
88 lines
3.2 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Representation;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Representation\Entity\Representation;
|
|
use App\Representation\Security\RepresentationEditLogger;
|
|
use App\Tests\ApiTestCase;
|
|
use Doctrine\DBAL\Connection;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
|
|
/**
|
|
* لاگِ ویرایشِ نماینده — یک ردیف app_log با channel اختصاصی، و هیچ ردیفی برای
|
|
* کاربری که نماینده نیست.
|
|
*/
|
|
class RepresentationEditLoggerTest extends ApiTestCase
|
|
{
|
|
/**
|
|
* سرویس فقط توسط کنترلرها مصرف میشود، پس کانتینر inlineاش میکند و از تست
|
|
* قابل get نیست. public کردنش صرفاً برای تست، پیکربندی production را آلوده
|
|
* میکرد؛ اینجا با همان وابستگیهای واقعی ساخته میشود.
|
|
*/
|
|
private function logger(): RepresentationEditLogger
|
|
{
|
|
return new RepresentationEditLogger(
|
|
static::getContainer()->get(Connection::class),
|
|
$this->em->getRepository(Representation::class),
|
|
new RequestStack(),
|
|
);
|
|
}
|
|
|
|
private function editLogCount(): int
|
|
{
|
|
return (int) static::getContainer()->get(Connection::class)
|
|
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
|
}
|
|
|
|
private function newRepresentative(): User
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
$this->em->persist(new Representation($user, 'نمایندهٔ لاگ'));
|
|
$this->em->flush();
|
|
|
|
return $user;
|
|
}
|
|
|
|
public function testEditByRepresentativeWritesOneRow(): void
|
|
{
|
|
$user = $this->newRepresentative();
|
|
$before = $this->editLogCount();
|
|
|
|
$this->logger()->logEdit($user, 'doctor', 'uuid-under-test', ['info' => 'x', 'images' => []]);
|
|
|
|
self::assertSame($before + 1, $this->editLogCount());
|
|
}
|
|
|
|
public function testTheRowNamesTheRepresentativeAndTheChangedFields(): void
|
|
{
|
|
$user = $this->newRepresentative();
|
|
$uuid = 'uuid-' . uniqid();
|
|
|
|
$this->logger()->logEdit($user, 'clinic', $uuid, ['clinic_logo' => 'https://a/b.png', 'info' => 'y']);
|
|
|
|
$row = static::getContainer()->get(Connection::class)->fetchAssociative(
|
|
"SELECT message, context, level FROM app_log WHERE channel = 'representation_edit' ORDER BY id DESC LIMIT 1"
|
|
);
|
|
|
|
self::assertSame('info', $row['level']);
|
|
self::assertStringContainsString($uuid, $row['message']);
|
|
|
|
$context = json_decode((string) $row['context'], true);
|
|
self::assertSame('clinic', $context['entity_type']);
|
|
self::assertSame(['clinic_logo', 'info'], $context['fields']);
|
|
// فقط کلیدها ثبت میشوند، نه مقادیر.
|
|
self::assertStringNotContainsString('https://a/b.png', (string) $row['context']);
|
|
}
|
|
|
|
public function testUserWithoutARepresentationRowWritesNothing(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_USER']);
|
|
$before = $this->editLogCount();
|
|
|
|
$this->logger()->logEdit($user, 'doctor', 'uuid-x', ['info' => 'x']);
|
|
|
|
self::assertSame($before, $this->editLogCount());
|
|
}
|
|
}
|