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