diff --git a/src/Module/Core/Domain/Exception/LastAdminProtectionException.php b/src/Module/Core/Domain/Exception/LastAdminProtectionException.php new file mode 100644 index 0000000..7f41a9b --- /dev/null +++ b/src/Module/Core/Domain/Exception/LastAdminProtectionException.php @@ -0,0 +1,26 @@ +checkAdminHeadcount(); + } + + /** + * Verifie qu'il restera au moins un admin apres la suppression de $user. + * + * Meme principe que ensureAtLeastOneAdminRemainsAfterDemotion() : $user + * est accepte pour la symetrie du contrat et les evolutions futures, + * mais le comptage ne depend pas de son identite. + */ + public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void + { + $this->checkAdminHeadcount(); + } + + /** + * Compte les administrateurs et leve une exception si le seuil minimum est atteint. + * + * La verification est volontairement conservative (<=1) pour couvrir + * le cas defensif ou la base serait deja dans un etat incoherent (0 admin). + * + * @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1 + */ + private function checkAdminHeadcount(): void + { + if ($this->userRepository->countAdmins() <= 1) { + throw new LastAdminProtectionException(); + } + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php index 279dade..ce01e15 100644 --- a/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php @@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo $this->getEntityManager()->persist($user); $this->getEntityManager()->flush(); } + + /** + * Compte les utilisateurs ayant le flag isAdmin a true. + * + * Utilise par AdminHeadcountGuard pour verifier que l'instance conserve + * toujours au moins un administrateur apres une demote ou une suppression. + */ + public function countAdmins(): int + { + return (int) $this->createQueryBuilder('u') + ->select('COUNT(u.id)') + ->where('u.isAdmin = true') + ->getQuery() + ->getSingleScalarResult() + ; + } } diff --git a/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php b/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php new file mode 100644 index 0000000..7278aac --- /dev/null +++ b/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php @@ -0,0 +1,127 @@ +createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(2); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + // Aucune exception ne doit etre levee + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + $this->addToAssertionCount(1); + } + + /** + * Bloque la demote quand il ne reste exactement qu'un admin. + */ + public function testBlocksDemotionWhenExactlyOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(1); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + } + + /** + * Bloque la demote de facon defensive si le compteur est a 0 (etat incoherent). + */ + public function testBlocksDemotionDefensivelyWhenZeroAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(0); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('alice'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDemotion($user); + } + + // --------------------------------------------------------------- + // Deletion (suppression de l'utilisateur) + // --------------------------------------------------------------- + + /** + * Autorise la suppression quand il reste plus d'un admin (cas nominal). + */ + public function testAllowsDeletionWhenMoreThanOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(2); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + // Aucune exception ne doit etre levee + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + $this->addToAssertionCount(1); + } + + /** + * Bloque la suppression quand il ne reste exactement qu'un admin. + */ + public function testBlocksDeletionWhenExactlyOneAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(1); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + } + + /** + * Bloque la suppression de facon defensive si le compteur est a 0 (etat incoherent). + */ + public function testBlocksDeletionDefensivelyWhenZeroAdmin(): void + { + $repo = $this->createMock(UserRepositoryInterface::class); + $repo->method('countAdmins')->willReturn(0); + + $guard = new AdminHeadcountGuard($repo); + $user = new User(); + $user->setUsername('bob'); + + $this->expectException(LastAdminProtectionException::class); + $guard->ensureAtLeastOneAdminRemainsAfterDeletion($user); + } +}