[method, path, action, body] */ private static function routes(): array { $cat = '11111111-1111-1111-1111-111111111111'; $item = '22222222-2222-2222-2222-222222222222'; $grp = '33333333-3333-3333-3333-333333333333'; return [ ['GET', '/api/v1/service-categories/tree', 'view', []], ['GET', "/api/v1/service-category/{$cat}/includes", 'view', []], ['GET', "/api/v1/service-item/{$item}/groups", 'view', []], ['POST', '/api/v1/service-selection/validate', 'view', ['item_uuids' => []]], ['POST', '/api/v1/service-category', 'create', ['name' => 'x']], ['POST', "/api/v1/service-category/{$cat}/includes", 'create', ['child_category_uuid' => $cat]], ['POST', "/api/v1/service-item/{$item}/groups", 'create', ['name' => 'x']], ['PATCH', "/api/v1/service-category/{$cat}", 'update', ['name' => 'x']], ['PATCH', "/api/v1/item-group/{$grp}", 'update', ['name' => 'x']], ['PUT', "/api/v1/item-group/{$grp}/items", 'update', ['item_uuids' => []]], ['PUT', "/api/v1/service-item/{$item}/relations", 'update', ['relations' => []]], ['PUT', "/api/v1/service-item/{$item}/branch-overrides", 'update', ['overrides' => []]], ['DELETE', "/api/v1/service-category/{$cat}/includes/{$cat}", 'delete', []], ['DELETE', "/api/v1/item-group/{$grp}", 'delete', []], ['DELETE', "/api/v1/service-category/{$cat}", 'delete', []], ]; } /** @return array{0: \App\Auth\Entity\User, 1: DoctorSecretary, 2: \App\Auth\Entity\User} */ private function makeClinicSecretary(): array { $owner = $this->createUser(['ROLE_CLINIC']); $clinic = new Clinic($owner); $this->em->persist($clinic); $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); $this->em->persist($doctor); $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); $rel = new DoctorSecretary($doctor, $secretary, $clinic); $this->em->persist($rel); $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); $this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic')); return [$secretary, $rel, $owner]; } private function denyAllServices(DoctorSecretary $rel): void { $rel->mergePermissions(['resources' => ['services' => [ 'view' => false, 'create' => false, 'update' => false, 'delete' => false, ]]]); } public function testEveryRouteIsDeniedWhenServicesIsOff(): void { [$secretary, $rel] = $this->makeClinicSecretary(); $this->denyAllServices($rel); $this->em->flush(); foreach (self::routes() as [$method, $path, $action, $body]) { $this->authJson($method, $path, $secretary, $body); self::assertSame( 403, $this->responseCode(), "{$method} {$path} باید ۴۰۳ بدهد وقتی services.{$action} خاموش است", ); } } /** * گِیت باید **قبل** از requireCategory/requireItem اجرا شود. اگر بعدش بیاید، * uuidِ ساختگی ۴۰۴ می‌دهد و وجود/نبودِ رکورد لو می‌رود. */ public function testDeniedWithForbiddenNotNotFoundForUnknownUuid(): void { [$secretary, $rel] = $this->makeClinicSecretary(); $this->denyAllServices($rel); $this->em->flush(); $this->authJson('DELETE', '/api/v1/service-category/00000000-0000-0000-0000-000000000000', $secretary); self::assertSame(403, $this->responseCode(), 'گِیت باید پیش از حل uuid اجرا شود'); } public function testViewOpensReadsButNotWrites(): void { [$secretary, $rel] = $this->makeClinicSecretary(); $this->denyAllServices($rel); $rel->mergePermissions(['resources' => ['services' => ['view' => true]]]); $this->em->flush(); $this->authJson('GET', '/api/v1/service-categories/tree', $secretary); self::assertSame(200, $this->responseCode(), 'خواندنِ درخت با services.view باید باز باشد'); foreach (self::routes() as [$method, $path, $action, $body]) { if ($action === 'view') { continue; } $this->authJson($method, $path, $secretary, $body); self::assertSame( 403, $this->responseCode(), "{$method} {$path} با فقط services.view نباید اجازه داشته باشد", ); } } /** منشیِ دارای update نباید بتواند بسازد یا حذف کند. */ public function testUpdateDoesNotImplyCreateOrDelete(): void { [$secretary, $rel] = $this->makeClinicSecretary(); $this->denyAllServices($rel); $rel->mergePermissions(['resources' => ['services' => ['view' => true, 'update' => true]]]); $this->em->flush(); $this->authJson('POST', '/api/v1/service-category', $secretary, ['name' => 'x']); self::assertSame(403, $this->responseCode(), 'update نباید create بدهد'); $this->authJson('DELETE', '/api/v1/service-category/11111111-1111-1111-1111-111111111111', $secretary); self::assertSame(403, $this->responseCode(), 'update نباید delete بدهد'); } /** مالکِ کلینیک از گِیت عبور می‌کند — هرگز نباید بتواند خودش را قفل کند. */ public function testClinicOwnerBypassesTheGate(): void { [, , $owner] = $this->makeClinicSecretary(); $this->em->flush(); $this->authJson('GET', '/api/v1/service-categories/tree', $owner); self::assertSame(200, $this->responseCode()); } /** تضمینِ پوششِ کامل: اگر routeی به کنترلر اضافه شود و به این فهرست نه، اینجا می‌شکند. */ public function testRouteListCoversEveryControllerRoute(): void { $source = file_get_contents( \dirname(__DIR__, 2) . '/src/ClinicService/Controller/ServiceCatalogController.php', ); $routeCount = preg_match_all('/#\[Route\(/', $source); $gateCount = preg_match_all('/denyServices\(\$user,/', $source); self::assertSame($routeCount, $gateCount, 'هر route باید دقیقاً یک denyServices داشته باشد'); self::assertCount($routeCount, self::routes(), 'فهرست تستِ بالا با تعداد routeهای کنترلر نمی‌خواند'); } }