The controller carried only IS_AUTHENTICATED_FULLY on the class and none of its 15 routes checked a permission. A secretary whose owner had turned `services` fully off could still create, rename and delete service categories, build item groups, replace group members, and rewrite service relations and per-branch overrides. Scope is intra-tenant privilege escalation, not IDOR: owned() and requireItem() already resolve every uuid against the caller's active environment, so no data crossed tenants. Gating is per-action (view/create/update/delete) and reuses denyServices() from ClinicServiceController in the same domain, so a secretary with `update` cannot create or delete. The call is the first statement in every action, before requireCategory/requireItem — placed after, an unknown uuid would answer 404 and leak whether the record exists. An earlier note claimed these endpoints were consumed by the booking flow and so could not be closed. That was wrong. service-selection/validate, the group routes and the relation routes have no consumer in any of the three API clients, and the sibling controller already puts every service read behind services.view — the booking modal reads service-items through it — so any flow needing services already needed the permission. The docs claimed appointment_settings.* for the includes routes, which was never enforced either; corrected to services.*. The test loops the whole route list rather than sampling, and a guard asserts the count of #[Route( equals the count of denyServices( so a future ungated route fails here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.8 KiB
PHP
167 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\ClinicService;
|
|
|
|
use App\Auth\Entity\UserActiveContext;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Secretary\Entity\DoctorSecretary;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* ServiceCatalogController تا پیش از این هیچ گِیت مجوزی نداشت — فقط
|
|
* IS_AUTHENTICATED_FULLY. منشی با `services` کاملاً خاموش میتوانست کاتالوگ سرویسِ
|
|
* محیط خودش را بخواند و بنویسد.
|
|
*
|
|
* حلقه روی **کل** فهرست routeها میچرخد، نه چند نمونه: هدف این است که routeِ تازهای
|
|
* که فردا بدون گِیت اضافه شود همینجا قرمز شود.
|
|
*/
|
|
class ServiceCatalogPermissionTest extends ApiTestCase
|
|
{
|
|
/** @return list<array{0: string, 1: string, 2: string, 3: array}> [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های کنترلر نمیخواند');
|
|
}
|
|
}
|