feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
238 lines
10 KiB
PHP
238 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Staff;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Auth\Entity\User;
|
|
use App\Auth\Repository\UserActiveContextRepository;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicService\Entity\CatalogCategory;
|
|
use App\ClinicService\Entity\CatalogCategoryInclude;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Resource\Entity\ClinicResource;
|
|
use App\Resource\Entity\ResourceType;
|
|
use App\Shared\Context\EntityContext;
|
|
use App\Staff\Entity\ClinicStaff;
|
|
use App\Tests\ApiTestCase;
|
|
use App\Treatment\Entity\SessionAreaRecord;
|
|
use App\Treatment\Entity\TreatmentCase;
|
|
use App\Treatment\Entity\TreatmentCaseArea;
|
|
use App\Treatment\Entity\TreatmentProtocol;
|
|
use App\Treatment\Entity\TreatmentProtocolStaff;
|
|
use App\Treatment\Entity\TreatmentProtocolStep;
|
|
use App\Treatment\Entity\TreatmentSession;
|
|
|
|
/**
|
|
* IDOR بینمحیطی برای نقش پرسنل.
|
|
*
|
|
* آدیت ۲۰۲۶-۰۸-۰۷ این سناریو را در بخش «محدودیت پوشش» باز گذاشت: در DB زنده تنها
|
|
* یک `ClinicStaff` وجود داشت، پس «پرسنل کلینیک الف روی جلسهٔ کلینیک ب» هرگز اجرا
|
|
* نشد. اینجا هر دو محیط در fixture ساخته میشوند تا آن حفرهٔ پوشش دائمی بسته شود.
|
|
*
|
|
* انتظار در همهٔ پروبها `404` است نه `403`: تفاوت «یافت نشد» و «اجازه نداری»
|
|
* خودش یک enumeration oracle است و به مهاجم میگوید کدام uuid در سیستم وجود دارد.
|
|
*/
|
|
class StaffCrossTenantTest extends ApiTestCase
|
|
{
|
|
/**
|
|
* این تست چند درخواستِ کرنل پشتسرهم میزند و هر کدام `$this->em` را کهنه
|
|
* میکند. بدون ریست، همان نمونه به تست بعدی ارث میرسد و آنجا — نه اینجا —
|
|
* با «Multiple non-persisted new entities» میشکند. همان دامی که
|
|
* ApiLeastPrivilegeTest قبلاً برایش همین tearDown را گذاشت.
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
static::getContainer()->get('doctrine')->resetManager();
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
private const LASER_SCHEMA = [
|
|
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
|
|
];
|
|
|
|
/**
|
|
* یک محیطِ کاملِ مستقل: کلینیک، پرسنل، پروتکل دوجلسهای، پرونده و جلسهٔ فعال.
|
|
*
|
|
* @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession}
|
|
*/
|
|
private function tenant(string $clinicName): array
|
|
{
|
|
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
|
$clinic->setName($clinicName);
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
|
|
$this->em->persist($doctor);
|
|
$clinic->getDoctors()->add($doctor);
|
|
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$address->setName('شعبهٔ مرکزی');
|
|
$this->em->persist($address);
|
|
|
|
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر');
|
|
$type->setFieldSchema(self::LASER_SCHEMA);
|
|
$this->em->persist($type);
|
|
$this->em->flush();
|
|
|
|
$resource = new ClinicResource($address, $type, 'Diode Laser');
|
|
$resource->setSupervisor($doctor);
|
|
$this->em->persist($resource);
|
|
|
|
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
|
$this->em->persist($section);
|
|
|
|
$parent = new CatalogCategory('clinic', (int) $clinic->getId(), 'توتال');
|
|
$this->em->persist($parent);
|
|
$area = new CatalogCategory('clinic', (int) $clinic->getId(), 'زیر بغل');
|
|
$this->em->persist($area);
|
|
$this->em->flush();
|
|
$this->em->persist(new CatalogCategoryInclude($parent, $area));
|
|
|
|
$service = new ServiceItem($section, 'لیزر توتال', 10_000_000);
|
|
$service->setCatalogCategory($parent)->setDurationMinutes(30);
|
|
$this->em->persist($service);
|
|
|
|
$staffUser = $this->createUser(['ROLE_USER', 'ROLE_STAFF']);
|
|
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
|
|
$staff->setUser($staffUser);
|
|
$this->em->persist($staff);
|
|
|
|
$protocol = new TreatmentProtocol($service);
|
|
$this->em->persist($protocol);
|
|
$protocol->replaceSteps([
|
|
new TreatmentProtocolStep($protocol, 1, 0),
|
|
new TreatmentProtocolStep($protocol, 2, 30),
|
|
]);
|
|
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
|
|
|
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
|
|
$this->em->persist($record);
|
|
$this->em->flush();
|
|
|
|
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
|
$case->addArea(new TreatmentCaseArea($case, $area, 0));
|
|
|
|
$appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic);
|
|
$appointment->setResource($resource);
|
|
$appointment->setStaff($staff);
|
|
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
$this->em->persist($appointment);
|
|
|
|
$session = new TreatmentSession($case, 1);
|
|
$session->attachAppointment($appointment);
|
|
$case->addSession($session);
|
|
$case->addSession(new TreatmentSession($case, 2));
|
|
$this->em->persist($case);
|
|
$this->em->flush();
|
|
|
|
// نقش بهتنهایی محیط نمیسازد؛ پرسنل باید محیط فعالش ست شده باشد.
|
|
static::getContainer()->get(UserActiveContextRepository::class)
|
|
->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
|
|
|
return ['staffUser' => $staffUser, 'staff' => $staff, 'session' => $session];
|
|
}
|
|
|
|
/** تنها ناحیهٔ یک جلسهٔ شروعشده. */
|
|
private function areaRecord(TreatmentSession $session): SessionAreaRecord
|
|
{
|
|
$records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]);
|
|
self::assertNotSame([], $records, 'جلسه باید دستکم یک ناحیه داشته باشد');
|
|
|
|
return $records[0];
|
|
}
|
|
|
|
/**
|
|
* شاهد مثبت: بدون این، «۴۰۴ در همهجا» میتوانست معنیِ «مسیر اصلاً کار نمیکند»
|
|
* بدهد و تستِ جداسازی بیاثر شود.
|
|
*/
|
|
public function testStaffCanStartASessionInsideTheirOwnTenant(): void
|
|
{
|
|
$a = $this->tenant('کلینیک الف');
|
|
|
|
$body = $this->authJson(
|
|
'POST',
|
|
'/api/v1/dashboard/staff/treatment-session/' . $a['session']->getUuid() . '/start',
|
|
$a['staffUser'],
|
|
);
|
|
|
|
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
self::assertSame($a['staff']->getUuid(), $body['data']['performed_by']['uuid']);
|
|
}
|
|
|
|
/** خواندن و نوشتنِ جلسهٔ محیط دیگر — هر چهار مسیر باید ۴۰۴ بدهند. */
|
|
public function testStaffCannotTouchASessionOfAnotherTenant(): void
|
|
{
|
|
$a = $this->tenant('کلینیک الف');
|
|
$b = $this->tenant('کلینیک ب');
|
|
|
|
$victim = $b['session']->getUuid();
|
|
$probes = [
|
|
['GET', "/api/v1/dashboard/staff/treatment-session/{$victim}"],
|
|
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/start"],
|
|
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/finish"],
|
|
];
|
|
|
|
foreach ($probes as [$method, $uri]) {
|
|
$body = $this->authJson($method, $uri, $a['staffUser']);
|
|
|
|
self::assertSame(404, $this->responseCode(), "{$method} {$uri}");
|
|
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], "{$method} {$uri}");
|
|
}
|
|
}
|
|
|
|
/** ناحیهٔ جلسهٔ محیط دیگر — همان قاعده یک لایه پایینتر در aggregate. */
|
|
public function testStaffCannotTouchASessionAreaOfAnotherTenant(): void
|
|
{
|
|
$a = $this->tenant('کلینیک الف');
|
|
$b = $this->tenant('کلینیک ب');
|
|
|
|
// ناحیهها هنگام شروعِ جلسه ساخته میشوند، پس اول محیط ب جلسهاش را شروع میکند.
|
|
$this->authJson(
|
|
'POST',
|
|
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid() . '/start',
|
|
$b['staffUser'],
|
|
);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$victim = $this->areaRecord($b['session'])->getUuid();
|
|
|
|
foreach (['start', 'complete', 'skip', 'reopen'] as $action) {
|
|
$uri = "/api/v1/dashboard/staff/session-area/{$victim}/{$action}";
|
|
$body = $this->authJson('POST', $uri, $a['staffUser'], ['parameters' => ['shots' => 10]]);
|
|
|
|
self::assertSame(404, $this->responseCode(), $uri);
|
|
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], $uri);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* مرزی: uuidِ اصلاً ناموجود باید همان ۴۰۴ را بدهد که uuidِ محیطِ دیگر میدهد.
|
|
* اگر این دو فرق کنند، همان تفاوت به مهاجم میگوید کدام uuid واقعی است.
|
|
*/
|
|
public function testUnknownUuidIsIndistinguishableFromAnotherTenantsUuid(): void
|
|
{
|
|
$a = $this->tenant('کلینیک الف');
|
|
$b = $this->tenant('کلینیک ب');
|
|
|
|
$nil = '00000000-0000-0000-0000-000000000000';
|
|
|
|
$unknown = $this->authJson('GET', "/api/v1/dashboard/staff/treatment-session/{$nil}", $a['staffUser']);
|
|
$unknownCode = $this->responseCode();
|
|
|
|
$foreign = $this->authJson(
|
|
'GET',
|
|
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid(),
|
|
$a['staffUser'],
|
|
);
|
|
|
|
self::assertSame($unknownCode, $this->responseCode());
|
|
self::assertSame($unknown['errors'][0]['code'], $foreign['errors'][0]['code']);
|
|
}
|
|
}
|