diff --git a/src/Module/Core/Domain/Security/AdminHeadcountGuard.php b/src/Module/Core/Domain/Security/AdminHeadcountGuard.php index 779f819..8978398 100644 --- a/src/Module/Core/Domain/Security/AdminHeadcountGuard.php +++ b/src/Module/Core/Domain/Security/AdminHeadcountGuard.php @@ -17,7 +17,7 @@ use App\Module\Core\Domain\Repository\UserRepositoryInterface; * Il compte les admins restants et leve LastAdminProtectionException si * le seuil minimum (1) serait franchi. */ -final class AdminHeadcountGuard +final class AdminHeadcountGuard implements AdminHeadcountGuardInterface { public function __construct(private readonly UserRepositoryInterface $userRepository) {} diff --git a/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php b/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php new file mode 100644 index 0000000..b45be17 --- /dev/null +++ b/src/Module/Core/Domain/Security/AdminHeadcountGuardInterface.php @@ -0,0 +1,31 @@ + + */ +final class UserProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly AdminHeadcountGuardInterface $adminHeadcountGuard, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof User) { + // Ce processor est wire exclusivement sur l'operation Delete de User. + // Si on arrive ici avec un autre type, c'est une misconfiguration. + throw new LogicException(sprintf( + 'UserProcessor attend une instance de %s, %s recu.', + User::class, + get_debug_type($data), + )); + } + + // Garde dernier admin global : on ne verifie que si on supprime + // effectivement un admin. La suppression d'un user standard n'a + // aucun impact sur le compteur d'administrateurs. + if ($data->isAdmin()) { + try { + $this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data); + } catch (LastAdminProtectionException $exception) { + throw new BadRequestHttpException($exception->getMessage(), $exception); + } + } + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php new file mode 100644 index 0000000..6836e4e --- /dev/null +++ b/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php @@ -0,0 +1,130 @@ +removeProcessor = $this->createMock(ProcessorInterface::class); + $this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class); + + $this->processor = new UserProcessor( + $this->removeProcessor, + $this->adminHeadcountGuard, + ); + } + + public function testDelegatesWhenUserIsNotAdmin(): void + { + $user = new User(); + $user->setUsername('alice'); + $user->setIsAdmin(false); + + // La garde ne doit jamais etre appellee pour un non-admin. + $this->adminHeadcountGuard + ->expects($this->never()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ; + + $this->removeProcessor + ->expects($this->once()) + ->method('process') + ->with($user) + ->willReturn(null) + ; + + $result = $this->processor->process($user, new Delete()); + + self::assertNull($result); + } + + public function testDelegatesWhenAdminButNotLast(): void + { + $user = new User(); + $user->setUsername('admin'); + $user->setIsAdmin(true); + + // La garde est appelee et ne leve pas d'exception (il reste d'autres admins). + $this->adminHeadcountGuard + ->expects($this->once()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ->with($user) + ; + + $this->removeProcessor + ->expects($this->once()) + ->method('process') + ->with($user) + ->willReturn(null) + ; + + $this->processor->process($user, new Delete()); + } + + public function testBlocksWhenDeletingLastAdmin(): void + { + $user = new User(); + $user->setUsername('admin'); + $user->setIsAdmin(true); + + $exceptionMessage = 'Impossible : au moins un administrateur doit rester sur l\'instance.'; + + $this->adminHeadcountGuard + ->expects($this->once()) + ->method('ensureAtLeastOneAdminRemainsAfterDeletion') + ->with($user) + ->willThrowException(new LastAdminProtectionException($exceptionMessage)) + ; + + // La suppression ne doit pas etre executee si la garde echoue. + $this->removeProcessor + ->expects($this->never()) + ->method('process') + ; + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->processor->process($user, new Delete()); + } + + public function testFailFastOnInvalidDataType(): void + { + // Garde-fou contre une misconfiguration : ce processor est wire + // exclusivement sur l'operation Delete de User. + $this->adminHeadcountGuard->expects($this->never())->method('ensureAtLeastOneAdminRemainsAfterDeletion'); + $this->removeProcessor->expects($this->never())->method('process'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('UserProcessor attend une instance de'); + + $this->processor->process(new stdClass(), new Delete()); + } +}