From 47724734a20dc86bb3e9aac312e7d01eaaa747e9 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 3 Dec 2025 07:58:21 +0100 Subject: [PATCH 01/43] refactor for monolog user create --- config/packages/monolog.yaml | 102 ++++++++++--- src/Controller/UserController.php | 206 +++++++++++++++++---------- src/Service/EmailService.php | 8 +- src/Service/LoggerService.php | 142 ++++++++++++++++++ src/Service/OrganizationsService.php | 5 +- src/Service/UserService.php | 188 +++++++++++++++++++++++- 6 files changed, 547 insertions(+), 104 deletions(-) create mode 100644 src/Service/LoggerService.php diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index c533f37..34dd8f7 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -12,25 +12,87 @@ monolog: - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists when@dev: - monolog: - handlers: - main: - type: stream - path: "%kernel.logs_dir%/%kernel.environment%.log" - level: debug - channels: ["!event"] - # uncomment to get logging in your browser - # you may have to allow bigger header sizes in your Web server configuration - #firephp: - # type: firephp - # level: info - #chromephp: - # type: chromephp - # level: info - console: - type: console - process_psr_3_messages: false - channels: ["!event", "!doctrine", "!console"] + monolog: + handlers: + critical_errors: + type: fingers_crossed + action_level: critical + handler: error_nested + buffer_size: 50 + + error_nested: + type: rotating_file + path: "%kernel.logs_dir%/error.log" + level: debug + max_files: 30 + + error: + type: rotating_file + path: "%kernel.logs_dir%/error.log" + level: error # logs error, critical, alert, emergency + max_files: 30 + channels: [ error ] + php_errors: + type: rotating_file + path: "%kernel.logs_dir%/php_error.log" + level: warning # warnings, errors, fatals… + max_files: 30 + channels: [ php ] + # User Management + user_management: + type: rotating_file + path: "%kernel.logs_dir%/user_management.log" + level: debug + channels: [ user_management ] + max_files: 30 + + # Authentication + authentication: + type: rotating_file + path: "%kernel.logs_dir%/authentication.log" + level: debug + channels: [ authentication ] + max_files: 30 + + # Organization Management + organization_management: + type: rotating_file + path: "%kernel.logs_dir%/organization_management.log" + level: debug + channels: [ organization_management ] + max_files: 30 + + # Access Control + access_control: + type: rotating_file + path: "%kernel.logs_dir%/access_control.log" + level: debug + channels: [ access_control ] + max_files: 30 + + # Email Notifications + email_notifications: + type: rotating_file + path: "%kernel.logs_dir%/email_notifications.log" + level: debug + channels: [ email_notifications ] + max_files: 30 + + # Admin Actions + admin_actions: + type: rotating_file + path: "%kernel.logs_dir%/admin_actions.log" + level: debug + channels: [ admin_actions ] + max_files: 30 + + # Security + security: + type: rotating_file + path: "%kernel.logs_dir%/security.log" + level: debug + channels: [ security ] + max_files: 30 when@test: monolog: @@ -57,7 +119,7 @@ when@prod: error_nested: type: rotating_file - path: "%kernel.logs_dir%/error.log" + path: "%kernel.logs_dir%/critical.log" level: debug max_files: 30 diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 31a8488..93c9a35 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -16,6 +16,7 @@ use App\Repository\UsersOrganizationsRepository; use App\Service\ActionService; use App\Service\AwsService; use App\Service\EmailService; +use App\Service\LoggerService; use App\Service\OrganizationsService; use App\Service\UserOrganizationAppService; use App\Service\UserOrganizationService; @@ -51,11 +52,8 @@ class UserController extends AbstractController private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $userManagementLogger, private readonly LoggerInterface $organizationManagementLogger, - private readonly LoggerInterface $accessControlLogger, - private readonly LoggerInterface $EmailNotificationLogger, - private readonly LoggerInterface $adminActionsLogger, private readonly LoggerInterface $errorLogger, - private readonly LoggerInterface $SecurityLogger, + private readonly LoggerService $loggerService, private readonly EmailService $emailService, private readonly AwsService $awsService, private readonly OrganizationsService $organizationsService, @@ -251,72 +249,130 @@ class UserController extends AbstractController public function new(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); + try { $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if ($this->userService->hasAccessTo($actingUser)) { - $user = new User(); - $form = $this->createForm(UserForm::class, $user); - $form->handleRequest($request); - $orgId = $request->get('organizationId'); - if ($orgId){ - $org = $this->organizationRepository->find($orgId) ?? throw new NotFoundHttpException(sprintf('%s not found', $orgId)); - } - if ($form->isSubmitted() && $form->isValid()) { - $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); - if ($existingUser && $orgId) { - $this->userService->handleExistingUser($existingUser, $org); - - $this->actionService->createAction("Create new user", $existingUser, $org, "Added user to organization" . $existingUser->getUserIdentifier() . " for organization " . $org->getName()); - $this->logger->notice("User added to organization " . $org->getName()); - $this->emailService->sendExistingUserNotificationEmail($existingUser, $org); - $this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier()); - $data = ['user' => $existingUser, 'organization' => $org]; - $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); - return $this->redirectToRoute('organization_show', ['id' => $orgId]); - } - - - // Handle file upload - $picture = $form->get('pictureUrl')->getData(); - $this->userService->formatNewUserData($user, $picture, true); - - if ($orgId) { - $uo = new UsersOrganizations(); - $uo->setUsers($user); - $uo->setOrganization($org); - $uo->setStatut("INVITED"); - $uo->setIsActive(false); - $uo->setModifiedAt(new \DateTimeImmutable('now')); - $this->entityManager->persist($uo); - $this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName()); - $this->logger->notice("User added to organization " . $org->getName()); - $this->emailService->sendPasswordSetupEmail($user, $orgId); - $this->logger->notice("Password setup email sent to " . $user->getUserIdentifier()); - $data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()]; - $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); - } - $this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier()); - $this->logger->notice("User created " . $user->getUserIdentifier()); - $this->entityManager->persist($user); - $this->entityManager->flush(); - - if ($orgId) { - return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]); - } - return $this->redirectToRoute('user_index'); - } + if (!$this->userService->hasAccessTo($actingUser)) { + throw $this->createAccessDeniedException(self::ACCESS_DENIED); } + + $user = new User(); + $form = $this->createForm(UserForm::class, $user); + $form->handleRequest($request); + + $orgId = $request->get('organizationId'); + $org = $orgId ? $this->organizationRepository->find($orgId) : null; + + if (!$org && $orgId) { + $this->loggerService->logCritical('Organization not found for user creation', [ + 'organization_id' => $orgId, + 'acting_user_id' => $actingUser->getId(), + 'ip' => $request->getClientIp(), + ]); + throw $this->createNotFoundException('Organization not found'); + } + + if ($form->isSubmitted() && $form->isValid()) { + $existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); + + // Case : User exists + has organization context + if ($existingUser && $org) { + $this->userService->addExistingUserToOrganization( + $existingUser, + $org, + $actingUser, + $request->getClientIp() + ); + + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $existingUser->getId(), + $org->getId(), + $actingUser->getId(), + $request->getClientIp(), + "Super Admin linked user to organization", + ); + } + + return $this->redirectToRoute('organization_show', ['id' => $orgId]); + } + + // Case : User exists but NO organization context → error + if ($existingUser) { + $this->loggerService->logError('Attempt to create user with existing email without organization', [ + 'target_user_email' => $user->getid(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $request->getClientIp(), + ]); + + $form->get('email')->addError( + new \Symfony\Component\Form\FormError( + 'This email is already in use. Add the user to an organization instead.' + ) + ); + + return $this->render('user/new.html.twig', [ + 'user' => $user, + 'form' => $form->createView(), + 'organizationId' => $orgId, + ]); + } + + $picture = $form->get('pictureUrl')->getData(); + $this->userService->createNewUser($user, $actingUser, $picture, $request->getClientIp()); + + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + $request->getClientIp(), + "Super Admin created new user", + + ); + } + + // Link to organization if provided + if ($org) { + $this->userService->linkUserToOrganization( + $user, + $org, + $actingUser, + $request->getClientIp() + ); + + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + $org->getId(), + $actingUser->getId(), + $request->getClientIp(), + "Super Admin linked user to organization", + ); + } + + return $this->redirectToRoute('organization_show', ['id' => $orgId]); + } + + return $this->redirectToRoute('user_index'); + } + return $this->render('user/new.html.twig', [ - 'user' => $user, - 'form' => $form->createView(), - 'organizationId' => $orgId + 'user' => $user, + 'form' => $form->createView(), + 'organizationId' => $orgId, ]); + } catch (\Exception $e) { - $this->logger->error($e->getMessage()); - if ($orgId) { + $this->loggerService->logError("Error on user creation route: " . $e->getMessage(), [ + 'ip' => $request->getClientIp(), + ]); + + if ($orgId ?? null) { return $this->redirectToRoute('organization_show', ['id' => $orgId]); } + return $this->redirectToRoute('user_index'); } } @@ -730,7 +786,8 @@ class UserController extends AbstractController $uo->setModifiedAt(new \DateTimeImmutable()); try { $data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()]; - $this->emailService->sendPasswordSetupEmail($user, $orgId); + $token = $this->userService->generatePasswordToken($user, $org->getId()); + $this->emailService->sendPasswordSetupEmail($user, $token); $this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName()); $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK); @@ -759,19 +816,20 @@ class UserController extends AbstractController throw $this->createNotFoundException('Invalid or expired invitation token.'); } $orgId = $this->userService->getOrgFromToken($token); - $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); - if (!$uo || $uo->getStatut() !== 'INVITED') { - $this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId); - throw $this->createNotFoundException('No pending invitation found for this user and organization.'); + if ($orgId) { + $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); + if (!$uo || $uo->getStatut() !== 'INVITED') { + $this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId); + throw $this->createNotFoundException('No pending invitation found for this user and organization.'); + } + $uo->setModifiedAt(new \DateTimeImmutable()); + $uo->setStatut("ACCEPTED"); + $uo->setIsActive(true); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + $this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId); } - $uo->setModifiedAt(new \DateTimeImmutable()); - $uo->setStatut("ACCEPTED"); - $uo->setIsActive(true); - $this->entityManager->persist($uo); - $this->entityManager->flush(); - $this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId); - - return $this->render('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]); + return $this->render('security/login.html.twig'); } } diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index d8c942e..61d750e 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -14,15 +14,12 @@ class EmailService { public function __construct( private readonly MailerInterface $mailer, - private readonly UserService $userService, private readonly LoggerInterface $logger, private UrlGeneratorInterface $urlGenerator ) {} - public function sendPasswordSetupEmail(User $user, int $orgId): void + public function sendPasswordSetupEmail(User $user, string $token): void { - $token = $this->userService->generatePasswordToken($user, $orgId); - // Generate absolute URL for the password setup route $link = $this->urlGenerator->generate( 'password_setup', @@ -52,9 +49,8 @@ class EmailService } } - public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org): void + public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org, $token): void { - $token = $this->userService->generatePasswordToken($existingUser, $org->getId()); $link = $this->urlGenerator->generate('user_accept',[ 'id' => $existingUser->getId(), 'token' => $token diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php new file mode 100644 index 0000000..016fa21 --- /dev/null +++ b/src/Service/LoggerService.php @@ -0,0 +1,142 @@ +userManagementLogger->notice("New user created: $userId", [ + 'target_user_id' => $userId, + 'acting_user_id' => $actingUserId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + public function logUserEdited(int $userId, int $actingUserId, ?int $orgId, ?string $ip): void + { + $this->userManagementLogger->notice('User information edited', [ + 'target_user_id' => $userId, + 'acting_user_id' => $actingUserId, + 'organization_id' => $orgId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + // Organization Management Logs + public function logUserOrganizationLinkCreated(int $userId, int $orgId, int $actingUserId, ?int $uoId, ?string $ip): void + { + $this->organizationManagementLogger->notice('User-Organization link created', [ + 'target_user_id' => $userId, + 'organization_id' => $orgId, + 'acting_user_id' => $actingUserId, + 'uo_id' => $uoId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + public function logExistingUserAddedToOrg(int $userId, int $orgId, int $actingUserId, int $uoId, ?string $ip): void + { + $this->organizationManagementLogger->notice('Existing user added to organization', [ + 'target_user_id' => $userId, + 'organization_id' => $orgId, + 'acting_user_id' => $actingUserId, + 'uo_id' => $uoId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + // Email Notification Logs + public function logPasswordSetupEmailSent(int $userId, ?int $orgId, ?string $ip): void + { + $this->emailNotificationLogger->notice("Password setup email sent to $userId", [ + 'target_user_id' => $userId, + 'organization_id' => $orgId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + public function logExistingUserNotificationSent(int $userId, int $orgId, ?string $ip): void + { + $this->emailNotificationLogger->notice("Existing user notification email sent to $userId", [ + 'target_user_id' => $userId, + 'organization_id' => $orgId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + public function logAdminNotified(int $adminUserId, int $targetUserId, int $orgId, int $actingUserId, ?string $ip): void + { + $this->emailNotificationLogger->notice('Organization admin notified', [ + 'admin_user_id' => $adminUserId, + 'target_user_id' => $targetUserId, + 'organization_id' => $orgId, + 'acting_user_id' => $actingUserId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + public function logSuperAdmin(int $userId, ?int $orgId, int $actingUserId, ?string $ip, string $message): void + { + $this->adminActionsLogger->notice($message, [ + 'target_user_id' => $userId, + 'organization_id' => $orgId, + 'acting_user_id' => $actingUserId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + // Error Logs + public function logError(string $message, array $context = []): void + { + $this->errorLogger->error($message, array_merge($context, [ + 'timestamp' => $this->now(), + ])); + } + + public function logCritical(string $message, array $context = []): void + { + $this->errorLogger->critical($message, array_merge($context, [ + 'timestamp' => $this->now(), + ])); + } + + // Security Logs + public function logAccessDenied(int $targetId, ?int $actingUserId, ?string $ip): void + { + $this->securityLogger->warning('Access denied', [ + 'target_id' => $targetId, + 'acting_user_id' => $actingUserId, + 'ip' => $ip, + 'timestamp' => $this->now(), + ]); + } + + // Helper + private function now(): string + { + return (new \DateTimeImmutable('now'))->format(DATE_ATOM); + } +} diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index ece09b3..9384809 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -6,6 +6,7 @@ use App\Entity\Apps; use App\Entity\Organizations; use App\Entity\Roles; use App\Entity\UserOrganizatonApp; +use App\Entity\UsersOrganizations; use App\Repository\UsersOrganizationsRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\File\Exception\FileException; @@ -62,7 +63,7 @@ class OrganizationsService } - public function notifyOrganizationAdmins(array $data, string $type): void + public function notifyOrganizationAdmins(array $data, string $type): UsersOrganizations { $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); @@ -130,6 +131,8 @@ class OrganizationsService } } + + return $adminUO; } } diff --git a/src/Service/UserService.php b/src/Service/UserService.php index ae5e908..228b8b2 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -27,7 +27,14 @@ class UserService public function __construct(private readonly EntityManagerInterface $entityManager, private readonly Security $security, - private readonly AwsService $awsService + private readonly AwsService $awsService, + private readonly LoggerService $loggerService, + private readonly ActionService $actionService, + private readonly EmailService $emailService, + private readonly OrganizationsService $organizationsService, + + + ) { @@ -330,7 +337,7 @@ class UserService return $formatted; } - public function generatePasswordToken(User $user, int $orgId): string + public function generatePasswordToken(User $user, int $orgId = null): string { $orgString = "o" . $orgId . "@"; $token = $orgString . bin2hex(random_bytes(32)); @@ -434,7 +441,7 @@ class UserService * @param Organizations $organization * @return void */ - public function handleExistingUser(User $user, Organizations $organization): void + public function handleExistingUser(User $user, Organizations $organization): int { if (!$user->isActive()) { $user->setIsActive(true); @@ -448,6 +455,8 @@ class UserService $uo->setModifiedAt(new \DateTimeImmutable('now')); $this->entityManager->persist($uo); $this->entityManager->flush(); + + return $uo->getId(); } /** @@ -479,4 +488,177 @@ class UserService $this->handleProfilePicture($user, $picture); } } + + /** + * Handle existing user being added to an organization + */ + public function addExistingUserToOrganization( + User $existingUser, + Organizations $org, + User $actingUser, + ?string $ip + ): int { + try { + $uoId = $this->handleExistingUser($existingUser, $org); + + $this->loggerService->logExistingUserAddedToOrg( + $existingUser->getId(), + $org->getId(), + $actingUser->getId(), + $uoId, + $ip + ); + + $this->actionService->createAction( + "Add existing user to organization", + $actingUser, + $org, + "Added user {$existingUser->getUserIdentifier()} to {$org->getName()}" + ); + + $this->sendExistingUserNotifications($existingUser, $org, $actingUser, $ip); + + return $uoId; + } catch (\Exception $e) { + $this->loggerService->logError('Error linking existing user to organization: ' . $e->getMessage(), [ + 'target_user_id' => $existingUser->getId(), + 'organization_id' => $org->getId(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + throw $e; + } + } + + /** + * Create a brand-new user + */ + public function createNewUser(User $user, User $actingUser, $picture, ?string $ip): void + { + try { + $this->formatNewUserData($user, $picture, true); + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $this->loggerService->logUserCreated($user->getId(), $actingUser->getId(), $ip); + $token = $this->generatePasswordToken($user); + $this->emailService->sendPasswordSetupEmail($user, $token); + $this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier()); + } catch (\Exception $e) { + $this->loggerService->logError('Error creating new user: ' . $e->getMessage(), [ + 'target_user_email' => $user->getEmail(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + throw $e; + } + } + + /** + * Link newly created user to an organization + */ + public function linkUserToOrganization( + User $user, + Organizations $org, + User $actingUser, + ?string $ip + ): UsersOrganizations { + try { + $uo = new UsersOrganizations(); + $uo->setUsers($user); + $uo->setOrganization($org); + $uo->setStatut("INVITED"); + $uo->setIsActive(false); + $uo->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + + $this->loggerService->logUserOrganizationLinkCreated( + $user->getId(), + $org->getId(), + $actingUser->getId(), + $uo->getId(), + $ip + ); + + $this->actionService->createAction( + "Link user to organization", + $actingUser, + $org, + "Added {$user->getUserIdentifier()} to {$org->getName()}" + ); + + $this->sendNewUserNotifications($user, $org, $actingUser, $ip); + + return $uo; + } catch (\Exception $e) { + $this->loggerService->logError('Error linking user to organization: ' . $e->getMessage(), [ + 'target_user_id' => $user->getId(), + 'organization_id' => $org->getId(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + throw $e; + } + } + + // Private helpers for email notifications + private function sendExistingUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void + { + try { + $token = $this->generatePasswordToken($user, $org->getId()); + $this->emailService->sendExistingUserNotificationEmail($user, $org, $token); + $this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId(), $ip); + } catch (\Exception $e) { + $this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [ + 'target_user_id' => $user->getId(), + 'organization_id' => $org->getId(), + 'ip' => $ip, + ]); + } + + $this->notifyOrgAdmins($user, $org, $actingUser, $ip); + } + + private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void + { + try { + $token = $this->generatePasswordToken($user, $org->getId()); + $this->emailService->sendPasswordSetupEmail($user, $token); + $this->loggerService->logPasswordSetupEmailSent($user->getId(), $org->getId(), $ip); + } catch (\Exception $e) { + $this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [ + 'target_user_id' => $user->getId(), + 'organization_id' => $org->getId(), + 'ip' => $ip, + ]); + } + + $this->notifyOrgAdmins($user, $org, $actingUser, $ip); + } + + private function notifyOrgAdmins(User $user, Organizations $org, User $actingUser, ?string $ip): void + { + try { + $data = ['user' => $user, 'organization' => $org]; + $adminsUos = $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); + + foreach ($adminsUos as $adminUo) { + $this->loggerService->logAdminNotified( + $adminUo->getUsers()->getId(), + $user->getId(), + $org->getId(), + $actingUser->getId(), + $ip + ); + } + } catch (\Exception $e) { + $this->loggerService->logError("Error notifying organization admins: " . $e->getMessage(), [ + 'target_user_id' => $user->getId(), + 'organization_id' => $org->getId(), + 'ip' => $ip, + ]); + } + } + } From 3c789dc68e6b0db9d711c2f5818aa0ad348eaa44 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 3 Dec 2025 14:33:28 +0100 Subject: [PATCH 02/43] refactor for monolog user activateStatus --- src/Controller/UserController.php | 264 +++++++++++++++++------------- src/Service/LoggerService.php | 2 +- 2 files changed, 153 insertions(+), 113 deletions(-) diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 93c9a35..e094d2b 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -53,7 +53,8 @@ class UserController extends AbstractController private readonly LoggerInterface $userManagementLogger, private readonly LoggerInterface $organizationManagementLogger, private readonly LoggerInterface $errorLogger, - private readonly LoggerService $loggerService, + private readonly LoggerInterface $securityLogger, + private readonly LoggerService $loggerService, private readonly EmailService $emailService, private readonly AwsService $awsService, private readonly OrganizationsService $organizationsService, @@ -169,29 +170,22 @@ class UserController extends AbstractController public function edit(int $id, Request $request): Response { $this->denyAccessUnlessGranted('ROLE_USER'); - try{ + try { $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if ($this->userService->hasAccessTo($actingUser)) { $user = $this->userRepository->find($id); if (!$user) { $this->userManagementLogger->notice('User not found for edit', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + 'target_user_id' => $user->getId(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $request->getClientIp(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), ]); throw $this->createNotFoundException(self::NOT_FOUND); } $form = $this->createForm(UserForm::class, $user); $form->handleRequest($request); - $this->userManagementLogger->notice('Format test', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); - if ($form->isSubmitted() && $form->isValid()) { // Handle user edit @@ -204,21 +198,21 @@ class UserController extends AbstractController //log and action $this->userManagementLogger->notice('User information edited', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'organization_id' => $request->get('organizationId'), - 'ip' => $request->getClientIp(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + 'target_user_id' => $user->getId(), + 'acting_user_id' => $actingUser->getId(), + 'organization_id' => $request->get('organizationId'), + 'ip' => $request->getClientIp(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), ]); if ($request->get('organizationId')) { $org = $this->organizationRepository->find($request->get('organizationId')); if ($org) { $this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier()); $this->organizationManagementLogger->info('User edited within organization context', [ - 'target_user_id' => $user->getId(), + 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), - 'acting_user' => $actingUser->getUserIdentifier(), - 'ip' => $request->getClientIp(), + 'acting_user' => $actingUser->getUserIdentifier(), + 'ip' => $request->getClientIp(), ]); return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $request->get('organizationId')]); } @@ -234,13 +228,13 @@ class UserController extends AbstractController 'organizationId' => $request->get('organizationId') ]); } - }catch (\Exception $e){ + } catch (\Exception $e) { $this->errorLogger->critical($e->getMessage()); } - $this->SecurityLogger->warning('Access denied on user edit', [ + $this->securityLogger->warning('Access denied on user edit', [ 'target_user_id' => $id, - 'acting_user' => $actingUser?->getId(), - 'ip' => $request->getClientIp(), + 'acting_user' => $actingUser?->getId(), + 'ip' => $request->getClientIp(), ]); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } @@ -267,8 +261,8 @@ class UserController extends AbstractController if (!$org && $orgId) { $this->loggerService->logCritical('Organization not found for user creation', [ 'organization_id' => $orgId, - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $request->getClientIp(), ]); throw $this->createNotFoundException('Organization not found'); } @@ -302,8 +296,8 @@ class UserController extends AbstractController if ($existingUser) { $this->loggerService->logError('Attempt to create user with existing email without organization', [ 'target_user_email' => $user->getid(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $request->getClientIp(), ]); $form->get('email')->addError( @@ -313,8 +307,8 @@ class UserController extends AbstractController ); return $this->render('user/new.html.twig', [ - 'user' => $user, - 'form' => $form->createView(), + 'user' => $user, + 'form' => $form->createView(), 'organizationId' => $orgId, ]); } @@ -359,8 +353,8 @@ class UserController extends AbstractController } return $this->render('user/new.html.twig', [ - 'user' => $user, - 'form' => $form->createView(), + 'user' => $user, + 'form' => $form->createView(), 'organizationId' => $orgId, ]); @@ -382,90 +376,136 @@ class UserController extends AbstractController public function activeStatus(int $id, Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_ADMIN'); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - try{ - if ($this->userService->hasAccessTo($actingUser, true)) { - $user = $this->userRepository->find($id); - if (!$user) { - throw $this->createNotFoundException(self::NOT_FOUND); - } - $status = $request->get('status'); - if ($status === 'deactivate') { - $user->setIsActive(false); - $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); - if ($this->userService->isUserConnected($user->getUserIdentifier())) { - $this->userService->revokeUserTokens($user->getUserIdentifier()); - } - $user->setModifiedAt(new \DateTimeImmutable('now')); - $this->entityManager->persist($user); - $this->entityManager->flush(); - $this->logger->notice("User deactivated " . $user->getUserIdentifier()); - $this->actionService->createAction("Deactivate user", $actingUser, null, $user->getUserIdentifier()); - return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); - } - if ($status === 'activate') { - $user->setIsActive(true); - $user->setModifiedAt(new \DateTimeImmutable('now')); - $this->logger->notice("User activated " . $user->getUserIdentifier()); - $this->actionService->createAction("Activate user", $actingUser, null, $user->getUserIdentifier()); - return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); - } - } - }catch (\Exception $e){ - $this->logger->error($e->getMessage()); - } - throw $this->createNotFoundException(self::NOT_FOUND); - } - - #[Route('/organization/activateStatus/{id}', name: 'activate_organization', methods: ['GET', 'POST'])] - public function activateStatusOrganization(int $id, Request $request): JsonResponse{ - $this->denyAccessUnlessGranted('ROLE_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $ip = $request->getClientIp(); + $status = $request->get('status'); + try { - if ($this->userService->hasAccessTo($actingUser, true)) { - $orgId = $request->get('organizationId'); - $org = $this->organizationRepository->find($orgId); - if (!$org) { - throw $this->createNotFoundException(self::NOT_FOUND); - } - $user = $this->userRepository->find($id); - if (!$user) { - throw $this->createNotFoundException(self::NOT_FOUND); - } - $uo = $this->uoRepository->findOneBy(['users' => $user, - 'organization' => $org]); - if (!$uo) { - throw $this->createNotFoundException(self::NOT_FOUND); - } - $status = $request->get('status'); - if ($status === 'deactivate') { - $uo->setIsActive(false); - $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); - $this->entityManager->persist($uo); - $this->entityManager->flush(); - $data = ['user' => $user, - 'organization' => $org]; - $this->organizationsService->notifyOrganizationAdmins($data, "USER_DEACTIVATED"); - $this->logger->notice("User Organizaton deactivated " . $user->getUserIdentifier()); - $this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier()); - return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); - } - if($status === "activate"){ - $uo->setIsActive(true); - $this->entityManager->persist($uo); - $this->entityManager->flush(); - $this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier()); - $data = ['user' => $user, - 'organization' => $org]; - $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACTIVATED"); - return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); - } + // Access control + if (!$this->userService->hasAccessTo($actingUser, true)) { + $this->securityLogger->warning('Access denied on user status change', [ + 'target_user_id' => $id, + 'acting_user_id' => $actingUser?->getId(), + 'acting_identifier' => $actingUser?->getUserIdentifier(), + 'requested_status' => $status, + 'ip' => $ip, + ]); + + throw $this->createAccessDeniedException(self::ACCESS_DENIED); } - }catch (\Exception $exception){ - $this->logger->error($exception->getMessage()); + + // Load target user + $user = $this->userRepository->find($id); + if (!$user) { + $this->securityLogger->warning('User not found for status change', [ + 'target_user_id' => $id, + 'acting_user_id' => $actingUser->getId(), + 'requested_status' => $status, + 'ip' => $ip, + ]); + + throw $this->createNotFoundException(self::NOT_FOUND); + } + + // Deactivate + if ($status === 'deactivate') { + $user->setIsActive(false); + + $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); + + if ($this->userService->isUserConnected($user->getUserIdentifier())) { + $this->userService->revokeUserTokens($user->getUserIdentifier()); + + $this->securityLogger->info('User tokens revoked due to deactivation', [ + 'target_user_id' => $user->getId(), + 'target_identifier' => $user->getUserIdentifier(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + } + + $user->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->flush(); + + $this->userManagementLogger->notice('User deactivated', [ + 'target_user_id' => $user->getId(), + 'target_identifier' => $user->getUserIdentifier(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + $ip, + 'Super admin deactivated user' + ); + } + + $this->actionService->createAction('Deactivate user', $actingUser, null, $user->getUserIdentifier()); + + return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); + } + + // Activate + if ($status === 'activate') { + $user->setIsActive(true); + $user->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->flush(); + + $this->userManagementLogger->notice('User activated', [ + 'target_user_id' => $user->getId(), + 'target_identifier' => $user->getUserIdentifier(), + 'acting_user_id' => $actingUser->getId(), + 'ip' => $ip, + ]); + + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + $ip, + 'Super admin activated user' + ); + } + + $this->actionService->createAction('Activate user', $actingUser, null, $user->getUserIdentifier()); + + return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); + } + + // Invalid status + $this->loggerService->warning('Invalid status passed to activeStatus', [ + 'target_user_id' => $user->getId(), + 'acting_user_id' => $actingUser->getId(), + 'requested_status' => $status, + 'ip' => $ip, + ]); + + return new JsonResponse(['error' => 'Invalid status'], Response::HTTP_BAD_REQUEST); + + } catch (\Throwable $e) { + // Application-level error logging → error.log (via error channel) + $this->errorLogger->error('Error in activeStatus', [ + 'exception_message' => $e->getMessage(), + 'exception_class' => get_class($e), + 'target_user_id' => $id, + 'acting_user_id' => $actingUser?->getId(), + 'requested_status' => $status, + 'ip' => $ip, + ]); + + // Preserve 403/404 semantics, 500 for everything else + if ($e instanceof NotFoundHttpException || $e instanceof AccessDeniedException) { + throw $e; + } + + return new JsonResponse(['error' => 'An error occurred'], Response::HTTP_INTERNAL_SERVER_ERROR); } - throw $this->createNotFoundException(self::NOT_FOUND); } //TODO : MONOLOG + remove picture from bucket diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index 016fa21..433ea71 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -97,7 +97,7 @@ readonly class LoggerService ]); } - public function logSuperAdmin(int $userId, ?int $orgId, int $actingUserId, ?string $ip, string $message): void + public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, ?string $ip, string $message): void { $this->adminActionsLogger->notice($message, [ 'target_user_id' => $userId, From 659eb08d6efc04e53bdb4e58128393b0e2bc4773 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 3 Dec 2025 14:38:03 +0100 Subject: [PATCH 03/43] refactor for monolog user activateStatus --- src/Controller/UserController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index e094d2b..40f52c4 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -387,7 +387,7 @@ class UserController extends AbstractController $this->securityLogger->warning('Access denied on user status change', [ 'target_user_id' => $id, 'acting_user_id' => $actingUser?->getId(), - 'acting_identifier' => $actingUser?->getUserIdentifier(), + 'acting_identifier' => $actingUser?->getId(), 'requested_status' => $status, 'ip' => $ip, ]); @@ -419,7 +419,7 @@ class UserController extends AbstractController $this->securityLogger->info('User tokens revoked due to deactivation', [ 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getUserIdentifier(), + 'target_identifier' => $user->getId(), 'acting_user_id' => $actingUser->getId(), 'ip' => $ip, ]); @@ -430,7 +430,7 @@ class UserController extends AbstractController $this->userManagementLogger->notice('User deactivated', [ 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getUserIdentifier(), + 'target_identifier' => $user->getId(), 'acting_user_id' => $actingUser->getId(), 'ip' => $ip, ]); @@ -458,7 +458,7 @@ class UserController extends AbstractController $this->userManagementLogger->notice('User activated', [ 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getUserIdentifier(), + 'target_identifier' => $user->getId(), 'acting_user_id' => $actingUser->getId(), 'ip' => $ip, ]); @@ -479,7 +479,7 @@ class UserController extends AbstractController } // Invalid status - $this->loggerService->warning('Invalid status passed to activeStatus', [ + $this->errorLogger->warning('Invalid status passed to activeStatus', [ 'target_user_id' => $user->getId(), 'acting_user_id' => $actingUser->getId(), 'requested_status' => $status, From 5f4336d824fa2380352af0ce7f84e44fe435aece Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 8 Dec 2025 16:27:55 +0100 Subject: [PATCH 04/43] Refactor for monolog in user controller --- src/Controller/SecurityController.php | 7 +- src/Controller/UserController.php | 396 +++++++++++++-------- src/Service/EmailService.php | 23 +- src/Service/LoggerService.php | 157 ++++++-- src/Service/OrganizationsService.php | 54 ++- src/Service/UserOrganizationAppService.php | 38 +- src/Service/UserOrganizationService.php | 13 +- src/Service/UserService.php | 90 +++-- 8 files changed, 542 insertions(+), 236 deletions(-) diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index f51f040..9ea51a6 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -98,7 +98,7 @@ class SecurityController extends AbstractController } #[Route('/password_reset/{id}', name: 'password_reset', methods: ['POST'])] - public function password_reset(int $id): Response + public function password_reset(int $id, Request $request): Response { $user = $this->userRepository->find($id); if (!$user) { @@ -127,7 +127,10 @@ class SecurityController extends AbstractController $uo->setIsActive(true); $this->entityManager->persist($uo); $this->entityManager->flush(); - $data = ['user' => $user, 'organization' => $uo->getOrganization()]; + $data = ['user' => $user, + 'organization' => $uo->getOrganization(), + 'ip' => $request->getClientIp(), + ]; $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED"); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 40f52c4..a7e6e03 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -22,18 +22,14 @@ use App\Service\UserOrganizationAppService; use App\Service\UserOrganizationService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; -use mysql_xdevapi\Exception; use Psr\Log\LoggerInterface; -use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mailer\MailerInterface; -use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; #[Route(path: '/user', name: 'user_')] class UserController extends AbstractController @@ -76,6 +72,7 @@ class UserController extends AbstractController // Vérification des droits d'accès supplémentaires if (!$this->userService->hasAccessTo($actingUser)) { + $this->loggerService->logAccessDenied($actingUser->getId()); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } @@ -104,6 +101,10 @@ class UserController extends AbstractController ]); if (!$uoList) { + $this->loggerService->logEntityNotFound('UsersOrganization', [ + 'user_id' => $user->getId(), + 'organization_id' => $orgId], + $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -117,6 +118,13 @@ class UserController extends AbstractController 'users' => $user, 'isActive' => true, ]); + if (!$uoList) { + $this->loggerService->logEntityNotFound('UsersOrganization', [ + 'user_id' => $user->getId(), + 'organization_id' => $orgId], + $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } } // Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées @@ -127,6 +135,13 @@ class UserController extends AbstractController 'userOrganization' => $uoList, 'isActive' => true, ]); + if (!$uoa) { + $this->loggerService->logEntityNotFound('UsersOrganizationApplication', [ + 'user_id' => $user->getId(), + 'organization_id' => $orgId], + $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } // Group UOA by app and ensure every app has a group $data['uoas'] = $this->userOrganizationAppService @@ -171,17 +186,11 @@ class UserController extends AbstractController { $this->denyAccessUnlessGranted('ROLE_USER'); try { - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if ($this->userService->hasAccessTo($actingUser)) { $user = $this->userRepository->find($id); if (!$user) { - $this->userManagementLogger->notice('User not found for edit', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $form = $this->createForm(UserForm::class, $user); @@ -190,64 +199,70 @@ class UserController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { // Handle user edit $picture = $form->get('pictureUrl')->getData(); - $this->userService->formatNewUserData($user, $picture); + $this->userService->formatUserData($user, $picture); $user->setModifiedAt(new \DateTimeImmutable('now')); $this->entityManager->persist($user); $this->entityManager->flush(); //log and action - $this->userManagementLogger->notice('User information edited', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'organization_id' => $request->get('organizationId'), - 'ip' => $request->getClientIp(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); - if ($request->get('organizationId')) { - $org = $this->organizationRepository->find($request->get('organizationId')); + $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User information edited'); + $orgId = $request->get('organizationId'); + if ($orgId) { + $org = $this->organizationRepository->find($orgId); if ($org) { $this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier()); - $this->organizationManagementLogger->info('User edited within organization context', [ - 'target_user_id' => $user->getId(), - 'organization_id' => $org->getId(), - 'acting_user' => $actingUser->getUserIdentifier(), - 'ip' => $request->getClientIp(), - ]); - return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $request->get('organizationId')]); + $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User information edited'); + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + "Super Admin accessed user edit page", + ); + } + return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $orgId]); } - } else { - $this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier()); - return $this->redirectToRoute('user_show', ['id' => $user->getId()]); + $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); } + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + "Super Admin accessed user edit page", + ); + } + $this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier()); + return $this->redirectToRoute('user_show', ['id' => $user->getId()]); } + return $this->render('user/edit.html.twig', [ 'user' => $user, 'form' => $form->createView(), 'organizationId' => $request->get('organizationId') ]); } + $this->loggerService->logAccessDenied($actingUser->getId()); + throw $this->createAccessDeniedException(self::ACCESS_DENIED); } catch (\Exception $e) { $this->errorLogger->critical($e->getMessage()); } - $this->securityLogger->warning('Access denied on user edit', [ - 'target_user_id' => $id, - 'acting_user' => $actingUser?->getId(), - 'ip' => $request->getClientIp(), - ]); + // Default deny access. shouldn't reach here normally. throw $this->createAccessDeniedException(self::ACCESS_DENIED); + } #[Route('/new', name: 'new', methods: ['GET', 'POST'])] public function new(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); - try { $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if (!$this->userService->hasAccessTo($actingUser)) { + $this->loggerService->logAccessDenied($actingUser->getId()); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } @@ -256,15 +271,12 @@ class UserController extends AbstractController $form->handleRequest($request); $orgId = $request->get('organizationId'); - $org = $orgId ? $this->organizationRepository->find($orgId) : null; - - if (!$org && $orgId) { - $this->loggerService->logCritical('Organization not found for user creation', [ - 'organization_id' => $orgId, - 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), - ]); - throw $this->createNotFoundException('Organization not found'); + if ($orgId) { + $org = $this->organizationRepository->find($orgId); + if (!$org) { + $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } } if ($form->isSubmitted() && $form->isValid()) { @@ -276,7 +288,6 @@ class UserController extends AbstractController $existingUser, $org, $actingUser, - $request->getClientIp() ); if ($this->isGranted('ROLE_SUPER_ADMIN')) { @@ -284,20 +295,17 @@ class UserController extends AbstractController $existingUser->getId(), $org->getId(), $actingUser->getId(), - $request->getClientIp(), "Super Admin linked user to organization", ); } - return $this->redirectToRoute('organization_show', ['id' => $orgId]); } - // Case : User exists but NO organization context → error + // Case : User exists but NO organization context -> throw error on email field. if ($existingUser) { $this->loggerService->logError('Attempt to create user with existing email without organization', [ 'target_user_email' => $user->getid(), 'acting_user_id' => $actingUser->getId(), - 'ip' => $request->getClientIp(), ]); $form->get('email')->addError( @@ -314,26 +322,24 @@ class UserController extends AbstractController } $picture = $form->get('pictureUrl')->getData(); - $this->userService->createNewUser($user, $actingUser, $picture, $request->getClientIp()); + $this->userService->createNewUser($user, $actingUser, $picture); if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), null, $actingUser->getId(), - $request->getClientIp(), "Super Admin created new user", ); } - // Link to organization if provided + // Case : Organization provided and user doesn't already exist if ($org) { $this->userService->linkUserToOrganization( $user, $org, $actingUser, - $request->getClientIp() ); if ($this->isGranted('ROLE_SUPER_ADMIN')) { @@ -341,8 +347,7 @@ class UserController extends AbstractController $user->getId(), $org->getId(), $actingUser->getId(), - $request->getClientIp(), - "Super Admin linked user to organization", + "Super Admin linked user to organization during creation", ); } @@ -359,11 +364,9 @@ class UserController extends AbstractController ]); } catch (\Exception $e) { - $this->loggerService->logError("Error on user creation route: " . $e->getMessage(), [ - 'ip' => $request->getClientIp(), - ]); + $this->errorLogger->critical($e->getMessage()); - if ($orgId ?? null) { + if ($orgId) { return $this->redirectToRoute('organization_show', ['id' => $orgId]); } @@ -378,32 +381,19 @@ class UserController extends AbstractController $this->denyAccessUnlessGranted('ROLE_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $ip = $request->getClientIp(); $status = $request->get('status'); try { // Access control if (!$this->userService->hasAccessTo($actingUser, true)) { - $this->securityLogger->warning('Access denied on user status change', [ - 'target_user_id' => $id, - 'acting_user_id' => $actingUser?->getId(), - 'acting_identifier' => $actingUser?->getId(), - 'requested_status' => $status, - 'ip' => $ip, - ]); - + $this->loggerService->logAccessDenied($actingUser->getId()); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } // Load target user $user = $this->userRepository->find($id); if (!$user) { - $this->securityLogger->warning('User not found for status change', [ - 'target_user_id' => $id, - 'acting_user_id' => $actingUser->getId(), - 'requested_status' => $status, - 'ip' => $ip, - ]); + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -416,31 +406,18 @@ class UserController extends AbstractController if ($this->userService->isUserConnected($user->getUserIdentifier())) { $this->userService->revokeUserTokens($user->getUserIdentifier()); - - $this->securityLogger->info('User tokens revoked due to deactivation', [ - 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, - ]); } $user->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->persist($user); $this->entityManager->flush(); - - $this->userManagementLogger->notice('User deactivated', [ - 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, - ]); + $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User deactivated'); if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), null, $actingUser->getId(), - $ip, 'Super admin deactivated user' ); } @@ -454,21 +431,16 @@ class UserController extends AbstractController if ($status === 'activate') { $user->setIsActive(true); $user->setModifiedAt(new \DateTimeImmutable('now')); + $this->entityManager->persist($user); $this->entityManager->flush(); + $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User activated'); - $this->userManagementLogger->notice('User activated', [ - 'target_user_id' => $user->getId(), - 'target_identifier' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, - ]); if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), null, $actingUser->getId(), - $ip, 'Super admin activated user' ); } @@ -479,25 +451,16 @@ class UserController extends AbstractController } // Invalid status - $this->errorLogger->warning('Invalid status passed to activeStatus', [ - 'target_user_id' => $user->getId(), - 'acting_user_id' => $actingUser->getId(), + $this->loggerService->logError('Invalid status provided for activeStatus', [ 'requested_status' => $status, - 'ip' => $ip, + 'target_user_id' => $id, ]); return new JsonResponse(['error' => 'Invalid status'], Response::HTTP_BAD_REQUEST); } catch (\Throwable $e) { // Application-level error logging → error.log (via error channel) - $this->errorLogger->error('Error in activeStatus', [ - 'exception_message' => $e->getMessage(), - 'exception_class' => get_class($e), - 'target_user_id' => $id, - 'acting_user_id' => $actingUser?->getId(), - 'requested_status' => $status, - 'ip' => $ip, - ]); + $this->errorLogger->critical($e->getMessage()); // Preserve 403/404 semantics, 500 for everything else if ($e instanceof NotFoundHttpException || $e instanceof AccessDeniedException) { @@ -508,34 +471,152 @@ class UserController extends AbstractController } } -//TODO : MONOLOG + remove picture from bucket + #[Route('/organization/activateStatus/{id}', name: 'activate_organization', methods: ['GET', 'POST'])] + public function activateStatusOrganization(int $id, Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + try { + if ($this->userService->hasAccessTo($actingUser, true)) { + $orgId = $request->get('organizationId'); + $org = $this->organizationRepository->find($orgId); + if (!$org) { + $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } + $user = $this->userRepository->find($id); + if (!$user) { + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } + $uo = $this->uoRepository->findOneBy(['users' => $user, + 'organization' => $org]); + if (!$uo) { + $this->loggerService->logEntityNotFound('UsersOrganization', ['user_id' => $user->getId(), + 'organization_id' => $org->getId()], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } + $status = $request->get('status'); + if ($status === 'deactivate') { + $uo->setIsActive(false); + $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + $data = ['user' => $user, + 'organization' => $org]; + $this->organizationsService->notifyOrganizationAdmins($data, "USER_DEACTIVATED"); + $this->loggerService->logOrganizationInformation($org->getId(), $actingUser->getId(), "UO link deactivated with uo id : {$uo->getId()}"); + $this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier()); + return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); + } + if ($status === "activate") { + $uo->setIsActive(true); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + $this->loggerService->logOrganizationInformation($orgId, $actingUser->getId(), "UO link activated with uo id : {$uo->getId()}"); + $this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier()); + $data = ['user' => $user, + 'organization' => $org]; + $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACTIVATED"); + return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); + } + //invalid status + $this->loggerService->logError('Invalid status provided for activateStatusOrganization', [ + 'requested_status' => $status, + 'target_user_id' => $id, + 'organization_id' => $orgId, + ]); + throw $this->createNotFoundException(self::NOT_FOUND); + } + } catch (\Exception $exception) { + $this->loggerService->logCritical($exception->getMessage()); + } + throw $this->createNotFoundException(self::NOT_FOUND); + } + +//TODO :remove picture from bucket #[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])] public function delete(int $id, Request $request): Response { - $this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN"); - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $user = $this->userRepository->find($id); - if (!$user) { - throw $this->createNotFoundException(self::NOT_FOUND); - } - $user->setIsActive(false); - $user->setModifiedAt(new \DateTimeImmutable('now')); - $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); - $user->setIsDeleted(true); - if ($this->userService->isUserConnected($user)) { - $this->userService->revokeUserTokens($user->getUserIdentifier()); - } - $this->entityManager->persist($user); - $this->entityManager->flush(); - $this->actionService->createAction("Delete user", $actingUser, null, $user->getUserIdentifier()); - $data = ['user' => $user, - 'organization' => null]; - $this->organizationsService->notifyOrganizationAdmins($data, "USER_DELETED"); + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - return new Response('', Response::HTTP_NO_CONTENT); //204 + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + + try { + $user = $this->userRepository->find($id); + + if (!$user) { + // Security/audit log for missing user + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + + throw $this->createNotFoundException(self::NOT_FOUND); + } + + // Soft delete the user + $user->setIsActive(false); + $user->setIsDeleted(true); + $user->setModifiedAt(new \DateTimeImmutable('now')); + + // Deactivate all org links + $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); + $this->loggerService->logOrganizationInformation($user->getId(), $actingUser->getId(), 'All user organization links deactivated'); + + // Revoke tokens if connected + if ($this->userService->isUserConnected($user->getUserIdentifier())) { + $this->userService->revokeUserTokens($user->getUserIdentifier()); + } + + $this->entityManager->flush(); + + // User management log + $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User deleted'); + + // Super admin log (standardized style) + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logSuperAdmin( + $user->getId(), + null, + $actingUser->getId(), + 'Super admin deleted user' + ); + } + + $this->actionService->createAction('Delete user', $actingUser, null, $user->getUserIdentifier()); + + // Notify organization admins (user may belong to multiple organizations) + try { + $data = [ + 'user' => $user, + 'organization' => null, + ]; + $this->organizationsService->notifyOrganizationAdmins($data, 'USER_DELETED'); + + + } catch (\Throwable $e) { + $this->loggerService->logCritical($e->getMessage(), [ + 'target_user_id' => $id, + 'acting_user_id' => $actingUser?->getId(), + ]); + // No rethrow here: deletion succeeded; only notifications failed + } + + return new Response('', Response::HTTP_NO_CONTENT); // 204 + + } catch (\Exception $e) { + // Route-level error logging → error.log + $this->loggerService->logCritical('error while deleting user', [ + 'target_user_id' => $id, + 'acting_user_id' => $actingUser?->getId(), + 'error' => $e->getMessage(), + ]); + if ($e instanceof NotFoundHttpException) { + throw $e; // keep 404 semantics + } + + return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR); + } } - //TODO : MONOLOG #[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])] public function applicationRole(int $id, Request $request): Response { @@ -543,19 +624,26 @@ class UserController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if ($this->userService->hasAccessTo($actingUser, true)) { - $uo = $this->userOrganizationService->getByIdOrFail($id); + $uo = $this->entityManager->getRepository(UsersOrganizations::class)->find($id); + if (!$uo) { + $this->loggerService->logEntityNotFound('UsersOrganization', ['id' => $id], $actingUser->getId()); + throw new NotFoundHttpException("UserOrganization not found"); + } $application = $this->entityManager->getRepository(Apps::class)->find($request->get('appId')); if (!$application) { + $this->loggerService->logEntityNotFound('Application', ['id' => $request->get('appId')], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $selectedRolesIds = $request->get('roles', []); $roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']); if (!$roleUser) { - throw $this->createNotFoundException('Default role not found'); + $this->loggerService->logEntityNotFound('Role', ['name' => 'USER'], $actingUser->getId()); + throw $this->createNotFoundException('User role not found'); } if (!empty($selectedRolesIds)) { + // Si le role User n'est pas sélectionné, on désactive tous les liens (affiché comme 'accès' dans l'UI) if (!in_array((string)$roleUser->getId(), $selectedRolesIds, true)) { $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo, $application); } else { @@ -651,6 +739,7 @@ class UserController extends AbstractController #[Route(path: '/', name: 'index', methods: ['GET'])] public function index(): Response { + $this->isGranted('ROLE_SUPER_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) { $totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]); @@ -658,6 +747,9 @@ class UserController extends AbstractController 'users' => $totalUsers ]); } + + //shouldn't be reached normally + $this->loggerService->logAccessDenied($actingUser->getId()); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } @@ -811,16 +903,21 @@ class UserController extends AbstractController $orgId = $request->get('organizationId'); $org = $this->organizationRepository->find($orgId); if (!$org) { + $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $user = $this->userRepository->find($userId); if (!$user) { + $this->loggerService->logEntityNotFound('User', ['id' => $user->getId()], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $org, 'statut' => "INVITED"]); if (!$uo) { + $this->loggerService->logEntityNotFound('UsersOrganization', [ + 'user_id' => $user->getId(), + 'organization_id' => $orgId], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $uo->setModifiedAt(new \DateTimeImmutable()); @@ -832,7 +929,12 @@ class UserController extends AbstractController $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK); } catch (\Exception $e) { - $this->logger->error("Error resending invitation email to user " . $user->getUserIdentifier() . " for organization " . $org->getName() . ": " . $e->getMessage()); + $this->loggerService->logCritical('Error while resending invitation', [ + 'target_user_id' => $user->getId(), + 'organization_id' => $orgId, + 'acting_user_id' => $actingUser->getId(), + 'error' => $e->getMessage(), + ]); return $this->json(['message' => 'Erreur lors de l\'envoie du mail.'], Response::HTTP_INTERNAL_SERVER_ERROR); } } @@ -846,20 +948,35 @@ class UserController extends AbstractController $userId = $request->get('id'); if (!$token || !$userId) { + $this->loggerService->logEntityNotFound('Token or UserId missing in accept invitation', [ + 'token' => $token, + 'user_id' => $userId + ], + null); throw $this->createNotFoundException('Invalid invitation link.'); } $user = $this->userRepository->find($userId); if (!$user) { + $this->loggerService->logEntityNotFound('User not found in accept invitation', [ + 'user_id' => $userId + ],null); throw $this->createNotFoundException(self::NOT_FOUND); } if (!$this->userService->isPasswordTokenValid($user, $token)) { + $this->loggerService->logError('Token or UserId mismatch in accept invitation', [ + 'token' => $token, + 'user_id' => $userId + ]); throw $this->createNotFoundException('Invalid or expired invitation token.'); } $orgId = $this->userService->getOrgFromToken($token); if ($orgId) { $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); if (!$uo || $uo->getStatut() !== 'INVITED') { - $this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId); + $this->loggerService->logEntityNotFound('UsersOrganization not found or not in INVITED status in accept invitation', [ + 'user_id' => $user->getId(), + 'organization_id' => $orgId + ], null); throw $this->createNotFoundException('No pending invitation found for this user and organization.'); } $uo->setModifiedAt(new \DateTimeImmutable()); @@ -867,7 +984,8 @@ class UserController extends AbstractController $uo->setIsActive(true); $this->entityManager->persist($uo); $this->entityManager->flush(); - $this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId); + $this->loggerService->logUserAction($user->getId(), null, "User accepted invitation for organization id : {$orgId}"); + $this->loggerService->logOrganizationInformation($orgId, $user->getId(), "User accepted invitation with uo id : {$uo->getId()}"); } return $this->render('security/login.html.twig'); } diff --git a/src/Service/EmailService.php b/src/Service/EmailService.php index 61d750e..f4f1b3e 100644 --- a/src/Service/EmailService.php +++ b/src/Service/EmailService.php @@ -4,6 +4,7 @@ namespace App\Service; use App\Entity\Organizations; use App\Entity\User; +use App\Service\LoggerService; use Psr\Log\LoggerInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; @@ -15,7 +16,7 @@ class EmailService public function __construct( private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, - private UrlGeneratorInterface $urlGenerator + private UrlGeneratorInterface $urlGenerator, private readonly LoggerService $loggerService ) {} public function sendPasswordSetupEmail(User $user, string $token): void @@ -43,7 +44,9 @@ class EmailService ]); try { + $orgId = $this->getOrgFromToken($token); $this->mailer->send($email); + $this->loggerService->logEmailSent($user->getId(), $orgId, 'Password setup email sent.'); } catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) { $this->logger->error('Failed to send password setup email: ' . $e->getMessage()); } @@ -69,10 +72,26 @@ class EmailService ]); try{ + $orgId = $org->getId(); + $this->loggerService->logEmailSent($existingUser->getId(), $orgId, 'Existing user notification email sent.'); $this->mailer->send($email); } catch (TransportExceptionInterface $e) { $this->logger->error('Failed to send existing user notification email: ' . $e->getMessage()); } } -} \ No newline at end of file + + private function getOrgFromToken(string $token): ?int + { + if (str_starts_with($token, 'o')) { + $parts = explode('@', $token); + if (count($parts) === 2) { + $orgPart = substr($parts[0], 1); // Remove the leading 'o' + if (is_numeric($orgPart)) { + return (int)$orgPart; + } + } + } + return null; + } +} diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index 433ea71..30ed48b 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -4,106 +4,99 @@ namespace App\Service; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; readonly class LoggerService { public function __construct( - private LoggerInterface $userManagementLogger, - private LoggerInterface $organizationManagementLogger, - private LoggerInterface $accessControlLogger, - private LoggerInterface $emailNotificationLogger, - private LoggerInterface $adminActionsLogger, - private LoggerInterface $securityLogger, - private LoggerInterface $errorLogger, + private LoggerInterface $userManagementLogger, + private LoggerInterface $organizationManagementLogger, + private LoggerInterface $accessControlLogger, + private LoggerInterface $emailNotificationLogger, + private LoggerInterface $adminActionsLogger, + private LoggerInterface $securityLogger, + private LoggerInterface $errorLogger, + private RequestStack $requestStack, ) {} + + // User Management Logs - public function logUserCreated(int $userId, int $actingUserId, ?string $ip): void + public function logUserCreated(int $userId, int $actingUserId): void { $this->userManagementLogger->notice("New user created: $userId", [ 'target_user_id' => $userId, 'acting_user_id' => $actingUserId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } - public function logUserEdited(int $userId, int $actingUserId, ?int $orgId, ?string $ip): void - { - $this->userManagementLogger->notice('User information edited', [ - 'target_user_id' => $userId, - 'acting_user_id' => $actingUserId, - 'organization_id' => $orgId, - 'ip' => $ip, - 'timestamp' => $this->now(), - ]); - } - // Organization Management Logs - public function logUserOrganizationLinkCreated(int $userId, int $orgId, int $actingUserId, ?int $uoId, ?string $ip): void + public function logUserOrganizationLinkCreated(int $userId, int $orgId, int $actingUserId, ?int $uoId): void { $this->organizationManagementLogger->notice('User-Organization link created', [ 'target_user_id' => $userId, 'organization_id' => $orgId, 'acting_user_id' => $actingUserId, 'uo_id' => $uoId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } - public function logExistingUserAddedToOrg(int $userId, int $orgId, int $actingUserId, int $uoId, ?string $ip): void + public function logExistingUserAddedToOrg(int $userId, int $orgId, int $actingUserId, int $uoId): void { $this->organizationManagementLogger->notice('Existing user added to organization', [ 'target_user_id' => $userId, 'organization_id' => $orgId, 'acting_user_id' => $actingUserId, 'uo_id' => $uoId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } // Email Notification Logs - public function logPasswordSetupEmailSent(int $userId, ?int $orgId, ?string $ip): void + public function logEmailSent(int $userId, ?int $orgId, string $message): void { - $this->emailNotificationLogger->notice("Password setup email sent to $userId", [ + $this->emailNotificationLogger->notice($message, [ 'target_user_id' => $userId, 'organization_id' => $orgId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } - public function logExistingUserNotificationSent(int $userId, int $orgId, ?string $ip): void + public function logExistingUserNotificationSent(int $userId, int $orgId): void { $this->emailNotificationLogger->notice("Existing user notification email sent to $userId", [ 'target_user_id' => $userId, 'organization_id' => $orgId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } - public function logAdminNotified(int $adminUserId, int $targetUserId, int $orgId, int $actingUserId, ?string $ip): void + public function logAdminNotified(int $adminUserId, int $targetUserId, int $orgId, int $actingUserId): void { $this->emailNotificationLogger->notice('Organization admin notified', [ 'admin_user_id' => $adminUserId, 'target_user_id' => $targetUserId, 'organization_id' => $orgId, 'acting_user_id' => $actingUserId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } - public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, ?string $ip, string $message): void + public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, string $message): void { $this->adminActionsLogger->notice($message, [ 'target_user_id' => $userId, 'organization_id' => $orgId, 'acting_user_id' => $actingUserId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ]); } @@ -113,6 +106,7 @@ readonly class LoggerService { $this->errorLogger->error($message, array_merge($context, [ 'timestamp' => $this->now(), + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', ])); } @@ -120,17 +114,18 @@ readonly class LoggerService { $this->errorLogger->critical($message, array_merge($context, [ 'timestamp' => $this->now(), + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', ])); } // Security Logs - public function logAccessDenied(int $targetId, ?int $actingUserId, ?string $ip): void + public function logAccessDenied(?int $actingUserId): void { $this->securityLogger->warning('Access denied', [ - 'target_id' => $targetId, 'acting_user_id' => $actingUserId, - 'ip' => $ip, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), + 'page_accessed' => $_SERVER['REQUEST_URI'] ?? 'unknown', ]); } @@ -139,4 +134,94 @@ readonly class LoggerService { return (new \DateTimeImmutable('now'))->format(DATE_ATOM); } + + + public function logUserAction(int $targetId, int $actingUserId, string $message): void + { + $this->userManagementLogger->notice($message, [ + 'target_user_id'=> $targetId, + 'acting_user_id'=> $actingUserId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logAdminAction(int $targetId, int $actingUserId, int $organizationId, string $message): void + { + $this->adminActionsLogger->notice($message, [ + 'target_id' => $targetId, + 'acting_user_id'=> $actingUserId, + 'organization_id'=> $organizationId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logEntityNotFound(string $entityType, array $criteria, ?int $actingUserId): void + { + $this->errorLogger->warning('Entity not found', [ + 'entity_type' => $entityType, + 'criteria' => $criteria, + 'acting_user_id' => $actingUserId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logAWSAction(string $action, array $details): void + { + $this->securityLogger->info("AWS action performed: $action", array_merge($details, [ + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ])); + } + + public function logTokenRevocation(string $message, array $array): void + { + $this->securityLogger->notice($message, array_merge($array, [ + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ])); + } + + public function logUOALinkDeactivated(int $uoaId, int $appId, int $roleId): void + { + $this->securityLogger->notice('UOA link deactivated', [ + 'uoa_id' => $uoaId, + 'app_id' => $appId, + 'role_id' => $roleId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logOrganizationInformation(int $organizationId, int $actingUserId, string $message): void + { + $this->organizationManagementLogger->info($message, [ + 'organization_id' => $organizationId, + 'acting_user_id' => $actingUserId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logRoleEntityAssignment(int $userId, int $organizationId, int $roleId, int $actingUserId, string $message): void + { + $this->accessControlLogger->info($message, [ + 'target_user_id' => $userId, + 'organization_id' => $organizationId, + 'role_id' => $roleId, + 'acting_user_id' => $actingUserId, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + public function logRoleAssignment(string $message, array $context): void + { + $this->accessControlLogger->info($message, [ + 'context' => $context, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } } diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index 9384809..142ab2e 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -8,7 +8,9 @@ use App\Entity\Roles; use App\Entity\UserOrganizatonApp; use App\Entity\UsersOrganizations; use App\Repository\UsersOrganizationsRepository; +use App\Service\LoggerService; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\Exception\FileException; class OrganizationsService @@ -16,10 +18,11 @@ class OrganizationsService private string $logoDirectory; public function __construct( - string $logoDirectory, private readonly AwsService $awsService, - private readonly EntityManagerInterface $entityManager, + string $logoDirectory, private readonly AwsService $awsService, + private readonly EntityManagerInterface $entityManager, private readonly UsersOrganizationsRepository $uoRepository, - private readonly NotificationService $notificationService + private readonly NotificationService $notificationService, + private readonly LoggerInterface $emailNotificationLogger, private readonly LoggerService $loggerService, ) { $this->logoDirectory = $logoDirectory; @@ -33,8 +36,18 @@ class OrganizationsService try { $this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $logoFile, $customFilename, $extension, 'logo/'); + $this->loggerService->logAWSAction('Upload organization logo', [ + 'organization_id' => $organization->getId(), + 'filename' => $customFilename, + 'bucket' => $_ENV['S3_PORTAL_BUCKET'], + ]); $organization->setLogoUrl('logo/' . $customFilename); } catch (FileException $e) { + $this->loggerService->logError('Failed to upload organization logo to S3', [ + 'organization_id' => $organization->getId(), + 'error' => $e->getMessage(), + 'bucket' => $_ENV['S3_PORTAL_BUCKET'], + ]); throw new FileException('Failed to upload logo to S3: ' . $e->getMessage()); } } @@ -87,6 +100,13 @@ class OrganizationsService $data['organization'] ); } + $this->emailNotificationLogger->info('Organization admins notified of new user accept', [ + 'admin_user_id' => $adminUO->getUsers()->getId(), + 'organization_id' => $data['organization']->getId(), + 'ip' => $data['ip'] ?? null, + 'user_accepted' => $newUser->getId(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + ]); break; case 'USER_INVITED': if ($uoa) { @@ -97,6 +117,13 @@ class OrganizationsService $data['organization'] ); } + $this->emailNotificationLogger->info('Organization admins notified of new user invited', [ + 'admin_user_id' => $adminUO->getUsers()->getId(), + 'organization_id' => $data['organization']->getId(), + 'ip' => $data['ip'] ?? null, + 'user_accepted' => $invitedUser->getId(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + ]); break; case 'USER_DEACTIVATED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -107,6 +134,13 @@ class OrganizationsService $data['organization'] ); } + $this->emailNotificationLogger->info('Organization admins notified of user deactivated', [ + 'admin_user_id' => $adminUO->getUsers()->getId(), + 'organization_id' => $data['organization']->getId(), + 'ip' => $data['ip'] ?? null, + 'user_accepted' => $removedUser->getId(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + ]); break; case 'USER_DELETED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -117,6 +151,13 @@ class OrganizationsService $data['organization'] ); } + $this->emailNotificationLogger->info('Organization admins notified of user deleted', [ + 'admin_user_id' => $adminUO->getUsers()->getId(), + 'organization_id' => $data['organization']->getId(), + 'ip' => $data['ip'] ?? null, + 'user_accepted' => $removedUser->getId(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + ]); break; case 'USER_ACTIVATED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -127,6 +168,13 @@ class OrganizationsService $data['organization'] ); } + $this->emailNotificationLogger->info('Organization admins notified of user activated', [ + 'admin_user_id' => $adminUO->getUsers()->getId(), + 'organization_id' => $data['organization']->getId(), + 'ip' => $data['ip'] ?? null, + 'user_accepted' => $activatedUser->getId(), + 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), + ]); break; } diff --git a/src/Service/UserOrganizationAppService.php b/src/Service/UserOrganizationAppService.php index 67c9613..bf2615f 100644 --- a/src/Service/UserOrganizationAppService.php +++ b/src/Service/UserOrganizationAppService.php @@ -8,13 +8,15 @@ use App\Entity\User; use App\Entity\UserOrganizatonApp; use App\Entity\UsersOrganizations; use App\Service\ActionService; +use App\Service\LoggerService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; class UserOrganizationAppService { - public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService, private readonly Security $security, private readonly UserService $userService) + public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ActionService $actionService, private readonly Security $security, private readonly UserService $userService, private readonly LoggerInterface $logger, private readonly LoggerService $loggerService) { } @@ -79,10 +81,20 @@ class UserOrganizationAppService $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'isActive' => true]); } foreach ($uoas as $uoa) { - $uoa->setIsActive(false); - $this->actionService->createAction("Deactivate UOA link", $userOrganization->getUsers(), - $userOrganization->getOrganization(), "App: " . $uoa->getApplication()->getName() . ", Role: " . $uoa->getRole()->getName()); - $this->entityManager->persist($uoa); + try{ + $uoa->setIsActive(false); + $this->actionService->createAction("Deactivate UOA link", $userOrganization->getUsers(), + $userOrganization->getOrganization(), "App: " . $uoa->getApplication()->getName() . ", Role: " . $uoa->getRole()->getName()); + $this->entityManager->persist($uoa); + $this->loggerService->logUOALinkDeactivated($uoa->getId(), $uoa->getApplication()->getId(), $uoa->getRole()->getId()); + }catch (\Exception $exception){ + $this->loggerService->logCritical("Error deactivating UOA link", [ + 'uoa_id' => $uoa->getId(), + 'app_id' => $uoa->getApplication()->getId(), + 'role_id' => $uoa->getRole()->getId(), + 'exception_message' => $exception->getMessage(), + ]); + } } } @@ -128,6 +140,11 @@ class UserOrganizationAppService if (!$uoa->isActive()) { $uoa->setIsActive(true); $this->entityManager->persist($uoa); + $this->loggerService->logOrganizationInformation( + $uo->getOrganization()->getId(), + $actingUser->getId(), + "Re-activated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()} with UOA ID {$uoa->getId()}'" + ); $this->actionService->createAction( "Re-activate user role for application", $actingUser, @@ -148,7 +165,11 @@ class UserOrganizationAppService if ($uoa->isActive()) { $uoa->setIsActive(false); $this->entityManager->persist($uoa); - + $this->loggerService->logOrganizationInformation( + $uo->getOrganization()->getId(), + $actingUser->getId(), + "Deactivated role '$roleName' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$uoa->getId()}'" + ); $this->actionService->createAction( "Deactivate user role for application", $actingUser, @@ -185,6 +206,11 @@ class UserOrganizationAppService $this->ensureAdminRoleForSuperAdmin($newUoa); } $this->entityManager->persist($newUoa); + $this->loggerService->logOrganizationInformation( + $uo->getOrganization()->getId(), + $actingUser->getId(), + "Created new role '{$role->getName()}' for user '{$uo->getUsers()->getId()}' in application '{$application->getName()}' with UOA ID {$newUoa->getId()}'" + ); $this->actionService->createAction("New user role for application", $actingUser, $uo->getOrganization(), diff --git a/src/Service/UserOrganizationService.php b/src/Service/UserOrganizationService.php index 8a323da..afd2d42 100644 --- a/src/Service/UserOrganizationService.php +++ b/src/Service/UserOrganizationService.php @@ -7,6 +7,7 @@ use App\Entity\Organizations; use App\Entity\User; use App\Entity\UsersOrganizations; use App\Service\ActionService; +use App\Service\LoggerService; use \App\Service\UserOrganizationAppService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -19,7 +20,7 @@ readonly class UserOrganizationService { public function __construct( - private userOrganizationAppService $userOrganizationAppService, private EntityManagerInterface $entityManager, private ActionService $actionService, + private userOrganizationAppService $userOrganizationAppService, private EntityManagerInterface $entityManager, private ActionService $actionService, private LoggerService $loggerService, ) { } @@ -43,20 +44,14 @@ readonly class UserOrganizationService //deactivate all UO links foreach ($uos as $uo) { $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); + $this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getId(), + 'Uo link deactivated'); $uo->setIsActive(false); $this->entityManager->persist($uo); $this->actionService->createAction("Deactivate UO link", $actingUser, $uo->getOrganization(), $uo->getOrganization()->getName() ); } } - public function getByIdOrFail(int $id): UsersOrganizations - { - $uo = $this->entityManager->getRepository(UsersOrganizations::class)->find($id); - if (!$uo) { - throw new NotFoundHttpException("UserOrganization not found"); - } - return $uo; - } } diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 228b8b2..9f2810c 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -95,6 +95,9 @@ class UserService */ public function hasAccessTo(User $user, bool $skipSelfCheck = false): bool { + if ($this->security->isGranted('ROLE_SUPER_ADMIN')) { + return true; + } if (!$skipSelfCheck && $user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) { return true; } @@ -106,9 +109,6 @@ class UserService } } } - if ($this->security->isGranted('ROLE_SUPER_ADMIN')) { - return true; - } return false; } @@ -151,6 +151,7 @@ class UserService { $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $userIdentifier]); if (!$user) { + $this->loggerService->logEntityNotFound('User', ['user_identifier' => $userIdentifier], null); throw new EntityNotFoundException(self::NOT_FOUND); } return $user; @@ -182,18 +183,20 @@ class UserService return ['none' => $group]; } -//TODO: reset function public function handleProfilePicture(User $user, $picture): void { // Get file extension $extension = $picture->guessExtension(); - // Create custom filename: userNameUserSurname_ddmmyyhhmmss + // Create custom filename: userNameUserSurname_dmyHis $customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension; -// $customFilename = $user->getName() . $user->getSurname() . "." .$extension; try { $this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/'); - + $this->loggerService->logAWSAction( + 'Profile picture uploaded to S3',[ + 'user_id' => $user->getId(), + 'filename' => $customFilename, + ]); $user->setPictureUrl('profile/' . $customFilename); } catch (FileException $e) { // Handle upload error @@ -249,12 +252,18 @@ class UserService if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) { $user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN'])); } + $this->loggerService->logRoleAssignment( + 'Role assigned to user', + [ + 'user_id' => $user->getId(), + 'role' => $roleFormatted, + ] + ); } else { // Remove the role if present and not used elsewhere if (in_array($roleFormatted, $user->getRoles(), true)) { $uos = $this->entityManager->getRepository(UsersOrganizations::class) ->findBy(['users' => $user, 'isActive' => true]); - $hasRole = false; foreach ($uos as $uo) { $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class) @@ -264,7 +273,6 @@ class UserService 'role' => $this->entityManager->getRepository(Roles::class) ->findOneBy(['name' => $role]), ]); - if ($uoa) { $hasRole = true; break; @@ -300,9 +308,26 @@ class UserService 'userIdentifier' => $userIdentifier, 'revoked' => false ]); - foreach ($tokens as $token) { - $token->revoke(); + try{ + $token->revoke(); + $this->loggerService->logTokenRevocation( + 'Access token revoked for user', + [ + 'user_identifier' => $userIdentifier, + 'token_id' => $token->getIdentifier(), + ] + ); + }catch (\Exception $e){ + $this->loggerService->logError( + 'Error revoking access token: ' . $e->getMessage(), + [ + 'user_identifier' => $userIdentifier, + 'token_id' => $token->getIdentifier(), + ] + ); + } + } } @@ -469,7 +494,7 @@ class UserService * @param User $user * @return void */ - public function formatNewUserData(User $user, $picture, bool $setPassword = false): void + public function formatUserData(User $user, $picture, bool $setPassword = false): void { // capitalize name and surname $user->setName(ucfirst(strtolower($user->getName()))); @@ -496,7 +521,6 @@ class UserService User $existingUser, Organizations $org, User $actingUser, - ?string $ip ): int { try { $uoId = $this->handleExistingUser($existingUser, $org); @@ -506,17 +530,14 @@ class UserService $org->getId(), $actingUser->getId(), $uoId, - $ip ); - $this->actionService->createAction( "Add existing user to organization", $actingUser, $org, "Added user {$existingUser->getUserIdentifier()} to {$org->getName()}" ); - - $this->sendExistingUserNotifications($existingUser, $org, $actingUser, $ip); + $this->sendExistingUserNotifications($existingUser, $org, $actingUser); return $uoId; } catch (\Exception $e) { @@ -524,7 +545,6 @@ class UserService 'target_user_id' => $existingUser->getId(), 'organization_id' => $org->getId(), 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, ]); throw $e; } @@ -533,14 +553,14 @@ class UserService /** * Create a brand-new user */ - public function createNewUser(User $user, User $actingUser, $picture, ?string $ip): void + public function createNewUser(User $user, User $actingUser, $picture): void { try { - $this->formatNewUserData($user, $picture, true); + $this->formatUserData($user, $picture, true); $this->entityManager->persist($user); $this->entityManager->flush(); - $this->loggerService->logUserCreated($user->getId(), $actingUser->getId(), $ip); + $this->loggerService->logUserCreated($user->getId(), $actingUser->getId()); $token = $this->generatePasswordToken($user); $this->emailService->sendPasswordSetupEmail($user, $token); $this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier()); @@ -548,7 +568,6 @@ class UserService $this->loggerService->logError('Error creating new user: ' . $e->getMessage(), [ 'target_user_email' => $user->getEmail(), 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, ]); throw $e; } @@ -561,7 +580,6 @@ class UserService User $user, Organizations $org, User $actingUser, - ?string $ip ): UsersOrganizations { try { $uo = new UsersOrganizations(); @@ -578,7 +596,7 @@ class UserService $org->getId(), $actingUser->getId(), $uo->getId(), - $ip + ); $this->actionService->createAction( @@ -588,7 +606,7 @@ class UserService "Added {$user->getUserIdentifier()} to {$org->getName()}" ); - $this->sendNewUserNotifications($user, $org, $actingUser, $ip); + $this->sendNewUserNotifications($user, $org, $actingUser); return $uo; } catch (\Exception $e) { @@ -596,51 +614,47 @@ class UserService 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), 'acting_user_id' => $actingUser->getId(), - 'ip' => $ip, ]); throw $e; } } // Private helpers for email notifications - private function sendExistingUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void + private function sendExistingUserNotifications(User $user, Organizations $org, User $actingUser): void { try { $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendExistingUserNotificationEmail($user, $org, $token); - $this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId(), $ip); + $this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId()); } catch (\Exception $e) { $this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [ 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), - 'ip' => $ip, ]); } - - $this->notifyOrgAdmins($user, $org, $actingUser, $ip); + $this->notifyOrgAdmins($user, $org, $actingUser,); } - private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void + private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser): void { try { $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendPasswordSetupEmail($user, $token); - $this->loggerService->logPasswordSetupEmailSent($user->getId(), $org->getId(), $ip); } catch (\Exception $e) { $this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [ 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), - 'ip' => $ip, ]); } - $this->notifyOrgAdmins($user, $org, $actingUser, $ip); + $this->notifyOrgAdmins($user, $org, $actingUser); } - private function notifyOrgAdmins(User $user, Organizations $org, User $actingUser, ?string $ip): void + private function notifyOrgAdmins(User $user, Organizations $org, User $actingUser): void { try { - $data = ['user' => $user, 'organization' => $org]; + $data = ['user' => $user, + 'organization' => $org]; $adminsUos = $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); foreach ($adminsUos as $adminUo) { @@ -649,14 +663,12 @@ class UserService $user->getId(), $org->getId(), $actingUser->getId(), - $ip ); } } catch (\Exception $e) { $this->loggerService->logError("Error notifying organization admins: " . $e->getMessage(), [ 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), - 'ip' => $ip, ]); } } From 3f9d388f7f108c2d08e35a082cf5a09de679c5b8 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 09:40:13 +0100 Subject: [PATCH 05/43] Refactor for monolog in security controller --- src/Controller/SecurityController.php | 52 ++++++++++++++++-------- src/Controller/UserController.php | 27 +++++++------ src/Service/AccessTokenService.php | 35 ++++++++++++---- src/Service/LoggerService.php | 40 ++++++++++++++---- src/Service/OrganizationsService.php | 57 ++++++++++---------------- src/Service/UserService.php | 58 ++------------------------- 6 files changed, 137 insertions(+), 132 deletions(-) diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 9ea51a6..9b2a988 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Repository\UserRepository; use App\Repository\UsersOrganizationsRepository; use App\Service\AccessTokenService; +use App\Service\LoggerService; use App\Service\OrganizationsService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; @@ -30,7 +31,7 @@ class SecurityController extends AbstractController private readonly UsersOrganizationsRepository $uoRepository, private readonly LoggerInterface $logger, private readonly EntityManagerInterface $entityManager, - private readonly OrganizationsService $organizationsService) + private readonly OrganizationsService $organizationsService, private readonly LoggerService $loggerService, private readonly Security $security) { $this->cguUserService = $cguUserService; } @@ -50,14 +51,16 @@ class SecurityController extends AbstractController #[Route(path: '/sso_logout', name: 'sso_logout')] public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response { - try{ - if( $stack->getSession()->invalidate()){ - $accessTokenService->revokeTokens($security->getUser()->getUserIdentifier()); + try { + $user = $this->userService->getUserByIdentifier($this->security->getUser()->getUserIdentifier()); + $id = $user->getId(); + if ($stack->getSession()->invalidate()) { + $accessTokenService->revokeUserTokens($security->getUser()->getUserIdentifier()); $security->logout(false); - $logger->info("Logout successfully"); - return $this->redirect('/'); + $this->loggerService->logUserConnection('User logged out', ['user_id' => $id]); + return $this->redirect('/'); } - }catch (\Exception $e){ + } catch (\Exception $e) { $logger->log(LogLevel::ERROR, 'Error invalidating session: ' . $e->getMessage()); } return $this->redirectToRoute('app_index'); @@ -69,6 +72,7 @@ class SecurityController extends AbstractController if ($request->isMethod('POST')) { if (!$request->request->has('decline')) { $this->cguUserService->acceptLatestCgu($this->getUser()); + $this->loggerService->logCGUAcceptance($this->getUser()->getId()); } return $this->redirectToRoute('oauth2_authorize', $request->query->all()); @@ -83,12 +87,24 @@ class SecurityController extends AbstractController $error = $request->get('error'); $user = $this->userRepository->find($id); if (!$user) { + $this->loggerService->logEntityNotFound('User', ['user_id' => $id, + 'error' => $error ?? null, + 'message' => 'user not found for password setup'], $id); throw $this->createNotFoundException(self::NOT_FOUND); } $token = $request->get('token'); - if (empty($token) || !$this->userService->isPasswordTokenValid($user, $token)) { + if (empty($token)) { $error = 'Le lien de définition du mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.'; - $this->logger->warning($user->getUserIdentifier(). " tried to use an invalid or expired password setup token."); + $this->loggerService->logTokenError('Token empty while trying to setup password', ['token' => $token, + 'token_empty' => true, + 'user_id' => $id, + 'message' => 'empty token provided for password setup']); + } + + if (!$this->userService->isPasswordTokenValid($user, $token)) { + $error = 'Le lien de définition du mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.'; + $this->loggerService->logTokenError('invalid or expired token for password setup', ['user_id' => $id, + 'token' => $token,]); } return $this->render('security/password_setup.html.twig', [ 'id' => $id, @@ -102,34 +118,38 @@ class SecurityController extends AbstractController { $user = $this->userRepository->find($id); if (!$user) { + $this->loggerService->logEntityNotFound('User', ['user_id' => $id, + 'message' => 'user not found for password reset'], $id); throw $this->createNotFoundException(self::NOT_FOUND); } $newPassword = $_POST['_password'] ?? null; $confirmPassword = $_POST['_passwordConfirm'] ?? null; if ($newPassword !== $confirmPassword) { $error = 'Les mots de passe ne correspondent pas. Veuillez réessayer.'; - $this->logger->warning($user->getUserIdentifier(). " provided non-matching passwords during password reset."); + $this->loggerService->logUserAction($id, $id, 'Password confirmation does not match during password reset.'); return $this->redirectToRoute('password_setup', [ 'id' => $id, 'token' => $_POST['token'] ?? '', - 'error'=> $error]); + 'error' => $error]); } if (!$this->userService->isPasswordStrong($newPassword)) { $error = 'Le mot de passe ne respecte pas les critères de sécurité. Veuillez en choisir un autre.'; - $this->logger->warning($user->getUserIdentifier(). " provided a weak password during password reset."); - return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error'=> $error]); + $this->loggerService->logUserAction($id, $id, ' provided a weak password during password reset.'); + return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error' => $error]); } $this->userService->updateUserPassword($user, $newPassword); - $orgId = $this->userService->getOrgFromToken( $_POST['token']); + $this->loggerService->logUserAction($id, $id, 'Password reset user successfully.'); + $orgId = $this->userService->getOrgFromToken($_POST['token']); $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); - if($uo){ + if ($uo) { $uo->setStatut("ACCEPTED"); $uo->setIsActive(true); $this->entityManager->persist($uo); $this->entityManager->flush(); + $this->loggerService->logOrganizationInformation($orgId, $user->getId(), 'User accepted organization invitation during password reset.'); + $this->loggerService->logUserAction($id, $id, "User accepted organization invitation successfully with uo link id : {$uo->getId()}"); $data = ['user' => $user, 'organization' => $uo->getOrganization(), - 'ip' => $request->getClientIp(), ]; $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED"); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index a7e6e03..be05141 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -13,6 +13,7 @@ use App\Repository\OrganizationsRepository; use App\Repository\RolesRepository; use App\Repository\UserRepository; use App\Repository\UsersOrganizationsRepository; +use App\Service\AccessTokenService; use App\Service\ActionService; use App\Service\AwsService; use App\Service\EmailService; @@ -45,17 +46,17 @@ class UserController extends AbstractController private readonly UserOrganizationService $userOrganizationService, private readonly UserRepository $userRepository, private readonly UsersOrganizationsRepository $uoRepository, - private readonly OrganizationsRepository $organizationRepository, - private readonly LoggerInterface $userManagementLogger, - private readonly LoggerInterface $organizationManagementLogger, - private readonly LoggerInterface $errorLogger, - private readonly LoggerInterface $securityLogger, - private readonly LoggerService $loggerService, - private readonly EmailService $emailService, - private readonly AwsService $awsService, - private readonly OrganizationsService $organizationsService, - private readonly AppsRepository $appsRepository, - private readonly RolesRepository $rolesRepository, + private readonly OrganizationsRepository $organizationRepository, + private readonly LoggerInterface $userManagementLogger, + private readonly LoggerInterface $organizationManagementLogger, + private readonly LoggerInterface $errorLogger, + private readonly LoggerInterface $securityLogger, + private readonly LoggerService $loggerService, + private readonly EmailService $emailService, + private readonly AwsService $awsService, + private readonly OrganizationsService $organizationsService, + private readonly AppsRepository $appsRepository, + private readonly RolesRepository $rolesRepository, private readonly AccessTokenService $accessTokenService, ) { } @@ -405,7 +406,7 @@ class UserController extends AbstractController $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); if ($this->userService->isUserConnected($user->getUserIdentifier())) { - $this->userService->revokeUserTokens($user->getUserIdentifier()); + $this->accessTokenService->revokeUserTokens($user->getUserIdentifier()); } $user->setModifiedAt(new \DateTimeImmutable('now')); @@ -563,7 +564,7 @@ class UserController extends AbstractController // Revoke tokens if connected if ($this->userService->isUserConnected($user->getUserIdentifier())) { - $this->userService->revokeUserTokens($user->getUserIdentifier()); + $this->accessTokenService->revokeUserTokens($user->getUserIdentifier()); } $this->entityManager->flush(); diff --git a/src/Service/AccessTokenService.php b/src/Service/AccessTokenService.php index 9fe9540..b36a229 100644 --- a/src/Service/AccessTokenService.php +++ b/src/Service/AccessTokenService.php @@ -11,17 +11,38 @@ class AccessTokenService private EntityManagerInterface $entityManager; - public function __construct(EntityManagerInterface $entityManager) + public function __construct(EntityManagerInterface $entityManager, + private readonly LoggerService $loggerService) { $this->entityManager = $entityManager; } - public function revokeTokens(String $userIdentifier): void { - $accessTokens = $this->entityManager->getRepository(AccessToken::class)->findBy(['userIdentifier' => $userIdentifier, 'revoked' => false]); - foreach($accessTokens as $accessToken) { - $accessToken->revoke(); - $this->entityManager->persist($accessToken); - $this->entityManager->flush(); + public function revokeUserTokens(string $userIdentifier): void + { + $tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([ + 'userIdentifier' => $userIdentifier, + 'revoked' => false + ]); + foreach ($tokens as $token) { + try{ + $token->revoke(); + $this->loggerService->logTokenRevocation( + 'Access token revoked for user', + [ + 'user_identifier' => $userIdentifier, + 'token_id' => $token->getIdentifier(), + ] + ); + }catch (\Exception $e){ + $this->loggerService->logError( + 'Error revoking access token: ' . $e->getMessage(), + [ + 'user_identifier' => $userIdentifier, + 'token_id' => $token->getIdentifier(), + ] + ); + } + } } diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index 30ed48b..bcb05c6 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -78,16 +78,12 @@ readonly class LoggerService ]); } - public function logAdminNotified(int $adminUserId, int $targetUserId, int $orgId, int $actingUserId): void + public function logAdminNotified(array $array): void { - $this->emailNotificationLogger->notice('Organization admin notified', [ - 'admin_user_id' => $adminUserId, - 'target_user_id' => $targetUserId, - 'organization_id' => $orgId, - 'acting_user_id' => $actingUserId, + $this->emailNotificationLogger->notice('Organization admin notified', array_merge($array, [ 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), - ]); + ])); } public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, string $message): void @@ -224,4 +220,34 @@ readonly class LoggerService 'timestamp' => $this->now(), ]); } + + public function logUserConnection(string $message, array $array) + { + $this->securityLogger->info($message, array_merge($array, [ + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ])); + } + + public function logCGUAcceptance(int $it) + { + $this->userManagementLogger->info("User accepted CGU", [ + 'user_id' => $it, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + $this->securityLogger->info("User accepted CGU", [ + 'user_id' => $it, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ]); + } + + public function logTokenError(string $message, array $context = []): void + { + $this->securityLogger->error($message, array_merge($context, [ + 'timestamp' => $this->now(), + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + ])); + } } diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index 142ab2e..41dc4a5 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -99,14 +99,11 @@ class OrganizationsService $newUser, $data['organization'] ); + $this->loggerService->logAdminNotified([ + 'admin_user_id' =>$adminUO->getUsers()->getId(), + 'target_user_id' => $newUser->getId(), + 'organization_id' => $data['organization']->getId(),'case' =>$type]); } - $this->emailNotificationLogger->info('Organization admins notified of new user accept', [ - 'admin_user_id' => $adminUO->getUsers()->getId(), - 'organization_id' => $data['organization']->getId(), - 'ip' => $data['ip'] ?? null, - 'user_accepted' => $newUser->getId(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); break; case 'USER_INVITED': if ($uoa) { @@ -116,14 +113,12 @@ class OrganizationsService $invitedUser, $data['organization'] ); + $this->loggerService->logAdminNotified([ + 'admin_user_id' =>$adminUO->getUsers()->getId(), + 'target_user_id' => $newUser->getId(), + 'organization_id' => $data['organization']->getId(),'case' =>$type]); } - $this->emailNotificationLogger->info('Organization admins notified of new user invited', [ - 'admin_user_id' => $adminUO->getUsers()->getId(), - 'organization_id' => $data['organization']->getId(), - 'ip' => $data['ip'] ?? null, - 'user_accepted' => $invitedUser->getId(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); + break; case 'USER_DEACTIVATED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -133,14 +128,12 @@ class OrganizationsService $removedUser, $data['organization'] ); + $this->loggerService->logAdminNotified([ + 'admin_user_id' =>$adminUO->getUsers()->getId(), + 'target_user_id' => $newUser->getId(), + 'organization_id' => $data['organization']->getId(),'case' =>$type]); } - $this->emailNotificationLogger->info('Organization admins notified of user deactivated', [ - 'admin_user_id' => $adminUO->getUsers()->getId(), - 'organization_id' => $data['organization']->getId(), - 'ip' => $data['ip'] ?? null, - 'user_accepted' => $removedUser->getId(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); + break; case 'USER_DELETED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -150,14 +143,11 @@ class OrganizationsService $removedUser, $data['organization'] ); + $this->loggerService->logAdminNotified([ + 'admin_user_id' =>$adminUO->getUsers()->getId(), + 'target_user_id' => $newUser->getId(), + 'organization_id' => $data['organization']->getId(),'case' =>$type]); } - $this->emailNotificationLogger->info('Organization admins notified of user deleted', [ - 'admin_user_id' => $adminUO->getUsers()->getId(), - 'organization_id' => $data['organization']->getId(), - 'ip' => $data['ip'] ?? null, - 'user_accepted' => $removedUser->getId(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); break; case 'USER_ACTIVATED': if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { @@ -167,14 +157,11 @@ class OrganizationsService $activatedUser, $data['organization'] ); + $this->loggerService->logAdminNotified([ + 'admin_user_id' =>$adminUO->getUsers()->getId(), + 'target_user_id' => $newUser->getId(), + 'organization_id' => $data['organization']->getId(),'case' =>$type]); } - $this->emailNotificationLogger->info('Organization admins notified of user activated', [ - 'admin_user_id' => $adminUO->getUsers()->getId(), - 'organization_id' => $data['organization']->getId(), - 'ip' => $data['ip'] ?? null, - 'user_accepted' => $activatedUser->getId(), - 'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM), - ]); break; } diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 9f2810c..b2dee75 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -302,34 +302,6 @@ class UserService return 'ROLE_' . $role; } - public function revokeUserTokens(string $userIdentifier) - { - $tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([ - 'userIdentifier' => $userIdentifier, - 'revoked' => false - ]); - foreach ($tokens as $token) { - try{ - $token->revoke(); - $this->loggerService->logTokenRevocation( - 'Access token revoked for user', - [ - 'user_identifier' => $userIdentifier, - 'token_id' => $token->getIdentifier(), - ] - ); - }catch (\Exception $e){ - $this->loggerService->logError( - 'Error revoking access token: ' . $e->getMessage(), - [ - 'user_identifier' => $userIdentifier, - 'token_id' => $token->getIdentifier(), - ] - ); - } - - } - } public function formatStatutForOrganizations(array $rows): array { @@ -626,13 +598,14 @@ class UserService $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendExistingUserNotificationEmail($user, $org, $token); $this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId()); + $this->organizationsService->notifyOrganizationAdmins(['user'=> $user, 'acting_user_id'=>$actingUser->getId(), + 'organization'=> $org], 'USER_INVITED'); } catch (\Exception $e) { $this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [ 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), ]); } - $this->notifyOrgAdmins($user, $org, $actingUser,); } private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser): void @@ -640,37 +613,14 @@ class UserService try { $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendPasswordSetupEmail($user, $token); + $this->organizationsService->notifyOrganizationAdmins(['user'=> $user, 'acting_user_id'=>$actingUser->getId(), + 'organization'=> $org], 'USER_INVITED'); } catch (\Exception $e) { $this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [ 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), ]); } - - $this->notifyOrgAdmins($user, $org, $actingUser); - } - - private function notifyOrgAdmins(User $user, Organizations $org, User $actingUser): void - { - try { - $data = ['user' => $user, - 'organization' => $org]; - $adminsUos = $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); - - foreach ($adminsUos as $adminUo) { - $this->loggerService->logAdminNotified( - $adminUo->getUsers()->getId(), - $user->getId(), - $org->getId(), - $actingUser->getId(), - ); - } - } catch (\Exception $e) { - $this->loggerService->logError("Error notifying organization admins: " . $e->getMessage(), [ - 'target_user_id' => $user->getId(), - 'organization_id' => $org->getId(), - ]); - } } } From 79ef977e1b1fdea7e3cb07b549ec5c378acef56a Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 10:06:48 +0100 Subject: [PATCH 06/43] removed unused button --- templates/user/show.html.twig | 7 ------- 1 file changed, 7 deletions(-) diff --git a/templates/user/show.html.twig b/templates/user/show.html.twig index 28bb8cc..6e85102 100644 --- a/templates/user/show.html.twig +++ b/templates/user/show.html.twig @@ -14,13 +14,6 @@ {% if is_granted("ROLE_SUPER_ADMIN") %} Supprimer - {% if user.active %} - Désactiver l'utilisateur - {% else %} - Activer - l'utilisateur - {% endif %} {% endif %} From 88e9c6db6ac4a30bdb3c582f033edfaa130c1e29 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 10:20:50 +0100 Subject: [PATCH 07/43] solve security access issue --- src/Controller/UserController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index be05141..00d7e6d 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -28,6 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Exception\AccessDeniedException; @@ -72,14 +73,14 @@ class UserController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); // Vérification des droits d'accès supplémentaires - if (!$this->userService->hasAccessTo($actingUser)) { - $this->loggerService->logAccessDenied($actingUser->getId()); - throw $this->createAccessDeniedException(self::ACCESS_DENIED); - } + // Chargement de l'utilisateur cible à afficher $user = $this->userRepository->find($id); - + if (!$this->userService->hasAccessTo($user)) { + $this->loggerService->logAccessDenied($actingUser->getId()); + throw new AccessDeniedHttpException (self::ACCESS_DENIED); + } try { // Paramètre optionnel de contexte organisationnel $orgId = $request->query->get('organizationId'); From c47d7877bbbe9339281d4ce50ac4304bc74f4fcb Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 10:21:01 +0100 Subject: [PATCH 08/43] Display correct information --- templates/organization/index.html.twig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index 22046c4..e47f594 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -18,12 +18,19 @@
{% if organizationsData|length == 0 %} + {% if is_granted('ROLE_SUPER_ADMIN') %} {# style présent juste pour créer de l'espace #}

Aucune organisation trouvée.

Créer une organisation
+ {% else %} +
+

Aucune organisation trouvée. Merci de contacter votre administrateur

+
+ {% endif %} + {% else %} From 530c7df5e20200aeb5c0d505d371e29459ff363d Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 10:35:40 +0100 Subject: [PATCH 09/43] correct writting level --- config/packages/monolog.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 34dd8f7..79ac6b4 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -9,6 +9,7 @@ monolog: - security - php - error + - aws_management - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists when@dev: @@ -138,12 +139,18 @@ when@prod: channels: [ php ] # User Management user_management: - type: stream + type: rotating_file path: "%kernel.logs_dir%/user_management.log" level: info channels: [user_management] max_files: 30 - + #AWS + aws_management: + type: rotating_file + path: "%kernel.logs_dir%/aws_management.log" + level: info + channels: [aws_management] + max_files: 30 # Authentication authentication: type: rotating_file From 6b4ad1d6fd47b200dd0713ac511d3d1b96dcd10b Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 11:40:20 +0100 Subject: [PATCH 10/43] Refactor monolog of organization controller --- src/Controller/OrganizationController.php | 93 +++++++++++++++-------- src/Service/LoggerService.php | 13 ++-- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 0d74bd2..a615a41 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -12,14 +12,17 @@ use App\Form\OrganizationForm; use App\Repository\OrganizationsRepository; use App\Service\ActionService; use App\Service\AwsService; +use App\Service\LoggerService; use App\Service\OrganizationsService; use App\Service\UserOrganizationService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; use Exception; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; use App\Entity\Organizations; use Symfony\Component\HttpFoundation\Response; @@ -37,7 +40,7 @@ class OrganizationController extends AbstractController private readonly ActionService $actionService, private readonly UserOrganizationService $userOrganizationService, private readonly OrganizationsRepository $organizationsRepository, - private readonly AwsService $awsService) + private readonly AwsService $awsService, private readonly LoggerService $loggerService, private readonly LoggerInterface $logger) { } @@ -50,7 +53,6 @@ class OrganizationController extends AbstractController if ($this->isGranted("ROLE_SUPER_ADMIN")) { $organizations = $this->organizationsRepository->findBy(['isDeleted' => false]); - } else { //get all the UO of the user $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); @@ -100,6 +102,8 @@ class OrganizationController extends AbstractController try { $this->entityManager->persist($organization); $this->entityManager->flush(); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Created"); + $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Created"); $this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } catch (Exception $e) { @@ -124,20 +128,33 @@ class OrganizationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $organization = $this->organizationsRepository->find($id); if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'org_id' => $id, + 'message' => 'Organization not found for edit'], $actingUser->getId() + ); $this->addFlash('error', self::NOT_FOUND); return $this->redirectToRoute('organization_index'); } if (!$this->isGranted("ROLE_SUPER_ADMIN")) { //check if the user is admin of the organization - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user, 'organization' => $organization]); + $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organization]); if (!$uo) { + $this->loggerService->logEntityNotFound('UO link', [ + 'user_id' => $actingUser->getId(), + 'org_id' => $organization->getId(), + 'message' => 'UO link not found for edit organization' + ], $actingUser->getId()); $this->addFlash('error', self::ACCESS_DENIED); return $this->redirectToRoute('organization_index'); } $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); $uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]); if (!$uoaAdmin) { + $this->loggerService->logEntityNotFound('UOA link', [ + 'uo_id' => $uo->getId(), + 'role_id' => $roleAdmin->getId(), + 'message' => 'UOA link not found for edit organization, user is not admin of organization' + ], $actingUser->getId()); $this->addFlash('error', self::ACCESS_DENIED); return $this->redirectToRoute('organization_index'); } @@ -152,6 +169,10 @@ class OrganizationController extends AbstractController try { $this->entityManager->persist($organization); $this->entityManager->flush(); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Edited"); + if ($this->isGranted("ROLE_SUPER_ADMIN")) { + $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Edited"); + } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } catch (Exception $e) { @@ -171,28 +192,18 @@ class OrganizationController extends AbstractController $organization = $this->organizationsRepository->find($id); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'org_id' => $id, + 'message' => 'Organization not found for view' + ], $actingUser->getId()); $this->addFlash('error', self::NOT_FOUND); return $this->redirectToRoute('organization_index'); } //check if the user is admin of the organization - if (!$this->isGranted("ROLE_SUPER_ADMIN") && !$this->userService->isAdminOfOrganization($organization)) { - $this->createNotFoundException(self::NOT_FOUND); + if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_SUPER_ADMIN")) { + $this->loggerService->logAccessDenied($actingUser->getId()); + throw new AccessDeniedHttpException('Access denied'); } - $newUO = $this->entityManager->getRepository(UsersOrganizations::class)->findNewestUO($organization); - $newUsers = []; - foreach ($newUO as $uo) { - $newUsers[] = $uo->getUsers(); - } - $adminUO = $this->entityManager->getRepository(UsersOrganizations::class)->findAdminsInOrganization($organization); - $adminUsers = []; - foreach ($adminUO as $uo) { - $adminUsers[] = $uo->getUsers(); - } - $uos = $this->entityManager - ->getRepository(UsersOrganizations::class) - ->findBy(['organization' => $organization]); - - $users = $this->userService->formatOrgUsers($uos); $allApps = $this->entityManager->getRepository(Apps::class)->findAll(); // appsAll $orgApps = $organization->getApps()->toArray(); // apps @@ -205,9 +216,6 @@ class OrganizationController extends AbstractController $this->actionService->createAction("View Organization", $actingUser, $organization, $organization->getName()); return $this->render('organization/show.html.twig', [ 'organization' => $organization, - 'newUsers' => $newUsers, - 'adminUsers' => $adminUsers, - 'users' => $users, 'applications' => $apps, 'activities' => $activities, ]); @@ -220,15 +228,25 @@ class OrganizationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $organization = $this->organizationsRepository->find($id); if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'org_id' => $id, + 'message' => 'Organization not found for delete' + ], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } - $organization->setIsActive(false); - $organization->setIsDeleted(true); - // Deactivate all associated UsersOrganizations - $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); + try { + $organization->setIsActive(false); + $organization->setIsDeleted(true); + // Deactivate all associated UsersOrganizations + $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); + + $this->entityManager->persist($organization); + $this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName()); + }catch (\Exception $e){ + $this->loggerService->logError($actingUser->getId(), ['message' => 'Error deleting organization: '.$e->getMessage()]); + $this->addFlash('error', 'Error deleting organization: ' . $e->getMessage()); + } - $this->entityManager->persist($organization); - $this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } @@ -239,12 +257,19 @@ class OrganizationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $organization = $this->organizationsRepository->find($id); if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'org_id' => $id, + 'message' => 'Organization not found for deactivate' + ], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } + $organization->setIsActive(false); // $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); $this->entityManager->persist($organization); $this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName()); + $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization deactivated'); + return $this->redirectToRoute('organization_index'); } @@ -255,10 +280,16 @@ class OrganizationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $organization = $this->organizationsRepository->find($id); if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'org_id' => $id, + 'message' => 'Organization not found for activate' + ], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } $organization->setIsActive(true); $this->entityManager->persist($organization); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Activated'); + $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization Activated'); $this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } @@ -276,8 +307,6 @@ class OrganizationController extends AbstractController $filters = $request->query->all('filter'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $qb = $this->organizationsRepository->createQueryBuilder('o') ->where('o.isDeleted = :del')->setParameter('del', false); diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index bcb05c6..db25e2c 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -16,6 +16,7 @@ readonly class LoggerService private LoggerInterface $adminActionsLogger, private LoggerInterface $securityLogger, private LoggerInterface $errorLogger, + private LoggerInterface $awsLogger, private RequestStack $requestStack, ) {} @@ -155,18 +156,18 @@ readonly class LoggerService public function logEntityNotFound(string $entityType, array $criteria, ?int $actingUserId): void { - $this->errorLogger->warning('Entity not found', [ + $this->errorLogger->error('Entity not found', array_merge($criteria, [ 'entity_type' => $entityType, - 'criteria' => $criteria, 'acting_user_id' => $actingUserId, 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), - ]); + 'page_accessed' => $_SERVER['REQUEST_URI'] ?? 'unknown', + ])); } public function logAWSAction(string $action, array $details): void { - $this->securityLogger->info("AWS action performed: $action", array_merge($details, [ + $this->awsLogger->info("AWS action performed: $action", array_merge($details, [ 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ])); @@ -174,7 +175,7 @@ readonly class LoggerService public function logTokenRevocation(string $message, array $array): void { - $this->securityLogger->notice($message, array_merge($array, [ + $this->securityLogger->warning($message, array_merge($array, [ 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', 'timestamp' => $this->now(), ])); @@ -182,7 +183,7 @@ readonly class LoggerService public function logUOALinkDeactivated(int $uoaId, int $appId, int $roleId): void { - $this->securityLogger->notice('UOA link deactivated', [ + $this->organizationManagementLogger->notice('UOA link deactivated', [ 'uoa_id' => $uoaId, 'app_id' => $appId, 'role_id' => $roleId, From 4022e905a885e543ba05402953795103602bf548 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 11:47:59 +0100 Subject: [PATCH 11/43] Refactor monolog of OAuth2controller controller --- src/Controller/OAuth2Controller.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Controller/OAuth2Controller.php b/src/Controller/OAuth2Controller.php index c614716..2427772 100644 --- a/src/Controller/OAuth2Controller.php +++ b/src/Controller/OAuth2Controller.php @@ -3,6 +3,8 @@ namespace App\Controller; use App\Service\AccessTokenService; +use App\Service\LoggerService; +use App\Service\UserService; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,17 +20,20 @@ class OAuth2Controller extends AbstractController { - + public function __construct(private readonly LoggerService $loggerService, private readonly UserService $userService) + { + } #[Route('/oauth2/userinfo', name: 'userinfo', methods: ['GET'])] public function userinfo(Request $request): JsonResponse { $user = $this->getUser(); -// dd($user); if (!$user) { + $this->loggerService->logAccessDenied($user->getId()); return new JsonResponse(['error' => 'Unauthorized'], 401); } + $this->loggerService->logUserAction($user->getId(), $user->getId(), 'Accessed userinfo endpoint'); return new JsonResponse([ 'id' => $user->getId(), 'name' => $user->getName(), @@ -66,7 +71,7 @@ class OAuth2Controller extends AbstractController if (!$userIdentifier) { return new JsonResponse(["ERROR" => "User identifier is required"], Response::HTTP_BAD_REQUEST); } - $accessTokenService->revokeTokens($userIdentifier); + $accessTokenService->revokeUserTokens($userIdentifier); $logger->info("Revoke tokens successfully"); return new JsonResponse(["SUCCESS" => "Tokens revoked successfully"], Response::HTTP_OK); From 0cd33e84f863853b6f6268321167cdaa5445f0ff Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 11:58:16 +0100 Subject: [PATCH 12/43] Refactor monolog of Application controller --- src/Controller/ApplicationController.php | 72 ++++++++++++++++++++--- src/Controller/NotificationController.php | 2 +- src/Service/LoggerService.php | 9 +++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index 13d3cbf..a666a8e 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Entity\Apps; use App\Entity\Organizations; use App\Service\ActionService; +use App\Service\LoggerService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -16,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route; class ApplicationController extends AbstractController { - public function __construct(private readonly EntityManagerInterface $entityManager, private readonly UserService $userService, private readonly ActionService $actionService) + public function __construct(private readonly EntityManagerInterface $entityManager, private readonly UserService $userService, private readonly ActionService $actionService, private readonly LoggerService $loggerService) { } @@ -37,6 +38,10 @@ class ApplicationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $application = $this->entityManager->getRepository(Apps::class)->find($id); if (!$application) { + $this->loggerService->logEntityNotFound('Application', [ + 'applicationId' => $id, + 'message' => "Application not found for editing." + ], $actingUser); $this->addFlash('error', "L'application n'existe pas ou n'est pas reconnu."); return $this->redirectToRoute('application_index'); } @@ -50,12 +55,28 @@ class ApplicationController extends AbstractController if ($request->isMethod('POST')) { - $data = $request->request->all(); - $application->setName($data['name']); - $application->setDescription($data['description']); - $application->setDescriptionSmall($data['descriptionSmall']); - $this->entityManager->persist($application); - $this->actionService->createAction("Modification de l'application ", $actingUser, null, $application->getId()); + try{ + $data = $request->request->all(); + $application->setName($data['name']); + $application->setDescription($data['description']); + $application->setDescriptionSmall($data['descriptionSmall']); + $this->entityManager->persist($application); + $this->actionService->createAction("Modification de l'application ", $actingUser->getId(), null, $application->getId()); + $this->loggerService->logApplicationInformation('Application Edited', [ + 'applicationId' => $application->getId(), + 'applicationName' => $application->getName(), + 'message' => "Application edited successfully." + ], $actingUser->getId()); + $this->addFlash('success', "L'application a été mise à jour avec succès."); + }catch (\Exception $e){ + $this->loggerService->logError('Application Edit Failed', [ + 'applicationId' => $application->getId(), + 'applicationName' => $application->getName(), + 'error' => $e->getMessage(), + 'message' => "Failed to edit application." + ], $actingUser); + $this->addFlash('error', "Une erreur est survenue lors de la mise à jour de l'application."); + } return $this->redirectToRoute('application_index'); } @@ -66,18 +87,35 @@ class ApplicationController extends AbstractController } #[Route(path: '/authorize/{id}', name: 'authorize', methods: ['POST'])] - public function authorize(int $id, Request $request) + public function authorize(int $id, Request $request): Response { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $application = $this->entityManager->getRepository(Apps::class)->find($id); if (!$application) { + $this->loggerService->logEntityNotFound('Application', [ + 'applicationId' => $id, + 'message' => "Application not found for authorization." + ], $actingUser->getId()); throw $this->createNotFoundException("L'application n'existe pas."); } $orgId = $request->get('organizationId'); $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); + if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'Organization_id' => $orgId, + 'message' => "Organization not found for authorization." + ], $actingUser->getId()); + throw $this->createNotFoundException("L'Organization n'existe pas."); + } $application->addOrganization($organization); + $this->loggerService->logApplicationInformation('Application Authorized', [ + 'applicationId' => $application->getId(), + 'applicationName' => $application->getName(), + 'organizationId' => $organization->getId(), + 'message' => "Application authorized for organization." + ], $actingUser->getId()); $this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName()); return new Response('', Response::HTTP_OK); @@ -90,12 +128,28 @@ class ApplicationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $application = $this->entityManager->getRepository(Apps::class)->find($id); if (!$application) { + $this->loggerService->logEntityNotFound('Application', [ + 'applicationId' => $id, + 'message' => "Application not found for authorization removal." + ], $actingUser->getId()); throw $this->createNotFoundException("L'application n'existe pas."); } $orgId = $request->get('organizationId'); $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); + if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'Organization_id' => $orgId, + 'message' => "Organization not found for authorization removal." + ], $actingUser->getId()); + throw $this->createNotFoundException("L'Organization n'existe pas."); + } $application->removeOrganization($organization); - + $this->loggerService->logApplicationInformation('Application Authorized removed', [ + 'applicationId' => $application->getId(), + 'applicationName' => $application->getName(), + 'organizationId' => $organization->getId(), + 'message' => "Application authorized removed for organization." + ], $actingUser->getId()); $this->actionService->createAction("Authorization retirer", $actingUser, $organization, $application->getName()); return new Response('', Response::HTTP_OK); diff --git a/src/Controller/NotificationController.php b/src/Controller/NotificationController.php index 3c7ac0c..9195577 100644 --- a/src/Controller/NotificationController.php +++ b/src/Controller/NotificationController.php @@ -28,7 +28,7 @@ class NotificationController extends AbstractController #[Route(path: '/', name: 'index', methods: ['GET'])] public function index(): JsonResponse { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $notifications = $this->notificationRepository->findRecentByUser($user, 50); diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index db25e2c..8e388d2 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -251,4 +251,13 @@ readonly class LoggerService 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', ])); } + + public function logApplicationInformation(string $string, array $array, int $actingUser) + { + $this->accessControlLogger->info($string, array_merge($array, [ + 'acting_user_id' => $actingUser, + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown', + 'timestamp' => $this->now(), + ])); + } } From 55c42c81fa14b435386d6c9d094a3d2d80e91fbf Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 15:22:35 +0100 Subject: [PATCH 13/43] user service test --- .gitignore | 5 - HELPER.MD | 5 +- composer.json | 2 +- composer.lock | 681 ++++++++++++---------- phpunit.xml.dist | 24 +- src/Controller/OrganizationController.php | 8 +- src/Controller/UserController.php | 10 +- src/Service/LoggerService.php | 2 +- tests/Service/UserServiceTest.php | 369 ++++++++++++ 9 files changed, 772 insertions(+), 334 deletions(-) create mode 100644 tests/Service/UserServiceTest.php diff --git a/.gitignore b/.gitignore index dba7697..2fa901b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,11 +15,6 @@ .phpunit.result.cache ###< phpunit/phpunit ### -###> symfony/phpunit-bridge ### -.phpunit.result.cache -/phpunit.xml -###< symfony/phpunit-bridge ### - ###> symfony/asset-mapper ### /public/assets/ /assets/vendor/ diff --git a/HELPER.MD b/HELPER.MD index 0daee14..51e463c 100644 --- a/HELPER.MD +++ b/HELPER.MD @@ -35,4 +35,7 @@ - Chaque élément est une carte afin de donner un style uniforme : ``` html
-``` \ No newline at end of file +``` + + +php bin/console messenger:consume async -vv \ No newline at end of file diff --git a/composer.json b/composer.json index b0e1ac3..2d80707 100644 --- a/composer.json +++ b/composer.json @@ -106,7 +106,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.5", + "phpunit/phpunit": "^11.0", "symfony/browser-kit": "7.2.*", "symfony/css-selector": "7.2.*", "symfony/debug-bundle": "7.2.*", diff --git a/composer.lock b/composer.lock index 60a8c06..dfb1b38 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6694f361e2e9943b29abdd66ef40904a", + "content-hash": "1b2e89b80b579953618c7e61c6b76560", "packages": [ { "name": "aws/aws-crt-php", @@ -10050,16 +10050,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -10078,7 +10078,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -10102,9 +10102,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-07-27T20:03:57+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -10226,35 +10226,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "version": "11.0.11", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^11.5.2" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10263,7 +10263,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -10292,40 +10292,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-08-22T04:23:01+00:00" + "time": "2025-08-27T14:37:49+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10352,7 +10364,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" }, "funding": [ { @@ -10360,28 +10373,28 @@ "type": "github" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2024-08-27T05:02:59+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -10389,7 +10402,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10415,7 +10428,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -10423,32 +10437,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10474,7 +10488,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -10482,32 +10497,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10533,7 +10548,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -10541,54 +10557,52 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.23", + "version": "11.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", - "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "ext-soap": "To be able to generate mocks based on WSDL files" }, "bin": [ "phpunit" @@ -10596,7 +10610,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -10628,7 +10642,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" }, "funding": [ { @@ -10652,32 +10666,32 @@ "type": "tidelift" } ], - "time": "2025-05-02T06:40:34+00:00" + "time": "2025-12-06T08:01:15+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -10700,7 +10714,8 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -10708,32 +10723,32 @@ "type": "github" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "1.0.8", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -10756,7 +10771,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "funding": [ { @@ -10764,32 +10780,32 @@ "type": "github" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2025-03-19T07:56:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10811,7 +10827,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -10819,34 +10836,39 @@ "type": "github" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -10885,41 +10907,54 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2025-08-10T08:07:46+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10942,7 +10977,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -10950,33 +10986,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "4.0.6", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^11.0", "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11008,7 +11044,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -11016,27 +11053,27 @@ "type": "github" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -11044,7 +11081,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -11063,7 +11100,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -11071,42 +11108,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -11148,46 +11198,56 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -11206,13 +11266,14 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -11220,33 +11281,33 @@ "type": "github" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.4", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -11269,7 +11330,8 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -11277,34 +11339,34 @@ "type": "github" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11326,7 +11388,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -11334,32 +11397,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -11381,7 +11444,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -11389,32 +11453,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -11444,94 +11508,53 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2023-02-03T06:07:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" - }, - "funding": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "3.2.1", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -11554,37 +11577,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", - "version": "3.0.2", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -11607,7 +11643,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -11615,7 +11652,59 @@ "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/browser-kit", @@ -12161,16 +12250,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -12199,7 +12288,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -12207,7 +12296,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6c4bfed..3949e0e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,15 @@ - - + colors="true" + cacheDirectory=".phpunit.cache"> + - - @@ -23,16 +18,9 @@ - + src - - - - - - - - - + + \ No newline at end of file diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index a615a41..d8bdf91 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -103,7 +103,7 @@ class OrganizationController extends AbstractController $this->entityManager->persist($organization); $this->entityManager->flush(); $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Created"); - $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Created"); + $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Created", $organization->getId()); $this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } catch (Exception $e) { @@ -171,7 +171,7 @@ class OrganizationController extends AbstractController $this->entityManager->flush(); $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Edited"); if ($this->isGranted("ROLE_SUPER_ADMIN")) { - $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Edited"); + $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Edited", $organization->getId()); } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); @@ -268,7 +268,7 @@ class OrganizationController extends AbstractController // $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); $this->entityManager->persist($organization); $this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName()); - $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization deactivated'); + $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization deactivated', $organization->getId()); return $this->redirectToRoute('organization_index'); } @@ -289,7 +289,7 @@ class OrganizationController extends AbstractController $organization->setIsActive(true); $this->entityManager->persist($organization); $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Activated'); - $this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization Activated'); + $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Activated', $organization->getId()); $this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 00d7e6d..e3af065 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -218,7 +218,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), "Super Admin accessed user edit page", ); @@ -231,7 +230,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), "Super Admin accessed user edit page", ); @@ -295,9 +293,9 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $existingUser->getId(), - $org->getId(), $actingUser->getId(), "Super Admin linked user to organization", + $org->getId(), ); } return $this->redirectToRoute('organization_show', ['id' => $orgId]); @@ -329,7 +327,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), "Super Admin created new user", @@ -347,9 +344,9 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - $org->getId(), $actingUser->getId(), "Super Admin linked user to organization during creation", + $org->getId() ); } @@ -418,7 +415,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), 'Super admin deactivated user' ); @@ -441,7 +437,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), 'Super admin activated user' ); @@ -577,7 +572,6 @@ class UserController extends AbstractController if ($this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logSuperAdmin( $user->getId(), - null, $actingUser->getId(), 'Super admin deleted user' ); diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index 8e388d2..a610708 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -87,7 +87,7 @@ readonly class LoggerService ])); } - public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, string $message): void + public function logSuperAdmin(int $userId, int $actingUserId, string $message, ?int $orgId = null): void { $this->adminActionsLogger->notice($message, [ 'target_user_id' => $userId, diff --git a/tests/Service/UserServiceTest.php b/tests/Service/UserServiceTest.php new file mode 100644 index 0000000..c38f586 --- /dev/null +++ b/tests/Service/UserServiceTest.php @@ -0,0 +1,369 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->security = $this->createMock(Security::class); + $this->awsService = $this->createMock(AwsService::class); + $this->actionService = $this->createMock(ActionService::class); + $this->emailService = $this->createMock(EmailService::class); + $this->organizationsService = $this->createMock(OrganizationsService::class); + + // HANDLING READONLY LOGGER SERVICE + // PHPUnit 10+ generally handles readonly classes fine. + // If your LoggerService is 'final readonly', you cannot mock it easily. + // Assuming it is just 'readonly class LoggerService': + $this->loggerService = $this->createMock(LoggerService::class); + + $this->userService = new UserService( + $this->entityManager, + $this->security, + $this->awsService, + $this->loggerService, + $this->actionService, + $this->emailService, + $this->organizationsService + ); + } + + public function testGenerateRandomPassword(): void + { + $password = $this->userService->generateRandomPassword(); + $this->assertEquals(50, strlen($password)); + $this->assertMatchesRegularExpression('/[a-zA-Z0-9!@#$%^&*()_+]+/', $password); + } + + public function testIsUserConnectedReturnsTrueIfTokenValid(): void + { + $userIdentifier = 'test@example.com'; + + // Mock the Repository for AccessToken + $repo = $this->createMock(EntityRepository::class); + + // Mock a token that expires in the future + $token = $this->createMock(AccessToken::class); + $token->method('getExpiry')->willReturn(new \DateTimeImmutable('+1 hour')); + + $repo->expects($this->once()) + ->method('findBy') + ->with(['userIdentifier' => $userIdentifier, 'revoked' => false]) + ->willReturn([$token]); + + $this->entityManager->expects($this->once()) + ->method('getRepository') + ->with(AccessToken::class) + ->willReturn($repo); + + $result = $this->userService->isUserConnected($userIdentifier); + $this->assertTrue($result); + } + + public function testIsUserConnectedReturnsFalseIfTokenExpired(): void + { + $userIdentifier = 'test@example.com'; + $repo = $this->createMock(EntityRepository::class); + + $token = $this->createMock(AccessToken::class); + $token->method('getExpiry')->willReturn(new \DateTimeImmutable('-1 hour')); + + $repo->method('findBy')->willReturn([$token]); + $this->entityManager->method('getRepository')->willReturn($repo); + + $result = $this->userService->isUserConnected($userIdentifier); + $this->assertFalse($result); + } + + public function testGetUserByIdentifierFound(): void + { + $identifier = 'user@test.com'; + $user = new User(); + $user->setEmail($identifier); + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once()) + ->method('findOneBy') + ->with(['email' => $identifier]) + ->willReturn($user); + + $this->entityManager->method('getRepository')->with(User::class)->willReturn($repo); + + $result = $this->userService->getUserByIdentifier($identifier); + $this->assertSame($user, $result); + } + + public function testGetUserByIdentifierNotFound(): void + { + $identifier = 'unknown@test.com'; + + $repo = $this->createMock(EntityRepository::class); + $repo->method('findOneBy')->willReturn(null); + $this->entityManager->method('getRepository')->with(User::class)->willReturn($repo); + + // Expect Logger to be called + $this->loggerService->expects($this->once()) + ->method('logEntityNotFound') + ->with('User', ['user_identifier' => $identifier], null); + + $this->expectException(EntityNotFoundException::class); + $this->expectExceptionMessage(UserService::NOT_FOUND); + + $this->userService->getUserByIdentifier($identifier); + } + + public function testHasAccessToReturnsTrueForSuperAdmin(): void + { + $this->security->method('isGranted')->with('ROLE_SUPER_ADMIN')->willReturn(true); + $user = new User(); // Dummy user + $this->assertTrue($this->userService->hasAccessTo($user)); + } + + public function testHasAccessToReturnsTrueForSelf(): void + { + $this->security->method('isGranted')->willReturn(false); + + $currentUser = new User(); + $currentUser->setEmail('me@test.com'); + + $targetUser = new User(); + $targetUser->setEmail('me@test.com'); + + $this->security->method('getUser')->willReturn($currentUser); + + // skipSelfCheck = false (default) + $this->assertTrue($this->userService->hasAccessTo($targetUser)); + } + + public function testHandleProfilePictureUploadsAndLogs(): void + { + $user = new User(); + $user->setName('John'); + $user->setSurname('Doe'); + + // Mock UploadedFile + $file = $this->createMock(UploadedFile::class); + $file->method('guessExtension')->willReturn('jpg'); + + // Expect AWS Call + $this->awsService->expects($this->once()) + ->method('PutDocObj') + ->with( + $this->anything(), // ENV variable usually + $file, + $this->stringContains('JohnDoe_'), + 'jpg', + 'profile/' + ); + + // Expect Logger Call + $this->loggerService->expects($this->once()) + ->method('logAWSAction'); + + // Set fake ENV for test context if needed, or ignore the argument in mock + $_ENV['S3_PORTAL_BUCKET'] = 'test-bucket'; + + $this->userService->handleProfilePicture($user, $file); + + $this->assertStringContainsString('profile/JohnDoe_', $user->getPictureUrl()); + } + + public function testSyncUserRolesAddsRole(): void + { + $user = new User(); + $user->setRoles(['ROLE_USER']); + + $this->loggerService->expects($this->once())->method('logRoleAssignment'); + + $this->userService->syncUserRoles($user, 'ADMIN', true); + + $this->assertContains('ROLE_ADMIN', $user->getRoles()); + } + + public function testSyncUserRolesRemovesRole(): void + { + $user = new User(); + $user->setRoles(['ROLE_USER', 'ROLE_ADMIN']); + + // Mock repositories to ensure no other org gives this role + $repoUO = $this->createMock(EntityRepository::class); + $repoUO->method('findBy')->willReturn([]); // No active org links + + $this->entityManager->method('getRepository') + ->willReturnMap([ + [UsersOrganizations::class, $repoUO] + ]); + + $this->userService->syncUserRoles($user, 'ADMIN', false); + + $this->assertNotContains('ROLE_ADMIN', $user->getRoles()); + } + + public function testIsPasswordStrong(): void + { + $this->assertTrue($this->userService->isPasswordStrong('StrongP@ss1')); // Chars + Digits + Special + Length + $this->assertFalse($this->userService->isPasswordStrong('weak')); // Too short + $this->assertFalse($this->userService->isPasswordStrong('123456789')); // No letters + } + + public function testCreateNewUserSuccess(): void + { + $newUser = new User(); + $newUser->setName('jane'); + $newUser->setSurname('doe'); + $newUser->setEmail('jane@doe.com'); + + $actingUser = new User(); + $this->setEntityId($actingUser, 99); // Give acting user an ID + $actingUser->setEmail('admin@test.com'); + + // When persist is called, we force an ID onto $newUser to simulate DB insertion + $this->entityManager->expects($this->exactly(2)) + ->method('persist') + ->with($newUser) + ->willReturnCallback(function ($entity) { + $this->setEntityId($entity, 123); // Simulate DB assigning ID 123 + }); + + $this->entityManager->expects($this->exactly(2))->method('flush'); + + // Now expects ID 123 + $this->loggerService->expects($this->once()) + ->method('logUserCreated') + ->with(123, 99); + + $this->emailService->expects($this->once())->method('sendPasswordSetupEmail'); + $this->actionService->expects($this->once())->method('createAction'); + + $this->userService->createNewUser($newUser, $actingUser, null); + + // Assertions + $this->assertEquals('Jane', $newUser->getName()); + $this->assertEquals(123, $newUser->getId()); // Verify ID was "generated" + } + + public function testLinkUserToOrganization(): void + { + $user = new User(); + $this->setEntityId($user, 10); // Pre-set ID for existing user + + $org = new Organizations(); + $this->setEntityId($org, 50); // Pre-set ID for org + + $actingUser = new User(); + $this->setEntityId($actingUser, 99); + + // Capture the UsersOrganizations entity when it is persisted to give it an ID + $this->entityManager->expects($this->exactly(2)) + ->method('persist') + ->willReturnCallback(function ($entity) use ($user) { + if ($entity instanceof UsersOrganizations) { + // This is the UO entity link (Call 1) + $this->setEntityId($entity, 555); + } elseif ($entity instanceof User && $entity === $user) { + // This is the User entity inside generatePasswordToken (Call 2) + // The ID is already set, so we do nothing here. + } + }); + + $this->entityManager->expects($this->exactly(2))->method('flush'); + + + // Now the logger will receive valid Integers instead of null + $this->loggerService->expects($this->once()) + ->method('logUserOrganizationLinkCreated') + ->with(10, 50, 99, 555); + + $this->emailService->expects($this->once())->method('sendPasswordSetupEmail'); + $this->organizationsService->expects($this->once())->method('notifyOrganizationAdmins'); + + $result = $this->userService->linkUserToOrganization($user, $org, $actingUser); + + $this->assertInstanceOf(UsersOrganizations::class, $result); + $this->assertEquals(555, $result->getId()); + } + + public function testIsAdminOfOrganizationReturnsTrue(): void + { + $org = new Organizations(); + + $currentUser = new User(); + $currentUser->setEmail('admin@test.com'); + + // Mock Security User + $this->security->method('getUser')->willReturn($currentUser); + $this->security->method('isGranted')->with('ROLE_ADMIN')->willReturn(true); + + // 1. getUserByIdentifier (internal call) mocks + $userRepo = $this->createMock(EntityRepository::class); + $userRepo->method('findOneBy')->with(['email' => 'admin@test.com'])->willReturn($currentUser); + + // 2. UsersOrganizations mock + $uoRepo = $this->createMock(EntityRepository::class); + $uo = new UsersOrganizations(); + $uoRepo->method('findOneBy')->willReturn($uo); + + // 3. Roles mock + $rolesRepo = $this->createMock(EntityRepository::class); + $adminRole = new Roles(); + $adminRole->setName('ADMIN'); + $rolesRepo->method('findOneBy')->with(['name' => 'ADMIN'])->willReturn($adminRole); + + // 4. UserOrganizatonApp mock (The link checking if they are admin active) + $uoaRepo = $this->createMock(EntityRepository::class); + $uoa = new UserOrganizatonApp(); + $uoaRepo->method('findOneBy')->willReturn($uoa); // Returns an object, so true + + // Configure EntityManager to return these repos based on class + $this->entityManager->method('getRepository')->willReturnMap([ + [User::class, $userRepo], + [UsersOrganizations::class, $uoRepo], + [Roles::class, $rolesRepo], + [UserOrganizatonApp::class, $uoaRepo], + ]); + + $result = $this->userService->isAdminOfOrganization($org); + $this->assertTrue($result); + } + + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // Required for PHP < 8.1 + $property->setValue($entity, $id); + } +} \ No newline at end of file From 0df623ba17fbb7a88b12592c47f1f688d26edf45 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 15:25:46 +0100 Subject: [PATCH 14/43] userOrganization service test --- tests/Service/UserOrganizationServiceTest.php | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/Service/UserOrganizationServiceTest.php diff --git a/tests/Service/UserOrganizationServiceTest.php b/tests/Service/UserOrganizationServiceTest.php new file mode 100644 index 0000000..6761f63 --- /dev/null +++ b/tests/Service/UserOrganizationServiceTest.php @@ -0,0 +1,186 @@ +userOrganizationAppService = $this->createMock(UserOrganizationAppService::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->actionService = $this->createMock(ActionService::class); + $this->loggerService = $this->createMock(LoggerService::class); + + $this->service = new UserOrganizationService( + $this->userOrganizationAppService, + $this->entityManager, + $this->actionService, + $this->loggerService + ); + } + + /** + * Helper to set private ID property on entities via Reflection. + * Essential because your service calls getId() on entities. + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + $property->setValue($entity, $id); + } + } + + public function testDeactivateAllLinksByUser(): void + { + // 1. Setup Data + $actingUser = new User(); + $this->setEntityId($actingUser, 1); + + $targetUser = new User(); + $this->setEntityId($targetUser, 2); + + $org = new Organizations(); + $this->setEntityId($org, 100); + $org->setName('Test Org'); + + // Create a dummy UsersOrganizations link + $uo = new UsersOrganizations(); + $uo->setUsers($targetUser); + $uo->setOrganization($org); + $uo->setIsActive(true); + // Assuming there is an ID on UO, though not strictly used in the logic provided + $this->setEntityId($uo, 555); + + // 2. Mock Repository + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once()) + ->method('findBy') + ->with(['users' => $targetUser, 'isActive' => true]) + ->willReturn([$uo]); + + $this->entityManager->expects($this->once()) + ->method('getRepository') + ->with(UsersOrganizations::class) + ->willReturn($repo); + + // 3. Expect Side Effects on Dependencies + + // Expect deactivation of app links + $this->userOrganizationAppService->expects($this->once()) + ->method('deactivateAllUserOrganizationsAppLinks') + ->with($uo); + + // Expect Logging + $this->loggerService->expects($this->once()) + ->method('logOrganizationInformation') + ->with(100, 1, 'Uo link deactivated'); // OrgID, ActingUserID + + // Expect Persist + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($uo); + + // Expect Action Creation + $this->actionService->expects($this->once()) + ->method('createAction') + ->with("Deactivate UO link", $actingUser, $org, 'Test Org'); + + // 4. Run Method + $this->service->deactivateAllUserOrganizationLinks($actingUser, $targetUser, null); + + // 5. Assert State Change + $this->assertFalse($uo->isActive(), 'The user-organization link should have been set to inactive.'); + } + + public function testDeactivateAllLinksByOrganization(): void + { + // 1. Setup Data + $actingUser = new User(); + $this->setEntityId($actingUser, 1); + + $org = new Organizations(); + $this->setEntityId($org, 200); + $org->setName('Org B'); + + $uo1 = new UsersOrganizations(); + $uo1->setOrganization($org); + $uo1->setIsActive(true); + + $uo2 = new UsersOrganizations(); + $uo2->setOrganization($org); + $uo2->setIsActive(true); + + // 2. Mock Repository to return 2 items + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once()) + ->method('findBy') + ->with(['organization' => $org, 'isActive' => true]) + ->willReturn([$uo1, $uo2]); + + $this->entityManager->expects($this->once()) + ->method('getRepository') + ->with(UsersOrganizations::class) + ->willReturn($repo); + + // 3. Expect Side Effects (Called twice, once for each UO) + $this->userOrganizationAppService->expects($this->exactly(2)) + ->method('deactivateAllUserOrganizationsAppLinks'); + + $this->loggerService->expects($this->exactly(2)) + ->method('logOrganizationInformation'); + + $this->entityManager->expects($this->exactly(2)) + ->method('persist'); + + $this->actionService->expects($this->exactly(2)) + ->method('createAction'); + + // 4. Run Method (User is null, Organization is provided) + $this->service->deactivateAllUserOrganizationLinks($actingUser, null, $org); + + // 5. Assert State + $this->assertFalse($uo1->isActive()); + $this->assertFalse($uo2->isActive()); + } + + public function testDeactivateDoesNothingIfNoLinksFound(): void + { + $actingUser = new User(); + $targetUser = new User(); + + // Repo returns empty array + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn([]); + + $this->entityManager->method('getRepository')->willReturn($repo); + + // Ensure services are NEVER called + $this->userOrganizationAppService->expects($this->never())->method('deactivateAllUserOrganizationsAppLinks'); + $this->loggerService->expects($this->never())->method('logOrganizationInformation'); + $this->entityManager->expects($this->never())->method('persist'); + $this->actionService->expects($this->never())->method('createAction'); + + $this->service->deactivateAllUserOrganizationLinks($actingUser, $targetUser); + } +} \ No newline at end of file From 30901335d9edf91ff5ce07c06943d4277e54ee5c Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 15:45:35 +0100 Subject: [PATCH 15/43] userOrganizationApp service test --- .../UserOrganizationAppServiceTest.php | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/Service/UserOrganizationAppServiceTest.php diff --git a/tests/Service/UserOrganizationAppServiceTest.php b/tests/Service/UserOrganizationAppServiceTest.php new file mode 100644 index 0000000..49dbf19 --- /dev/null +++ b/tests/Service/UserOrganizationAppServiceTest.php @@ -0,0 +1,320 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->actionService = $this->createMock(ActionService::class); + $this->security = $this->createMock(Security::class); + $this->userService = $this->createMock(UserService::class); + $this->psrLogger = $this->createMock(LoggerInterface::class); + $this->loggerService = $this->createMock(LoggerService::class); + + $this->service = new UserOrganizationAppService( + $this->entityManager, + $this->actionService, + $this->security, + $this->userService, + $this->psrLogger, + $this->loggerService + ); + } + + /** + * Helper to set private ID property on entities. + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // Needed for PHP < 8.1 + $property->setValue($entity, $id); + } + } + + // ========================================== + // TEST: groupUserOrganizationAppsByApplication + // ========================================== + + public function testGroupUserOrganizationAppsByApplication(): void + { + // 1. Setup Apps + $app1 = new Apps(); $this->setEntityId($app1, 1); + $app2 = new Apps(); $this->setEntityId($app2, 2); // No roles for this one + + // 2. Setup Existing Link + $role = new Roles(); $this->setEntityId($role, 10); + + $uo = new UsersOrganizations(); $this->setEntityId($uo, 99); + + $uoa = new UserOrganizatonApp(); + $this->setEntityId($uoa, 500); + $uoa->setApplication($app1); + $uoa->setRole($role); + $uoa->setUserOrganization($uo); + + // 3. Run + $result = $this->service->groupUserOrganizationAppsByApplication( + [$uoa], + [$app1, $app2], + null + ); + + // 4. Assert + $this->assertArrayHasKey(1, $result); + $this->assertArrayHasKey(2, $result); + + // Check App 1 (Has existing link) + $this->assertEquals(99, $result[1]['uoId']); + $this->assertEquals([10], $result[1]['selectedRoleIds']); + + // Check App 2 (Empty default) + $this->assertNull($result[2]['uoId']); + $this->assertEmpty($result[2]['selectedRoleIds']); + } + + // ========================================== + // TEST: deactivateAllUserOrganizationsAppLinks + // ========================================== + + public function testDeactivateAllLinksSuccess(): void + { + $uo = new UsersOrganizations(); + $user = new User(); + $org = new Organizations(); + $uo->setUsers($user); + $uo->setOrganization($org); + + $app = new Apps(); + $this->setEntityId($app, 1); + $role = new Roles(); + $this->setEntityId($role, 10); + + $uoa = new UserOrganizatonApp(); + $this->setEntityId($uoa, 555); + $uoa->setApplication($app); + $uoa->setRole($role); + $uoa->setIsActive(true); + + // Mock Repository + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn([$uoa]); + $this->entityManager->method('getRepository')->willReturn($repo); + + // Expectations + $this->actionService->expects($this->once())->method('createAction'); + $this->entityManager->expects($this->once())->method('persist')->with($uoa); + $this->loggerService->expects($this->once())->method('logUOALinkDeactivated'); + + $this->service->deactivateAllUserOrganizationsAppLinks($uo, null); + + $this->assertFalse($uoa->isActive()); + } + + public function testDeactivateHandlesException(): void + { + $uo = new UsersOrganizations(); + + // The service needs a User to create an Action log + $user = new User(); + $this->setEntityId($user, 99); + $uo->setUsers($user); // <--- Assign the user! + + // Also needs an Org for the Action log + $org = new Organizations(); + $this->setEntityId($org, 88); + $uo->setOrganization($org); + + $app = new Apps(); $this->setEntityId($app, 1); + $role = new Roles(); $this->setEntityId($role, 1); + + $realUoa = new UserOrganizatonApp(); + $this->setEntityId($realUoa, 100); + $realUoa->setApplication($app); + $realUoa->setRole($role); + $realUoa->setIsActive(true); + + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn([$realUoa]); + $this->entityManager->method('getRepository')->willReturn($repo); + + // Throw exception on persist + $this->entityManager->method('persist')->willThrowException(new \Exception('DB Error')); + + // Expect Logger Critical + $this->loggerService->expects($this->once())->method('logCritical'); + + $this->service->deactivateAllUserOrganizationsAppLinks($uo); + } + + // ========================================== + // TEST: syncRolesForUserOrganizationApp + // ========================================== + + public function testSyncRolesAddsNewRole(): void + { + // Setup + $actingUser = new User(); $this->setEntityId($actingUser, 1); + $targetUser = new User(); $this->setEntityId($targetUser, 2); + + $org = new Organizations(); $this->setEntityId($org, 10); + $uo = new UsersOrganizations(); + $uo->setOrganization($org); + $uo->setUsers($targetUser); + + $app = new Apps(); $this->setEntityId($app, 5); + $app->setName('App1'); + + $roleId = 20; + $role = new Roles(); + $role->setName('EDITOR'); + $this->setEntityId($role, $roleId); + + // Mock Repositories + $uoaRepo = $this->createMock(EntityRepository::class); + $uoaRepo->method('findBy')->willReturn([]); // No existing roles + + $roleRepo = $this->createMock(EntityRepository::class); + $roleRepo->method('find')->with($roleId)->willReturn($role); + + $this->entityManager->method('getRepository')->willReturnMap([ + [UserOrganizatonApp::class, $uoaRepo], + [Roles::class, $roleRepo], + ]); + + // Expect creation + $this->entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(UserOrganizatonApp::class)); + $this->entityManager->expects($this->once())->method('flush'); + $this->actionService->expects($this->once())->method('createAction'); + + // Run + $this->service->syncRolesForUserOrganizationApp($uo, $app, [(string)$roleId], $actingUser); + } + + public function testSyncRolesDeactivatesUnselectedRole(): void + { + $actingUser = new User(); $this->setEntityId($actingUser, 1); + $targetUser = new User(); $this->setEntityId($targetUser, 2); + $org = new Organizations(); $this->setEntityId($org, 10); + + $uo = new UsersOrganizations(); + $uo->setOrganization($org); + $uo->setUsers($targetUser); + + $app = new Apps(); $this->setEntityId($app, 5); + $app->setName('App1'); + + // Existing active role + $role = new Roles(); $this->setEntityId($role, 30); + $role->setName('VIEWER'); + + $existingUoa = new UserOrganizatonApp(); + $this->setEntityId($existingUoa, 999); + $existingUoa->setRole($role); + $existingUoa->setApplication($app); + $existingUoa->setUserOrganization($uo); + $existingUoa->setIsActive(true); + + // Repos + $uoaRepo = $this->createMock(EntityRepository::class); + $uoaRepo->method('findBy')->willReturn([$existingUoa]); + + $this->entityManager->method('getRepository')->willReturnMap([ + [UserOrganizatonApp::class, $uoaRepo], + ]); + + // We pass empty array [] as selected roles -> expect deactivation + $this->service->syncRolesForUserOrganizationApp($uo, $app, [], $actingUser); + + $this->assertFalse($existingUoa->isActive()); + } + + public function testSyncRolesHandlesSuperAdminLogic(): void + { + // Setup + $actingUser = new User(); $this->setEntityId($actingUser, 1); + $targetUser = new User(); $this->setEntityId($targetUser, 2); + $uo = new UsersOrganizations(); + $uo->setUsers($targetUser); + + $org = new Organizations(); + $this->setEntityId($org, 500); // <--- Give the Org an ID! + $uo->setOrganization($org); + + $app = new Apps(); $this->setEntityId($app, 1); + $app->setName('Portal'); + + // Roles + $superAdminRole = new Roles(); + $superAdminRole->setName('SUPER ADMIN'); + $this->setEntityId($superAdminRole, 100); + + $adminRole = new Roles(); + $adminRole->setName('ADMIN'); + $this->setEntityId($adminRole, 101); + + // Repositories Configuration + $uoaRepo = $this->createMock(EntityRepository::class); + // 1. findBy (initial check) -> returns empty + // 2. findOneBy (inside ensureAdminRoleForSuperAdmin) -> returns null (Admin link doesn't exist yet) + $uoaRepo->method('findBy')->willReturn([]); + $uoaRepo->method('findOneBy')->willReturn(null); + + $roleRepo = $this->createMock(EntityRepository::class); + $roleRepo->method('find')->with(100)->willReturn($superAdminRole); + $roleRepo->method('findOneBy')->with(['name' => 'ADMIN'])->willReturn($adminRole); + + $this->entityManager->method('getRepository')->willReturnMap([ + [UserOrganizatonApp::class, $uoaRepo], + [Roles::class, $roleRepo], + ]); + + // Expectations + + // 1. UserService should be called to sync SUPER ADMIN + $this->userService->expects($this->once()) + ->method('syncUserRoles') + ->with($targetUser, 'SUPER ADMIN', true); + + // 2. EntityManager should persist: + // - The new SUPER ADMIN link + // - The new ADMIN link (automatically created) + $this->entityManager->expects($this->exactly(2)) + ->method('persist') + ->with($this->isInstanceOf(UserOrganizatonApp::class)); + + // Run + $this->service->syncRolesForUserOrganizationApp($uo, $app, ['100'], $actingUser); + } +} \ No newline at end of file From fdf52465fe7e091e810f47ae3be350bd8b6a3660 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 16:31:20 +0100 Subject: [PATCH 16/43] Organization service test --- tests/Service/OrganizationsServiceTest.php | 271 +++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 tests/Service/OrganizationsServiceTest.php diff --git a/tests/Service/OrganizationsServiceTest.php b/tests/Service/OrganizationsServiceTest.php new file mode 100644 index 0000000..d410144 --- /dev/null +++ b/tests/Service/OrganizationsServiceTest.php @@ -0,0 +1,271 @@ +awsService = $this->createMock(AwsService::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->uoRepository = $this->createMock(UsersOrganizationsRepository::class); + $this->notificationService = $this->createMock(NotificationService::class); + $this->emailNotificationLogger = $this->createMock(LoggerInterface::class); + $this->loggerService = $this->createMock(LoggerService::class); + + // Set the ENV variable used in the service + $_ENV['S3_PORTAL_BUCKET'] = 'test-bucket'; + + $this->service = new OrganizationsService( + $this->logoDirectory, + $this->awsService, + $this->entityManager, + $this->uoRepository, + $this->notificationService, + $this->emailNotificationLogger, + $this->loggerService + ); + } + + /** + * Helper to set private ID property via Reflection + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // PHP < 8.1 + $property->setValue($entity, $id); + } + } + + // ========================================== + // TEST: handleLogo + // ========================================== + + public function testHandleLogoSuccess(): void + { + $org = new Organizations(); + $this->setEntityId($org, 1); + $org->setName('MyOrg'); + + $file = $this->createMock(UploadedFile::class); + $file->method('guessExtension')->willReturn('png'); + + // Expect AWS Upload + $this->awsService->expects($this->once()) + ->method('PutDocObj') + ->with( + 'test-bucket', + $file, + $this->stringContains('MyOrg_'), // Filename check + 'png', + 'logo/' + ); + + // Expect Log + $this->loggerService->expects($this->once())->method('logAWSAction'); + + $this->service->handleLogo($org, $file); + + // Assert URL is set on entity + $this->assertStringContainsString('logo/MyOrg_', $org->getLogoUrl()); + } + + public function testHandleLogoThrowsException(): void + { + $org = new Organizations(); + $this->setEntityId($org, 1); + $org->setName('MyOrg'); + + $file = $this->createMock(UploadedFile::class); + $file->method('guessExtension')->willReturn('png'); + + // Simulate AWS Failure + $this->awsService->method('PutDocObj') + ->willThrowException(new FileException('S3 Down')); + + // Expect Error Log + $this->loggerService->expects($this->once()) + ->method('logError') + ->with('Failed to upload organization logo to S3', $this->anything()); + + $this->expectException(FileException::class); + $this->expectExceptionMessage('Failed to upload logo to S3: S3 Down'); + + $this->service->handleLogo($org, $file); + } + + // ========================================== + // TEST: appsAccess + // ========================================== + + public function testAppsAccess(): void + { + $app1 = new Apps(); $this->setEntityId($app1, 10); + $app2 = new Apps(); $this->setEntityId($app2, 20); + $app3 = new Apps(); $this->setEntityId($app3, 30); + + $allApps = [$app1, $app2, $app3]; + $orgApps = [$app2]; // Org only has access to App 2 + + $result = $this->service->appsAccess($allApps, $orgApps); + + $this->assertCount(3, $result); + + // App 1 -> False + $this->assertSame($app1, $result[0]['entity']); + $this->assertFalse($result[0]['hasAccess']); + + // App 2 -> True + $this->assertSame($app2, $result[1]['entity']); + $this->assertTrue($result[1]['hasAccess']); + + // App 3 -> False + $this->assertSame($app3, $result[2]['entity']); + $this->assertFalse($result[2]['hasAccess']); + } + + // ========================================== + // TEST: notifyOrganizationAdmins + // ========================================== + + public function testNotifyOrganizationAdminsUserAccepted(): void + { + // 1. Setup Data + $targetUser = new User(); $this->setEntityId($targetUser, 100); + + $adminUser = new User(); $this->setEntityId($adminUser, 999); + + $org = new Organizations(); $this->setEntityId($org, 50); + + $data = ['user' => $targetUser, 'organization' => $org]; + + // 2. Setup Admin Link (The user who IS admin) + $adminUO = new UsersOrganizations(); + $this->setEntityId($adminUO, 555); + $adminUO->setUsers($adminUser); + $adminUO->setOrganization($org); + + // 3. Setup Role Logic + $adminRole = new Roles(); $this->setEntityId($adminRole, 1); + $adminRole->setName('ADMIN'); + + // 4. Setup UOA Logic (Proof that user is Admin of an App) + $uoa = new UserOrganizatonApp(); + $this->setEntityId($uoa, 777); + $uoa->setUserOrganization($adminUO); + $uoa->setRole($adminRole); + $uoa->setIsActive(true); + + // 5. Mocks + // Mock Roles Repo + $rolesRepo = $this->createMock(EntityRepository::class); + $rolesRepo->method('findOneBy')->with(['name' => 'ADMIN'])->willReturn($adminRole); + + // Mock UO Repo (Find potential admins in org) + $this->uoRepository->expects($this->once()) + ->method('findBy') + ->with(['organization' => $org, 'isActive' => true]) + ->willReturn([$adminUO]); + + // Mock UOA Repo (Check if they have ADMIN role) + $uoaRepo = $this->createMock(EntityRepository::class); + $uoaRepo->method('findOneBy')->willReturn($uoa); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Roles::class, $rolesRepo], + [UserOrganizatonApp::class, $uoaRepo], + ]); + + // 6. Expectations + $this->notificationService->expects($this->once()) + ->method('notifyUserAcceptedInvite') + ->with($adminUser, $targetUser, $org); + + $this->loggerService->expects($this->once()) + ->method('logAdminNotified') + ->with([ + 'admin_user_id' => 999, + 'target_user_id' => 100, + 'organization_id' => 50, + 'case' => 'USER_ACCEPTED' + ]); + + // 7. Run + $result = $this->service->notifyOrganizationAdmins($data, 'USER_ACCEPTED'); + + // The service returns the last admin UO processed (based on loop) + $this->assertSame($adminUO, $result); + } + + /** + * This test ensures that if the admin is the SAME person as the target user, + * they do not get notified (Skip Self Check). + */ + public function testNotifyOrganizationAdminsSkipsSelf(): void + { + $user = new User(); $this->setEntityId($user, 100); + $org = new Organizations(); $this->setEntityId($org, 50); + + // Admin IS the user + $adminUO = new UsersOrganizations(); + $adminUO->setUsers($user); + + $roleAdmin = new Roles(); + + $uoa = new UserOrganizatonApp(); // active admin link + + // Mocks setup + $rolesRepo = $this->createMock(EntityRepository::class); + $rolesRepo->method('findOneBy')->willReturn($roleAdmin); + + $this->uoRepository->method('findBy')->willReturn([$adminUO]); + + $uoaRepo = $this->createMock(EntityRepository::class); + $uoaRepo->method('findOneBy')->willReturn($uoa); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Roles::class, $rolesRepo], + [UserOrganizatonApp::class, $uoaRepo], + ]); + + // Expectations: Notification service should NEVER be called + $this->notificationService->expects($this->never())->method('notifyUserAcceptedInvite'); + $this->loggerService->expects($this->never())->method('logAdminNotified'); + + $this->service->notifyOrganizationAdmins(['user' => $user, 'organization' => $org], 'USER_ACCEPTED'); + } +} \ No newline at end of file From 78583620fae6392ef93bf07dfda83150e9d8a46d Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 16:31:33 +0100 Subject: [PATCH 17/43] Correct monolog behavior --- src/Service/OrganizationsService.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index 41dc4a5..5ce58ae 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -115,7 +115,7 @@ class OrganizationsService ); $this->loggerService->logAdminNotified([ 'admin_user_id' =>$adminUO->getUsers()->getId(), - 'target_user_id' => $newUser->getId(), + 'target_user_id' => $invitedUser->getId(), 'organization_id' => $data['organization']->getId(),'case' =>$type]); } @@ -130,7 +130,7 @@ class OrganizationsService ); $this->loggerService->logAdminNotified([ 'admin_user_id' =>$adminUO->getUsers()->getId(), - 'target_user_id' => $newUser->getId(), + 'target_user_id' => $removedUser->getId(), 'organization_id' => $data['organization']->getId(),'case' =>$type]); } @@ -145,7 +145,7 @@ class OrganizationsService ); $this->loggerService->logAdminNotified([ 'admin_user_id' =>$adminUO->getUsers()->getId(), - 'target_user_id' => $newUser->getId(), + 'target_user_id' => $removedUser->getId(), 'organization_id' => $data['organization']->getId(),'case' =>$type]); } break; @@ -159,7 +159,7 @@ class OrganizationsService ); $this->loggerService->logAdminNotified([ 'admin_user_id' =>$adminUO->getUsers()->getId(), - 'target_user_id' => $newUser->getId(), + 'target_user_id' => $activatedUser->getId(), 'organization_id' => $data['organization']->getId(),'case' =>$type]); } break; From 70ef7175069e115daad00a164b7ee785d8fe73b8 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 16:45:31 +0100 Subject: [PATCH 18/43] Test for notification Service --- tests/Service/NotificationServiceTest.php | 239 ++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/Service/NotificationServiceTest.php diff --git a/tests/Service/NotificationServiceTest.php b/tests/Service/NotificationServiceTest.php new file mode 100644 index 0000000..6419f03 --- /dev/null +++ b/tests/Service/NotificationServiceTest.php @@ -0,0 +1,239 @@ +messageBus = $this->createMock(MessageBusInterface::class); + $this->service = new NotificationService($this->messageBus); + } + + /** + * Helper to inject IDs into entities without setters. + * Prevents "getId() on null" errors since we are not using a real DB. + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // Uncomment if using PHP < 8.1 + $property->setValue($entity, $id); + } + } + + public function testNotifyUserInvited(): void + { + // 1. Setup Data + $recipient = new User(); + $this->setEntityId($recipient, 1); + + $invitedUser = new User(); + $this->setEntityId($invitedUser, 2); + $invitedUser->setName('John'); + $invitedUser->setSurname('Doe'); + $invitedUser->setEmail('john@doe.com'); + + $org = new Organizations(); + $this->setEntityId($org, 100); + $org->setName('Acme Corp'); + + // 2. Expect Dispatch + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + // Verify the content of the dispatched message object + return $message->getType() === NotificationService::TYPE_USER_INVITED + && $message->getTitle() === 'Invitation envoyée' + && str_contains($message->getMessage(), 'John Doe a été invité à rejoindre Acme Corp') + && $message->getData()['userEmail'] === 'john@doe.com' + && $message->getOrganizationId() === 100; + })) + ->willReturn(new Envelope(new \stdClass())); // Dispatch returns an Envelope + + // 3. Run + $this->service->notifyUserInvited($recipient, $invitedUser, $org); + } + + public function testNotifyUserAcceptedInvite(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $acceptedUser = new User(); $this->setEntityId($acceptedUser, 2); + $acceptedUser->setName('Jane'); + $acceptedUser->setSurname('Smith'); + $acceptedUser->setEmail('jane@smith.com'); + + $org = new Organizations(); $this->setEntityId($org, 200); + $org->setName('TechGlobal'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_USER_ACCEPTED + && $message->getTitle() === 'Invitation acceptée' + && str_contains($message->getMessage(), 'Jane Smith a accepté l\'invitation à TechGlobal') + && $message->getData()['organizationName'] === 'TechGlobal'; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyUserAcceptedInvite($recipient, $acceptedUser, $org); + } + + public function testNotifyUserDeactivated(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $removedUser = new User(); $this->setEntityId($removedUser, 3); + $removedUser->setName('Bob'); + $removedUser->setSurname('Builder'); + + $org = new Organizations(); $this->setEntityId($org, 300); + $org->setName('BuildIt'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_USER_DEACTIVATED + && $message->getTitle() === 'Membre retiré' + && str_contains($message->getMessage(), 'Bob Builder a été désactivé de BuildIt') + && $message->getData()['userId'] === 3; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyUserDeactivated($recipient, $removedUser, $org); + } + + public function testNotifyUserActivated(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $activatedUser = new User(); $this->setEntityId($activatedUser, 4); + $activatedUser->setName('Alice'); + $activatedUser->setSurname('Wonder'); + + $org = new Organizations(); $this->setEntityId($org, 400); + $org->setName('Wonderland'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === 'user_activated' + && $message->getTitle() === 'Membre réactivé' + && str_contains($message->getMessage(), 'Alice Wonder a été réactivé dans Wonderland'); + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyUserActivated($recipient, $activatedUser, $org); + } + + public function testNotifyOrganizationUpdate(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $org = new Organizations(); $this->setEntityId($org, 500); + $org->setName('OrgUpdate'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_ORG_UPDATE + && $message->getTitle() === 'Organisation mise à jour' + && str_contains($message->getMessage(), 'L\'organisation OrgUpdate a été Renamed') + && $message->getData()['action'] === 'Renamed'; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyOrganizationUpdate($recipient, $org, 'Renamed'); + } + + public function testNotifyAppAccessChangedGranted(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $org = new Organizations(); $this->setEntityId($org, 600); + $org->setName('AppCorp'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_APP_ACCESS + && str_contains($message->getMessage(), 'L\'accès à Portal a été autorisé pour AppCorp') + && $message->getData()['granted'] === true; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyAppAccessChanged($recipient, $org, 'Portal', true); + } + + public function testNotifyAppAccessChangedRevoked(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $org = new Organizations(); $this->setEntityId($org, 600); + $org->setName('AppCorp'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getData()['granted'] === false + && str_contains($message->getMessage(), 'retiré'); + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyAppAccessChanged($recipient, $org, 'Portal', false); + } + + public function testNotifyRoleChanged(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $targetUser = new User(); $this->setEntityId($targetUser, 5); + $targetUser->setName('Tom'); + $targetUser->setSurname('Role'); + + $org = new Organizations(); $this->setEntityId($org, 700); + $org->setName('RoleOrg'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_ROLE_CHANGED + && $message->getTitle() === 'Rôle modifié' + && str_contains($message->getMessage(), 'Tom Role a été changé en ADMIN') + && $message->getData()['newRole'] === 'ADMIN'; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyRoleChanged($recipient, $targetUser, $org, 'ADMIN'); + } + + public function testNotifyUserDeleted(): void + { + $recipient = new User(); $this->setEntityId($recipient, 1); + $deletedUser = new User(); $this->setEntityId($deletedUser, 99); + $deletedUser->setName('Del'); + $deletedUser->setSurname('User'); + $deletedUser->setEmail('del@test.com'); + + // Test without organization (null) + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (NotificationMessage $message) { + return $message->getType() === NotificationService::TYPE_USER_REMOVED + && $message->getTitle() === 'Utilisateur supprimé' + && $message->getOrganizationId() === null + && $message->getData()['userEmail'] === 'del@test.com'; + })) + ->willReturn(new Envelope(new \stdClass())); + + $this->service->notifyUserDeleted($recipient, $deletedUser, null); + } +} \ No newline at end of file From 14366b5ed47b6cc78726e7037539bad9d97ac1f6 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 9 Dec 2025 16:48:42 +0100 Subject: [PATCH 19/43] Test for Logger Service --- tests/Service/LoggerServiceTest.php | 295 ++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/Service/LoggerServiceTest.php diff --git a/tests/Service/LoggerServiceTest.php b/tests/Service/LoggerServiceTest.php new file mode 100644 index 0000000..519e6e1 --- /dev/null +++ b/tests/Service/LoggerServiceTest.php @@ -0,0 +1,295 @@ +userManagementLogger = $this->createMock(LoggerInterface::class); + $this->organizationManagementLogger = $this->createMock(LoggerInterface::class); + $this->accessControlLogger = $this->createMock(LoggerInterface::class); + $this->emailNotificationLogger = $this->createMock(LoggerInterface::class); + $this->adminActionsLogger = $this->createMock(LoggerInterface::class); + $this->securityLogger = $this->createMock(LoggerInterface::class); + $this->errorLogger = $this->createMock(LoggerInterface::class); + $this->awsLogger = $this->createMock(LoggerInterface::class); + $this->requestStack = $this->createMock(RequestStack::class); + + $this->service = new LoggerService( + $this->userManagementLogger, + $this->organizationManagementLogger, + $this->accessControlLogger, + $this->emailNotificationLogger, + $this->adminActionsLogger, + $this->securityLogger, + $this->errorLogger, + $this->awsLogger, + $this->requestStack + ); + } + + /** + * Helper to simulate a request with a specific IP. + */ + private function mockRequestIp(?string $ip): void + { + if ($ip === null) { + $this->requestStack->method('getCurrentRequest')->willReturn(null); + } else { + $request = $this->createMock(Request::class); + $request->method('getClientIp')->willReturn($ip); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + } + } + + /** + * Helper assertion to check context contains basic fields + specific data. + */ + private function assertContextContains(array $expectedSubset): \PHPUnit\Framework\Constraint\Callback + { + return $this->callback(function (array $context) use ($expectedSubset) { + // Check Timestamp exists (we can't check exact value easily) + if (!isset($context['timestamp'])) { + return false; + } + + // Check IP exists + if (!isset($context['ip'])) { + return false; + } + + // Check specific keys + foreach ($expectedSubset as $key => $value) { + if (!array_key_exists($key, $context) || $context[$key] !== $value) { + return false; + } + } + + return true; + }); + } + + // ========================================== + // TESTS FOR USER MANAGEMENT LOGS + // ========================================== + + public function testLogUserCreated(): void + { + $this->mockRequestIp('127.0.0.1'); + + $this->userManagementLogger->expects($this->once()) + ->method('notice') + ->with( + "New user created: 10", + $this->assertContextContains([ + 'target_user_id' => 10, + 'acting_user_id' => 99, + 'ip' => '127.0.0.1' + ]) + ); + + $this->service->logUserCreated(10, 99); + } + + public function testLogCGUAcceptanceLogsToTwoChannels(): void + { + $this->mockRequestIp('192.168.1.1'); + $userId = 55; + + // Expect call on User Logger + $this->userManagementLogger->expects($this->once()) + ->method('info') + ->with("User accepted CGU", $this->assertContextContains(['user_id' => $userId])); + + // Expect call on Security Logger + $this->securityLogger->expects($this->once()) + ->method('info') + ->with("User accepted CGU", $this->assertContextContains(['user_id' => $userId])); + + $this->service->logCGUAcceptance($userId); + } + + // ========================================== + // TESTS FOR ORGANIZATION LOGS + // ========================================== + + public function testLogUserOrganizationLinkCreated(): void + { + $this->mockRequestIp('10.0.0.1'); + + $this->organizationManagementLogger->expects($this->once()) + ->method('notice') + ->with( + 'User-Organization link created', + $this->assertContextContains([ + 'target_user_id' => 1, + 'organization_id' => 2, + 'acting_user_id' => 3, + 'uo_id' => 4 + ]) + ); + + $this->service->logUserOrganizationLinkCreated(1, 2, 3, 4); + } + + // ========================================== + // TESTS FOR ERROR LOGS + // ========================================== + + public function testLogError(): void + { + $this->mockRequestIp('127.0.0.1'); + + $this->errorLogger->expects($this->once()) + ->method('error') + ->with( + 'Something failed', + $this->assertContextContains(['details' => 'foo']) + ); + + $this->service->logError('Something failed', ['details' => 'foo']); + } + + public function testLogEntityNotFoundHandlesGlobals(): void + { + $this->mockRequestIp('127.0.0.1'); + + // Simulate global server variable for REQUEST_URI + $_SERVER['REQUEST_URI'] = '/some/path'; + + $this->errorLogger->expects($this->once()) + ->method('error') + ->with( + 'Entity not found', + $this->assertContextContains([ + 'entity_type' => 'User', + 'id' => 123, + 'page_accessed' => '/some/path' + ]) + ); + + $this->service->logEntityNotFound('User', ['id' => 123], 1); + + // Cleanup global + unset($_SERVER['REQUEST_URI']); + } + + // ========================================== + // TESTS FOR SECURITY LOGS + // ========================================== + + public function testLogAccessDenied(): void + { + $this->mockRequestIp('10.10.10.10'); + + $this->securityLogger->expects($this->once()) + ->method('warning') + ->with( + 'Access denied', + $this->assertContextContains(['acting_user_id' => 5]) + ); + + $this->service->logAccessDenied(5); + } + + public function testLogTokenRevocation(): void + { + $this->mockRequestIp(null); // Test with NO REQUEST (e.g. CLI) + + $this->securityLogger->expects($this->once()) + ->method('warning') + ->with( + 'Token revoked', + $this->callback(function($context) { + return $context['ip'] === 'unknown' && $context['reason'] === 'expired'; + }) + ); + + $this->service->logTokenRevocation('Token revoked', ['reason' => 'expired']); + } + + // ========================================== + // TESTS FOR ADMIN ACTIONS + // ========================================== + + public function testLogSuperAdmin(): void + { + $this->mockRequestIp('1.2.3.4'); + + $this->adminActionsLogger->expects($this->once()) + ->method('notice') + ->with( + 'Global reset', + $this->assertContextContains([ + 'target_user_id' => 10, + 'acting_user_id' => 1, + 'organization_id' => null + ]) + ); + + $this->service->logSuperAdmin(10, 1, 'Global reset'); + } + + // ========================================== + // TESTS FOR AWS LOGS + // ========================================== + + public function testLogAWSAction(): void + { + $this->mockRequestIp('8.8.8.8'); + + $this->awsLogger->expects($this->once()) + ->method('info') + ->with( + 'AWS action performed: Upload', + $this->assertContextContains(['bucket' => 'my-bucket']) + ); + + $this->service->logAWSAction('Upload', ['bucket' => 'my-bucket']); + } + + // ========================================== + // TESTS FOR ACCESS CONTROL + // ========================================== + + public function testLogRoleEntityAssignment(): void + { + $this->mockRequestIp('127.0.0.1'); + + $this->accessControlLogger->expects($this->once()) + ->method('info') + ->with( + 'Role Assigned', + $this->assertContextContains([ + 'target_user_id' => 2, + 'organization_id' => 3, + 'role_id' => 4, + 'acting_user_id' => 1 + ]) + ); + + $this->service->logRoleEntityAssignment(2, 3, 4, 1, 'Role Assigned'); + } +} \ No newline at end of file From 8045ff03c817074e42623f2f68149387dadf4e3f Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 08:48:54 +0100 Subject: [PATCH 20/43] Test for emailService --- .idea/php.xml | 1 + tests/Service/EmailServiceTest.php | 225 +++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/Service/EmailServiceTest.php diff --git a/.idea/php.xml b/.idea/php.xml index d547306..69a5912 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -179,6 +179,7 @@ + diff --git a/tests/Service/EmailServiceTest.php b/tests/Service/EmailServiceTest.php new file mode 100644 index 0000000..64a8971 --- /dev/null +++ b/tests/Service/EmailServiceTest.php @@ -0,0 +1,225 @@ +mailer = $this->createMock(MailerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->loggerService = $this->createMock(LoggerService::class); + + $this->service = new EmailService( + $this->mailer, + $this->logger, + $this->urlGenerator, + $this->loggerService + ); + } + + /** + * Helper to set private ID property on entities. + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // Uncomment for PHP < 8.1 + $property->setValue($entity, $id); + } + } + + // ========================================== + // TEST: sendPasswordSetupEmail + // ========================================== + + public function testSendPasswordSetupEmailSuccess(): void + { + // 1. Setup Data + $user = new User(); + $this->setEntityId($user, 10); + $user->setEmail('new@user.com'); + + // Token format: "o{OrgId}@{RandomHex}" + // We use "o50@abcdef" to test that Org ID 50 is correctly extracted + $token = 'o50@abcdef123456'; + + // 2. Expect URL Generation + $this->urlGenerator->expects($this->once()) + ->method('generate') + ->with( + 'password_setup', + ['id' => 10, 'token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL + ) + ->willReturn('https://sudalys.fr/setup/10/token'); + + // 3. Expect Mailer Send + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (TemplatedEmail $email) use ($user, $token) { + // Verify Email Construction + $context = $email->getContext(); + + return $email->getTo()[0]->getAddress() === 'new@user.com' + && $email->getSubject() === 'Définissez votre mot de passe' + && $email->getHtmlTemplate() === 'emails/password_setup.html.twig' + && $context['user'] === $user + && $context['token'] === $token + && $context['linkUrl'] === 'https://sudalys.fr/setup/10/token'; + })); + + // 4. Expect Business Log (Success) + // Ensure the Org ID '50' was extracted from the token 'o50@...' + $this->loggerService->expects($this->once()) + ->method('logEmailSent') + ->with(10, 50, 'Password setup email sent.'); + + // 5. Run + $this->service->sendPasswordSetupEmail($user, $token); + } + + public function testSendPasswordSetupEmailWithoutOrgIdInToken(): void + { + $user = new User(); + $this->setEntityId($user, 10); + $user->setEmail('user@test.com'); + + // Token WITHOUT 'o' prefix -> Org ID should be null + $token = 'abcdef123456'; + + $this->urlGenerator->method('generate')->willReturn('https://link.com'); + + // Verify log receives null for Org ID + $this->loggerService->expects($this->once()) + ->method('logEmailSent') + ->with(10, null, 'Password setup email sent.'); + + $this->service->sendPasswordSetupEmail($user, $token); + } + + public function testSendPasswordSetupEmailHandlesException(): void + { + $user = new User(); + $this->setEntityId($user, 10); + $user->setEmail('fail@test.com'); + $token = 'token'; + + $this->urlGenerator->method('generate')->willReturn('http://link'); + + // Simulate Mailer Failure + $this->mailer->expects($this->once()) + ->method('send') + ->willThrowException(new TransportException('SMTP Error')); + + // Expect System Error Log + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Failed to send password setup email: SMTP Error')); + + // Ensure business log is NOT called (or called depending on where failure happens, + // in your code business log is AFTER mailer, so it should NOT be called) + $this->loggerService->expects($this->never())->method('logEmailSent'); + + // No exception should bubble up (caught in catch block) + $this->service->sendPasswordSetupEmail($user, $token); + } + + // ========================================== + // TEST: sendExistingUserNotificationEmail + // ========================================== + + public function testSendExistingUserNotificationEmailSuccess(): void + { + // 1. Setup Data + $user = new User(); + $this->setEntityId($user, 20); + $user->setEmail('existing@user.com'); + + $org = new Organizations(); + $this->setEntityId($org, 99); + $org->setName('My Organization'); + + $token = 'some-token'; + + // 2. Expect URL Generation + $this->urlGenerator->expects($this->once()) + ->method('generate') + ->with( + 'user_accept', + ['id' => 20, 'token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL + ) + ->willReturn('https://sudalys.fr/accept/20'); + + // 3. Expect Mailer Send + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (TemplatedEmail $email) use ($org) { + return $email->getTo()[0]->getAddress() === 'existing@user.com' + && $email->getSubject() === "Invitation à rejoindre l'organisation My Organization" + && $email->getContext()['expirationDays'] === 15; + })); + + // 4. Expect Business Log + $this->loggerService->expects($this->once()) + ->method('logEmailSent') + ->with(20, 99, 'Existing user notification email sent.'); + + // 5. Run + $this->service->sendExistingUserNotificationEmail($user, $org, $token); + } + + public function testSendExistingUserNotificationEmailHandlesException(): void + { + $user = new User(); + $this->setEntityId($user, 20); + $user->setEmail('fail@user.com'); + + $org = new Organizations(); + $this->setEntityId($org, 99); + + $this->urlGenerator->method('generate')->willReturn('link'); + + // In this specific method, your code logs success BEFORE sending email? + // Looking at source: + // $this->loggerService->logEmailSent(...); + // $this->mailer->send($email); + + // So we expect logEmailSent to be called even if mailer fails + $this->loggerService->expects($this->once())->method('logEmailSent'); + + $this->mailer->method('send') + ->willThrowException(new TransportException('Connection refused')); + + // Expect System Error Log + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Failed to send existing user notification email')); + + $this->service->sendExistingUserNotificationEmail($user, $org, 'token'); + } +} \ No newline at end of file From 2a09564323f6d0b08b8e8069e97ed9a325fd53e6 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 09:38:21 +0100 Subject: [PATCH 21/43] Test for CguService --- tests/Service/CguUserServiceTest.php | 219 +++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/Service/CguUserServiceTest.php diff --git a/tests/Service/CguUserServiceTest.php b/tests/Service/CguUserServiceTest.php new file mode 100644 index 0000000..cb8079e --- /dev/null +++ b/tests/Service/CguUserServiceTest.php @@ -0,0 +1,219 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->service = new CguUserService($this->entityManager); + } + + // ========================================== + // TEST: isLatestCguAccepted + // ========================================== + + public function testIsLatestCguAcceptedReturnsFalseIfNoCguExists(): void + { + $user = $this->createMock(User::class); + + // 1. Create a mock of your ACTUAL custom repository + // Since 'findLatestCgu' exists in CguRepository, PHPUnit allows this. + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn(null); + + $this->entityManager->method('getRepository') + ->with(Cgu::class) + ->willReturn($cguRepo); + + $this->assertFalse($this->service->isLatestCguAccepted($user)); + } + + public function testIsLatestCguAcceptedReturnsFalseIfRelationDoesNotExist(): void + { + $user = $this->createMock(User::class); + $latestCgu = new Cgu(); + + // Mock CguRepository + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn($latestCgu); + + // Mock Generic Repository for CguUser (standard findOneBy) + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->method('findOneBy') + ->with(['users' => $user, 'cgu' => $latestCgu]) + ->willReturn(null); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Cgu::class, $cguRepo], + [CguUser::class, $cguUserRepo], + ]); + + $this->assertFalse($this->service->isLatestCguAccepted($user)); + } + + public function testIsLatestCguAcceptedReturnsTrueIfAccepted(): void + { + $user = $this->createMock(User::class); + $latestCgu = new Cgu(); + + $cguUser = new CguUser(); + $cguUser->setIsAccepted(true); + + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn($latestCgu); + + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->method('findOneBy')->willReturn($cguUser); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Cgu::class, $cguRepo], + [CguUser::class, $cguUserRepo], + ]); + + $this->assertTrue($this->service->isLatestCguAccepted($user)); + } + + // ========================================== + // TEST: acceptLatestCgu + // ========================================== + + public function testAcceptLatestCguDoNothingIfNoCgu(): void + { + $user = $this->createMock(User::class); + + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn(null); + + $this->entityManager->method('getRepository')->willReturn($cguRepo); + + $this->entityManager->expects($this->never())->method('persist'); + $this->entityManager->expects($this->never())->method('flush'); + + $this->service->acceptLatestCgu($user); + } + + public function testAcceptLatestCguCreatesNewRelation(): void + { + $user = $this->createMock(User::class); + $latestCgu = new Cgu(); + + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn($latestCgu); + + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->method('findOneBy')->willReturn(null); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Cgu::class, $cguRepo], + [CguUser::class, $cguUserRepo], + ]); + + // Capture logic for persist + $capturedCguUser = null; + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($this->callback(function ($entity) use ($latestCgu, $user, &$capturedCguUser) { + // Check basic structure + if ($entity instanceof CguUser && $entity->getCgu() === $latestCgu && $entity->getUsers() === $user) { + $capturedCguUser = $entity; + return true; + } + return false; + })); + + $this->entityManager->expects($this->once())->method('flush'); + + $this->service->acceptLatestCgu($user); + + // Assert Final State (after setIsAccepted(true) was called) + $this->assertNotNull($capturedCguUser); + $this->assertTrue($capturedCguUser->isAccepted()); + } + + public function testAcceptLatestCguUpdatesExistingRelation(): void + { + $user = $this->createMock(User::class); + $latestCgu = new Cgu(); + + $cguUser = new CguUser(); + $cguUser->setIsAccepted(false); + + $cguRepo = $this->createMock(CguRepository::class); + $cguRepo->method('findLatestCgu')->willReturn($latestCgu); + + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->method('findOneBy')->willReturn($cguUser); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Cgu::class, $cguRepo], + [CguUser::class, $cguUserRepo], + ]); + + $this->entityManager->expects($this->never())->method('persist'); + $this->entityManager->expects($this->once())->method('flush'); + + $this->service->acceptLatestCgu($user); + + $this->assertTrue($cguUser->isAccepted()); + } + + // ========================================== + // TEST: declineCgu + // ========================================== + + public function testDeclineCguSuccess(): void + { + $user = $this->createMock(User::class); + $cgu = new Cgu(); + + $cguUser = new CguUser(); + $cguUser->setIsAccepted(true); + + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->expects($this->once()) + ->method('findOneBy') + ->with(['users' => $user, 'cgu' => $cgu]) + ->willReturn($cguUser); + + $this->entityManager->method('getRepository') + ->with(CguUser::class) + ->willReturn($cguUserRepo); + + $this->entityManager->expects($this->once())->method('flush'); + + $this->service->declineCgu($user, $cgu); + + $this->assertFalse($cguUser->isAccepted()); + } + + public function testDeclineCguThrowsExceptionIfNotFound(): void + { + $user = $this->createMock(User::class); + $cgu = new Cgu(); + + $cguUserRepo = $this->createMock(EntityRepository::class); + $cguUserRepo->method('findOneBy')->willReturn(null); + + $this->entityManager->method('getRepository')->willReturn($cguUserRepo); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('CGU not found for this user'); + + $this->service->declineCgu($user, $cgu); + } +} \ No newline at end of file From eeb82277f733a3f39542ff15f2e5840310b5194e Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 11:41:15 +0100 Subject: [PATCH 22/43] Test for AWS service --- tests/Service/AwsServiceTest.php | 235 +++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/Service/AwsServiceTest.php diff --git a/tests/Service/AwsServiceTest.php b/tests/Service/AwsServiceTest.php new file mode 100644 index 0000000..f0a6075 --- /dev/null +++ b/tests/Service/AwsServiceTest.php @@ -0,0 +1,235 @@ +mockHandler = new MockHandler(); + + // 2. Create a History container to capture the requests sent + $this->history = new History(); + + // 3. Instantiate S3Client passing the MockHandler DIRECTLY to 'handler' + $s3Client = new S3Client([ + 'region' => 'eu-west-3', + 'version' => 'latest', + 'handler' => $this->mockHandler, + 'credentials' => [ + 'key' => 'test', + 'secret' => 'test', + ], + ]); + + // 4. Attach the History middleware + $s3Client->getHandlerList()->appendSign(Middleware::history($this->history)); + + $this->service = new AwsService( + $s3Client, + 'https://s3.eu-west-3.amazonaws.com' + ); + } + + public function testGenerateUUIDv4(): void + { + $uuid = $this->service->generateUUIDv4(); + // Matches the static string we defined at the top of this file + $this->assertEquals('78832168-3015-4673-952c-745143093202', $uuid); + } + + public function testGetPublicUrl(): void + { + $result = $this->service->getPublicUrl('my-bucket'); + $this->assertEquals('https://my-bucket.s3.eu-west-3.amazonaws.com/', $result); + } + + // ========================================== + // TEST: createBucket + // ========================================== + + public function testCreateBucketSuccess(): void + { + // Queue a success response (200 OK) + $this->mockHandler->append(new Result(['@metadata' => ['statusCode' => 200]])); + + $result = $this->service->createBucket(); + + // Since we mocked uuid_create, we know EXACTLY what the bucket name will be + $expectedBucketName = '78832168-3015-4673-952c-745143093202'; + $this->assertEquals($expectedBucketName, $result); + + $this->assertCount(1, $this->history); + /** @var CommandInterface $cmd */ + $cmd = $this->history->getLastCommand(); + + $this->assertEquals('CreateBucket', $cmd->getName()); + $this->assertEquals('BucketOwnerPreferred', $cmd['ObjectOwnership']); + $this->assertEquals($expectedBucketName, $cmd['Bucket']); + } + + public function testCreateBucketFailure(): void + { + $this->mockHandler->append(new Result(['@metadata' => ['statusCode' => 403]])); + + $result = $this->service->createBucket(); + + $this->assertIsArray($result); + $this->assertEquals(403, $result['statusCode']); + } + + // ========================================== + // TEST: DeleteBucket + // ========================================== + + public function testDeleteBucket(): void + { + $this->mockHandler->append(new Result(['@metadata' => ['statusCode' => 200]])); + + $result = $this->service->DeleteBucket('test-bucket'); + + $this->assertEquals('test-bucket', $result); + + $cmd = $this->history->getLastCommand(); + $this->assertEquals('DeleteBucket', $cmd->getName()); + $this->assertEquals('test-bucket', $cmd['Bucket']); + } + + // ========================================== + // TEST: getListObject + // ========================================== + + public function testGetListObjectReturnsContents(): void + { + $this->mockHandler->append(new Result([ + 'Contents' => [ + ['Key' => 'file1.txt'], + ['Key' => 'file2.jpg'], + ] + ])); + + $result = $this->service->getListObject('my-bucket', 'prefix'); + + $this->assertCount(2, $result); + $this->assertEquals('file1.txt', $result[0]['Key']); + + $cmd = $this->history->getLastCommand(); + $this->assertEquals('ListObjectsV2', $cmd->getName()); + $this->assertEquals('my-bucket', $cmd['Bucket']); + $this->assertEquals('prefix', $cmd['Prefix']); + } + + // ========================================== + // TEST: PutDocObj + // ========================================== + + public function testPutDocObj(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'test_s3'); + file_put_contents($tempFile, 'dummy content'); + + $this->mockHandler->append(new Result(['@metadata' => ['statusCode' => 200]])); + + // Helper object to bypass strictly typed generic object hint + fopen + $fileObj = new class($tempFile) { + public function __construct(private $path) {} + public function __toString() { return $this->path; } + }; + + $status = $this->service->PutDocObj( + 'my-bucket', + $fileObj, + 'image.png', + 'image/png', + 'folder/' + ); + + $this->assertEquals(200, $status); + + $cmd = $this->history->getLastCommand(); + $this->assertEquals('PutObject', $cmd->getName()); + $this->assertEquals('folder/image.png', $cmd['Key']); + $this->assertNotEmpty($cmd['ChecksumSHA256']); + + @unlink($tempFile); + } + + // ========================================== + // TEST: renameDocObj + // ========================================== + + public function testRenameDocObj(): void + { + $this->mockHandler->append( + new Result(['@metadata' => ['statusCode' => 200]]), + new Result(['@metadata' => ['statusCode' => 204]]) + ); + + $status = $this->service->renameDocObj('b', 'old.txt', 'new.txt', 'p/'); + + $this->assertEquals(200, $status); + + $this->assertCount(2, $this->history); + $requests = iterator_to_array($this->history); + + /** @var CommandInterface $cmdCopy */ + $cmdCopy = $requests[0]['command']; + $this->assertEquals('CopyObject', $cmdCopy->getName()); + $this->assertEquals('p/new.txt', $cmdCopy['Key']); + + /** @var CommandInterface $cmdDelete */ + $cmdDelete = $requests[1]['command']; + $this->assertEquals('DeleteObject', $cmdDelete->getName()); + $this->assertEquals('p/old.txt', $cmdDelete['Key']); + } + + // ========================================== + // TEST: moveDocObj + // ========================================== + + public function testMoveDocObj(): void + { + $this->mockHandler->append( + new Result(['@metadata' => ['statusCode' => 200]]), + new Result(['@metadata' => ['statusCode' => 204]]) + ); + + $status = $this->service->moveDocObj('b', 'file.txt', 'old/', 'new/'); + + $this->assertEquals(200, $status); + + $requests = iterator_to_array($this->history); + + $cmdCopy = $requests[0]['command']; + $this->assertEquals('new/file.txt', $cmdCopy['Key']); + } +} \ No newline at end of file From ec561ef0a10d910031441e9018b67eeaaa95083a Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 11:49:15 +0100 Subject: [PATCH 23/43] Test for action service --- tests/Service/ActionServiceTest.php | 193 ++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/Service/ActionServiceTest.php diff --git a/tests/Service/ActionServiceTest.php b/tests/Service/ActionServiceTest.php new file mode 100644 index 0000000..3f6ef0e --- /dev/null +++ b/tests/Service/ActionServiceTest.php @@ -0,0 +1,193 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->service = new ActionService($this->entityManager); + } + + /** + * Helper to set private ID properties on entities without setters + */ + private function setEntityId(object $entity, int $id): void + { + $reflection = new \ReflectionClass($entity); + if ($reflection->hasProperty('id')) { + $property = $reflection->getProperty('id'); + // $property->setAccessible(true); // Uncomment for PHP < 8.1 + $property->setValue($entity, $id); + } + } + + // ========================================== + // TEST: getActivityColor + // ========================================== + + public function testGetActivityColorRecent(): void + { + // Less than 15 minutes ago + $date = new \DateTimeImmutable('-10 minutes'); + $color = $this->service->getActivityColor($date); + $this->assertEquals('#086572', $color); + } + + public function testGetActivityColorMedium(): void + { + // Between 15 and 60 minutes ago + $date = new \DateTimeImmutable('-30 minutes'); + $color = $this->service->getActivityColor($date); + $this->assertEquals('#247208', $color); + } + + public function testGetActivityColorOld(): void + { + // Older than 1 hour + $date = new \DateTimeImmutable('-2 hours'); + $color = $this->service->getActivityColor($date); + $this->assertEquals('#cc664c', $color); + } + + // ========================================== + // TEST: formatActivities + // ========================================== + + public function testFormatActivities(): void + { + $user = new User(); + $org = new Organizations(); + + $action1 = new Actions(); + $action1->setDate(new \DateTimeImmutable('-5 minutes')); // Recent + $action1->setActionType('LOGIN'); + $action1->setUsers($user); + $action1->setOrganization($org); + $action1->setDescription('User logged in'); + + $action2 = new Actions(); + $action2->setDate(new \DateTimeImmutable('-2 hours')); // Old + $action2->setActionType('LOGOUT'); + + $activities = [$action1, $action2]; + + $result = $this->service->formatActivities($activities); + + $this->assertCount(2, $result); + + // Check first activity (Recent) + $this->assertEquals('#086572', $result[0]['color']); + $this->assertEquals('LOGIN', $result[0]['actionType']); + $this->assertSame($user, $result[0]['users']); + $this->assertSame($org, $result[0]['organization']); + + // Check second activity (Old) + $this->assertEquals('#cc664c', $result[1]['color']); + $this->assertEquals('LOGOUT', $result[1]['actionType']); + } + + // ========================================== + // TEST: createAction + // ========================================== + + public function testCreateActionBasic(): void + { + $user = new User(); + $user->setEmail('user@test.com'); + + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($this->callback(function (Actions $action) use ($user) { + return $action->getActionType() === 'LOGIN' + && $action->getUsers() === $user + && $action->getOrganization() === null + && $action->getDescription() === null; + })); + + $this->entityManager->expects($this->once())->method('flush'); + + $this->service->createAction('LOGIN', $user); + } + + public function testCreateActionWithOrganizationAndTarget(): void + { + $user = new User(); + $user->setEmail('admin@test.com'); + + $org = new Organizations(); + $this->setEntityId($org, 99); + + // Expect persist with full details + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($this->callback(function (Actions $action) use ($user, $org) { + return $action->getActionType() === 'UPDATE' + && $action->getUsers() === $user + && $action->getOrganization() === $org + // Check description generated by descriptionAction + && str_contains($action->getDescription(), 'UPDATE by admin@test.com onto Settings'); + })); + + $this->entityManager->expects($this->once())->method('flush'); + + $this->service->createAction('UPDATE', $user, $org, 'Settings'); + } + + // ========================================== + // TEST: descriptionAction + // ========================================== + + public function testDescriptionActionSuccess(): void + { + $user = new User(); + $user->setEmail('jane@doe.com'); + + $action = new Actions(); + $action->setActionType('DELETE'); + $action->setUsers($user); + + // Pass by reference + $this->service->descriptionAction($action, 'Document.pdf'); + + $this->assertEquals( + 'DELETE by jane@doe.com onto Document.pdf', + $action->getDescription() + ); + } + + public function testDescriptionActionThrowsIfNoUser(): void + { + $action = new Actions(); + $action->setActionType('DELETE'); + // No user set + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Action must have a user set'); + + $this->service->descriptionAction($action, 'Target'); + } + + public function testDescriptionActionThrowsIfInvalidType(): void + { + $invalidObject = new \stdClass(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Action must be an instance of Actions'); + + // Pass an object that is NOT an instance of Actions entity + $this->service->descriptionAction($invalidObject, 'Target'); + } +} \ No newline at end of file From 76b3af7f2e13e24e3e851d3c1a2fbc7b4bc9d70b Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 11:53:51 +0100 Subject: [PATCH 24/43] Test for AccessToken service --- tests/Service/AccessTokenServiceTest.php | 134 +++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/Service/AccessTokenServiceTest.php diff --git a/tests/Service/AccessTokenServiceTest.php b/tests/Service/AccessTokenServiceTest.php new file mode 100644 index 0000000..ee7d6d9 --- /dev/null +++ b/tests/Service/AccessTokenServiceTest.php @@ -0,0 +1,134 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->loggerService = $this->createMock(LoggerService::class); + + $this->service = new AccessTokenService( + $this->entityManager, + $this->loggerService + ); + } + + public function testRevokeUserTokensSuccess(): void + { + $userIdentifier = 'test@user.com'; + + // 1. Create Mock Tokens + $token1 = $this->createMock(AccessToken::class); + $token1->method('getIdentifier')->willReturn('token_1'); + + $token2 = $this->createMock(AccessToken::class); + $token2->method('getIdentifier')->willReturn('token_2'); + + // 2. Mock Repository to return these tokens + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once()) + ->method('findBy') + ->with(['userIdentifier' => $userIdentifier, 'revoked' => false]) + ->willReturn([$token1, $token2]); + + $this->entityManager->expects($this->once()) + ->method('getRepository') + ->with(AccessToken::class) + ->willReturn($repo); + + // 3. Expect revoke() to be called on EACH token + $token1->expects($this->once())->method('revoke'); + $token2->expects($this->once())->method('revoke'); + + // 4. Expect success logs + $this->loggerService->expects($this->exactly(2)) + ->method('logTokenRevocation') + ->with( + 'Access token revoked for user', + $this->callback(function ($context) use ($userIdentifier) { + return $context['user_identifier'] === $userIdentifier + && in_array($context['token_id'], ['token_1', 'token_2']); + }) + ); + + // 5. Run + $this->service->revokeUserTokens($userIdentifier); + } + + public function testRevokeUserTokensHandlesException(): void + { + $userIdentifier = 'fail@user.com'; + + // 1. Create a Token that fails to revoke + $tokenBad = $this->createMock(AccessToken::class); + $tokenBad->method('getIdentifier')->willReturn('bad_token'); + + // Throw exception when revoke is called + $tokenBad->expects($this->once()) + ->method('revoke') + ->willThrowException(new \Exception('DB Connection Lost')); + + // 2. Create a Token that works (to prove loop continues, if applicable) + // Your code uses try-catch inside the loop, so it SHOULD continue. + $tokenGood = $this->createMock(AccessToken::class); + $tokenGood->method('getIdentifier')->willReturn('good_token'); + $tokenGood->expects($this->once())->method('revoke'); + + // 3. Mock Repository + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn([$tokenBad, $tokenGood]); + $this->entityManager->method('getRepository')->willReturn($repo); + + // 4. Expect Logger calls + // Expect 1 Error log + $this->loggerService->expects($this->once()) + ->method('logError') + ->with( + 'Error revoking access token: DB Connection Lost', + ['user_identifier' => $userIdentifier, 'token_id' => 'bad_token'] + ); + + // Expect 1 Success log (for the good token) + $this->loggerService->expects($this->once()) + ->method('logTokenRevocation') + ->with( + 'Access token revoked for user', + ['user_identifier' => $userIdentifier, 'token_id' => 'good_token'] + ); + + // 5. Run + $this->service->revokeUserTokens($userIdentifier); + } + + public function testRevokeUserTokensDoesNothingIfNoneFound(): void + { + $userIdentifier = 'ghost@user.com'; + + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn([]); // Empty array + + $this->entityManager->method('getRepository')->willReturn($repo); + + // Expect NO logs + $this->loggerService->expects($this->never())->method('logTokenRevocation'); + $this->loggerService->expects($this->never())->method('logError'); + + $this->service->revokeUserTokens($userIdentifier); + } +} \ No newline at end of file From 07bd064faaf959d2f4a44e4b5181907d7a3f5ccb Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 12:04:10 +0100 Subject: [PATCH 25/43] update pwd gen for better security --- src/Service/UserService.php | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Service/UserService.php b/src/Service/UserService.php index b2dee75..16c3c34 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -16,6 +16,7 @@ use Doctrine\ORM\EntityNotFoundException; use Exception; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use Random\RandomException; +use RuntimeException; use SebastianBergmann\CodeCoverage\Util\DirectoryCouldNotBeCreatedException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\File\Exception\FileException; @@ -46,16 +47,7 @@ class UserService */ public function generateRandomPassword(): string { - $length = 50; // Length of the password - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+'; - $charactersLength = strlen($characters); - $randomPassword = ''; - - for ($i = 0; $i < $length; $i++) { - $randomPassword .= $characters[random_int(0, $charactersLength - 1)]; - } - - return $randomPassword; + return bin2hex(random_bytes(32)); } @@ -478,7 +470,14 @@ class UserService $user->setEmail(trim($user->getEmail())); if($setPassword) { //FOR SETTING A DEFAULT RANDOM PASSWORD OF 50 CHARACTERS until user set his own password - $user->setPassword($this->generateRandomPassword()); + try { + $user->setPassword(bin2hex(random_bytes(50))); + } catch (RandomException $e) { + $this->loggerService->logError('Error generating random password: ' . $e->getMessage(), [ + 'target_user_id' => $user->getId(), + ]); + throw new RuntimeException('Error generating random password: ' . $e->getMessage()); + } } if($picture) { From 87ecf70d953090a1ffac9503dd6c5448bd26d6b7 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 10 Dec 2025 12:07:20 +0100 Subject: [PATCH 26/43] wrapped potential error in try catch --- src/Service/CguUserService.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Service/CguUserService.php b/src/Service/CguUserService.php index 279c33e..6e491b8 100644 --- a/src/Service/CguUserService.php +++ b/src/Service/CguUserService.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\Service\LoggerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Security\Core\User\UserInterface; use App\Entity\Cgu; @@ -9,7 +10,7 @@ use App\Entity\CguUser; class CguUserService { - public function __construct(private EntityManagerInterface $entityManager) + public function __construct(private EntityManagerInterface $entityManager, private readonly LoggerService $loggerService) { } @@ -40,11 +41,20 @@ class CguUserService $cguUser = $this->entityManager->getRepository(CguUser::class)->findOneBy(['users' => $user, 'cgu' => $latestCgu]); if (!$cguUser) { - // Create a new CguUser relation if it doesn't exist - $cguUser = new CguUser(); - $cguUser->setUsers($user); - $cguUser->setCgu($latestCgu); - $this->entityManager->persist($cguUser); + try{ + // Create a new CguUser relation if it doesn't exist + $cguUser = new CguUser(); + $cguUser->setUsers($user); + $cguUser->setCgu($latestCgu); + $this->entityManager->persist($cguUser); + }catch (\Exception $e){ + $this->loggerService->logError('CguUserService', [ + 'acceptLatestCgu' => 'Failed to create CguUser relation', + 'exception' => $e, + 'targer_user_id' => $user->getId(),]); + throw new \RuntimeException('Failed to create CguUser relation: ' . $e->getMessage()); + } + } $cguUser->setIsAccepted(true); From 271c2e31d1db957cd8eed7c299fea3e488c03a7a Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 09:03:14 +0100 Subject: [PATCH 27/43] refactor user service --- src/Event/UserCreatedEvent.php | 25 ++++++++ src/EventSubscriber/UserSubscriber.php | 55 +++++++++++++++++ src/Service/UserService.php | 81 +++++++++++++------------- 3 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 src/Event/UserCreatedEvent.php create mode 100644 src/EventSubscriber/UserSubscriber.php diff --git a/src/Event/UserCreatedEvent.php b/src/Event/UserCreatedEvent.php new file mode 100644 index 0000000..3f706f0 --- /dev/null +++ b/src/Event/UserCreatedEvent.php @@ -0,0 +1,25 @@ +newUser; + } + + public function getActingUser(): User + { + return $this->actingUser; + } +} diff --git a/src/EventSubscriber/UserSubscriber.php b/src/EventSubscriber/UserSubscriber.php new file mode 100644 index 0000000..3e27e14 --- /dev/null +++ b/src/EventSubscriber/UserSubscriber.php @@ -0,0 +1,55 @@ + 'onUserCreated', + ]; + } + + public function onUserCreated(UserCreatedEvent $event): void + { + $user = $event->getNewUser(); + $actingUser = $event->getActingUser(); + + // 1. Generate Token (If logic was moved here, otherwise assume UserService set it) + // If the token generation logic is still in UserService, just send the email here. + // If you moved generating the token here, do it now. + + // 2. Send Email + // Note: You might need to pass the token in the Event if it's not stored in the DB entity + // or generate a new one here if appropriate. + if ($user->getPasswordToken()) { + $this->emailService->sendPasswordSetupEmail($user, $user->getPasswordToken()); + } + + // 3. Log the creation + $this->loggerService->logUserCreated($user->getId(), $actingUser->getId()); + + // 4. Create the Audit Action + $this->actionService->createAction( + "Create new user", + $actingUser, + null, + $user->getUserIdentifier() + ); + } +} \ No newline at end of file diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 16c3c34..a8078a4 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -8,7 +8,6 @@ use App\Entity\Roles; use App\Entity\User; use App\Entity\UserOrganizatonApp; use App\Entity\UsersOrganizations; -use App\Service\AwsService; use DateTimeImmutable; use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; @@ -17,25 +16,24 @@ use Exception; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use Random\RandomException; use RuntimeException; -use SebastianBergmann\CodeCoverage\Util\DirectoryCouldNotBeCreatedException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\File\Exception\FileException; +use App\Event\UserCreatedEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class UserService { public const NOT_FOUND = 'Entity not found'; - public function __construct(private readonly EntityManagerInterface $entityManager, - private readonly Security $security, - private readonly AwsService $awsService, - private readonly LoggerService $loggerService, - private readonly ActionService $actionService, - private readonly EmailService $emailService, - private readonly OrganizationsService $organizationsService, - - - + public function __construct(private readonly EntityManagerInterface $entityManager, + private readonly Security $security, + private readonly AwsService $awsService, + private readonly LoggerService $loggerService, + private readonly ActionService $actionService, + private readonly EmailService $emailService, + private readonly OrganizationsService $organizationsService, + private readonly EventDispatcherInterface $eventDispatcher ) { @@ -185,9 +183,9 @@ class UserService try { $this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/'); $this->loggerService->logAWSAction( - 'Profile picture uploaded to S3',[ - 'user_id' => $user->getId(), - 'filename' => $customFilename, + 'Profile picture uploaded to S3', [ + 'user_id' => $user->getId(), + 'filename' => $customFilename, ]); $user->setPictureUrl('profile/' . $customFilename); } catch (FileException $e) { @@ -468,7 +466,7 @@ class UserService $user->setName(trim($user->getName())); $user->setSurname(trim($user->getSurname())); $user->setEmail(trim($user->getEmail())); - if($setPassword) { + if ($setPassword) { //FOR SETTING A DEFAULT RANDOM PASSWORD OF 50 CHARACTERS until user set his own password try { $user->setPassword(bin2hex(random_bytes(50))); @@ -480,7 +478,7 @@ class UserService } } - if($picture) { + if ($picture) { $this->handleProfilePicture($user, $picture); } } @@ -489,10 +487,11 @@ class UserService * Handle existing user being added to an organization */ public function addExistingUserToOrganization( - User $existingUser, + User $existingUser, Organizations $org, - User $actingUser, - ): int { + User $actingUser, + ): int + { try { $uoId = $this->handleExistingUser($existingUser, $org); @@ -513,9 +512,9 @@ class UserService return $uoId; } catch (\Exception $e) { $this->loggerService->logError('Error linking existing user to organization: ' . $e->getMessage(), [ - 'target_user_id' => $existingUser->getId(), + 'target_user_id' => $existingUser->getId(), 'organization_id' => $org->getId(), - 'acting_user_id' => $actingUser->getId(), + 'acting_user_id' => $actingUser->getId(), ]); throw $e; } @@ -528,17 +527,20 @@ class UserService { try { $this->formatUserData($user, $picture, true); + + // Generate token here if it's part of the user persistence flow + $token = $this->generatePasswordToken($user); + $this->entityManager->persist($user); $this->entityManager->flush(); + + $this->eventDispatcher->dispatch(new UserCreatedEvent($user, $actingUser)); - $this->loggerService->logUserCreated($user->getId(), $actingUser->getId()); - $token = $this->generatePasswordToken($user); - $this->emailService->sendPasswordSetupEmail($user, $token); - $this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier()); } catch (\Exception $e) { + // Error logging remains here because the event won't fire if exception occurs $this->loggerService->logError('Error creating new user: ' . $e->getMessage(), [ 'target_user_email' => $user->getEmail(), - 'acting_user_id' => $actingUser->getId(), + 'acting_user_id' => $actingUser->getId(), ]); throw $e; } @@ -548,10 +550,11 @@ class UserService * Link newly created user to an organization */ public function linkUserToOrganization( - User $user, + User $user, Organizations $org, - User $actingUser, - ): UsersOrganizations { + User $actingUser, + ): UsersOrganizations + { try { $uo = new UsersOrganizations(); $uo->setUsers($user); @@ -567,7 +570,7 @@ class UserService $org->getId(), $actingUser->getId(), $uo->getId(), - + ); $this->actionService->createAction( @@ -582,9 +585,9 @@ class UserService return $uo; } catch (\Exception $e) { $this->loggerService->logError('Error linking user to organization: ' . $e->getMessage(), [ - 'target_user_id' => $user->getId(), + 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), - 'acting_user_id' => $actingUser->getId(), + 'acting_user_id' => $actingUser->getId(), ]); throw $e; } @@ -597,11 +600,11 @@ class UserService $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendExistingUserNotificationEmail($user, $org, $token); $this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId()); - $this->organizationsService->notifyOrganizationAdmins(['user'=> $user, 'acting_user_id'=>$actingUser->getId(), - 'organization'=> $org], 'USER_INVITED'); + $this->organizationsService->notifyOrganizationAdmins(['user' => $user, 'acting_user_id' => $actingUser->getId(), + 'organization' => $org], 'USER_INVITED'); } catch (\Exception $e) { $this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [ - 'target_user_id' => $user->getId(), + 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), ]); } @@ -612,11 +615,11 @@ class UserService try { $token = $this->generatePasswordToken($user, $org->getId()); $this->emailService->sendPasswordSetupEmail($user, $token); - $this->organizationsService->notifyOrganizationAdmins(['user'=> $user, 'acting_user_id'=>$actingUser->getId(), - 'organization'=> $org], 'USER_INVITED'); + $this->organizationsService->notifyOrganizationAdmins(['user' => $user, 'acting_user_id' => $actingUser->getId(), + 'organization' => $org], 'USER_INVITED'); } catch (\Exception $e) { $this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [ - 'target_user_id' => $user->getId(), + 'target_user_id' => $user->getId(), 'organization_id' => $org->getId(), ]); } From cdd61123ea61c696379b19ccc82a75bc759f9136 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 10:17:09 +0100 Subject: [PATCH 28/43] solve refactor problem --- templates/user/userInformation.html.twig | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/templates/user/userInformation.html.twig b/templates/user/userInformation.html.twig index cf0398e..c4f50c9 100644 --- a/templates/user/userInformation.html.twig +++ b/templates/user/userInformation.html.twig @@ -13,26 +13,6 @@
{% if canEdit %} - {% if organizationId is not null %} - {% if uoActive %} -
- - -
- {% else %} -
- - -
- {% endif %} - {% endif %} - - Modifier {% endif %} From 9af81b1d2c6158e6ba4fd9d01b47b17943213722 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 11:00:13 +0100 Subject: [PATCH 29/43] fix actions displaying incorrectly --- src/Controller/OrganizationController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index d8bdf91..45fadef 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -210,7 +210,7 @@ class OrganizationController extends AbstractController $apps = $this->organizationsService->appsAccess($allApps, $orgApps); - $actions = $this->entityManager->getRepository(Actions::class)->findBy(['Organization' => $organization], limit: 15); + $actions = $this->entityManager->getRepository(Actions::class)->findBy(['Organization' => $organization], orderBy: ['date' => 'DESC'], limit: 15); $activities = $this->actionService->formatActivities($actions); $this->actionService->createAction("View Organization", $actingUser, $organization, $organization->getName()); From b5d56f1d85150e224c513ac89b89d2c4b1668ed9 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 13:52:54 +0100 Subject: [PATCH 30/43] put activities in an ajax call --- assets/controllers/organization_controller.js | 73 ++++++++++++++++++- src/Controller/ActionController.php | 34 +++++++++ src/Service/ActionService.php | 8 +- templates/organization/activity.html.twig | 25 ------- templates/organization/index.html.twig | 1 + templates/organization/show.html.twig | 43 ++++++++--- .../user/organization/userActivity.html.twig | 17 ----- 7 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 src/Controller/ActionController.php delete mode 100644 templates/organization/activity.html.twig delete mode 100644 templates/user/organization/userActivity.html.twig diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 446de27..126d9d3 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -4,10 +4,24 @@ import {TabulatorFull as Tabulator} from 'tabulator-tables'; import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js"; export default class extends Controller { - static values = {aws: String}; + static values = {aws: String, + id: String, + activities: Boolean, + table: Boolean, + }; + static targets = ["activityList", "emptyMessage"] connect() { - this.table(); + if(this.activitiesValue){ + this.loadActivities(); + setInterval(() => { + this.loadActivities(); + }, 5000); // Refresh every 60 seconds + } + if (this.tableValue){ + this.table(); + } + } table(){ @@ -82,4 +96,59 @@ export default class extends Controller { }], }); } + + async loadActivities() { + try { + // 1. Fetch the data using the ID from values + const response = await fetch(`/actions/organization/${this.idValue}/activities-ajax`); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const activities = await response.json(); + + // 2. Render + this.renderActivities(activities); + + } catch (error) { + console.error('Error fetching activities:', error); + this.activityListTarget.innerHTML = `
Erreur lors du chargement.
`; + } + } + + renderActivities(activities) { + // Clear the loading spinner + this.activityListTarget.innerHTML = ''; + + if (activities.length === 0) { + // Show empty message + this.activityListTarget.innerHTML = this.emptyMessageTarget.innerHTML; + return; + } + + // Loop through JSON and build HTML + const html = activities.map(activity => { + return ` +
+ +
+
+ ${activity.date} +
+
+ +
+
+ ${activity.userName} +
${activity.actionType}
+
+
+
+ `; + }).join(''); + + this.activityListTarget.innerHTML = html; + } } \ No newline at end of file diff --git a/src/Controller/ActionController.php b/src/Controller/ActionController.php new file mode 100644 index 0000000..b01b613 --- /dev/null +++ b/src/Controller/ActionController.php @@ -0,0 +1,34 @@ +entityManager->getRepository(Actions::class)->findBy( + ['Organization' => $organization], + ['date' => 'DESC'], + 15 + ); + $formattedActivities = $this->actionService->formatActivities($actions); + + return new JsonResponse($formattedActivities); + } +} diff --git a/src/Service/ActionService.php b/src/Service/ActionService.php index f0a7a44..7b750f9 100644 --- a/src/Service/ActionService.php +++ b/src/Service/ActionService.php @@ -40,11 +40,11 @@ readonly class ActionService { return array_map(function (Actions $activity) { return [ - 'date' => $activity->getDate(), + 'date' => $activity->getDate()->format('d/m/Y H:i'), 'actionType' => $activity->getActionType(), - 'users' => $activity->getUsers(), - 'organization' => $activity->getOrganization(), - 'description' => $activity->getDescription(), + 'userName' => $activity->getUsers()->getName(), +// 'organization' => $activity->getOrganization(), +// 'description' => $activity->getDescription(), 'color' => $this->getActivityColor($activity->getDate()) ]; }, $activities); diff --git a/templates/organization/activity.html.twig b/templates/organization/activity.html.twig deleted file mode 100644 index 3e8723f..0000000 --- a/templates/organization/activity.html.twig +++ /dev/null @@ -1,25 +0,0 @@ -{% block body %} - - -
-
-

{{ title }}

-
-
- {% if activities|length == 0 %} -

Aucune activité récente.

- {% else %} - {% set sortedActivities = activities|sort((a, b) => a.date <=> b.date)|reverse %} -
    - {% for activity in sortedActivities%} - {% include 'user/organization/userActivity.html.twig' with { - activityTime: activity.date, - action: activity.actionType, - userName: activity.users.name, - color: activity.color - } %} - {% endfor %} -
- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index e47f594..3e09ad9 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -35,6 +35,7 @@ {% else %}
diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index 9704040..fe48db6 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -39,9 +39,9 @@
-{# single row so that activity and users tabs are next to each other#} + {# single row so that activity and users tabs are next to each other #}
-{# User tables #} + {# User tables #}
@@ -49,7 +49,8 @@

Nouveaux utilisateurs

- Ajouter un utilisateur + Ajouter un utilisateur
{# APPLICATION ROW #} -{# TODO: Weird gap not going away#} + {# TODO: Weird gap not going away #}
{% for application in applications %}
@@ -104,17 +105,37 @@ {% endfor %}
-{# Activities col#} + {# Activities col #}
- {% include 'organization/activity.html.twig' with { - title: 'Activités récentes', - empty_message: 'Aucune activité récente.' - } %} +
+ +
+

Activité récente

+ + +
+ +
+
+
+ +
+
+ + {# Empty state #} +
+
Aucune activité récente.
+
+
+
-{# Ne pas enlever le 2ème /div#} -
diff --git a/templates/user/organization/userActivity.html.twig b/templates/user/organization/userActivity.html.twig deleted file mode 100644 index aa12274..0000000 --- a/templates/user/organization/userActivity.html.twig +++ /dev/null @@ -1,17 +0,0 @@ -{% block body %} - -
-
-
- -

- - {{ activityTime|ago }}

-
-
-
-

{{ userName }} - {{ action }}

-
-
- -{% endblock %} \ No newline at end of file From 0b8890e3d70879fe552d868d01c54f722ad66d47 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 13:53:19 +0100 Subject: [PATCH 31/43] typo --- src/Service/NotificationService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/NotificationService.php b/src/Service/NotificationService.php index c824b1e..49c23cd 100644 --- a/src/Service/NotificationService.php +++ b/src/Service/NotificationService.php @@ -65,7 +65,7 @@ class NotificationService $this->send( recipient: $recipient, type: self::TYPE_USER_DEACTIVATED, - title: 'Membre retiré', + title: 'Membre désactivé', message: sprintf('%s %s a été désactivé de %s', $removedUser->getName(), $removedUser->getSurname(), $organization->getName()), data: [ 'userId' => $removedUser->getId(), From 12f2b39ccd37433e2ef4cfd08c178bd59634226c Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 14:16:14 +0100 Subject: [PATCH 32/43] added rate limiter for log in --- .idea/Easy_solution.iml | 1 + .idea/php.xml | 1 + composer.json | 1 + composer.lock | 76 +++++++++++++++++++++++++++++++- config/packages/security.yaml | 3 ++ config/packages/translation.yaml | 2 +- translations/security.fr.yaml | 1 + 7 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 translations/security.fr.yaml diff --git a/.idea/Easy_solution.iml b/.idea/Easy_solution.iml index c9acc92..5c18800 100644 --- a/.idea/Easy_solution.iml +++ b/.idea/Easy_solution.iml @@ -18,6 +18,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 69a5912..26549ad 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -180,6 +180,7 @@ + diff --git a/composer.json b/composer.json index 2d80707..20e0ac9 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "symfony/process": "7.2.*", "symfony/property-access": "7.2.*", "symfony/property-info": "7.2.*", + "symfony/rate-limiter": "7.2.*", "symfony/runtime": "7.2.*", "symfony/security-bundle": "7.2.*", "symfony/serializer": "7.2.*", diff --git a/composer.lock b/composer.lock index dfb1b38..8611449 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1b2e89b80b579953618c7e61c6b76560", + "content-hash": "00e62b0a959e7b09d4b1fdb7e0501549", "packages": [ { "name": "aws/aws-crt-php", @@ -7684,6 +7684,80 @@ ], "time": "2024-09-26T08:57:56+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v7.2.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "daae5da398aca84809aa6088371314a9cb88b42e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/daae5da398aca84809aa6088371314a9cb88b42e", + "reference": "daae5da398aca84809aa6088371314a9cb88b42e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^6.4|^7.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.2.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:29:33+00:00" + }, { "name": "symfony/routing", "version": "v7.2.9", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 18e4e11..58afd30 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -42,6 +42,9 @@ security: user_checker: App\Security\UserChecker lazy: true provider: app_user_provider + login_throttling: + max_attempts: 3 + interval: '1 minute' form_login: login_path: app_login check_path: app_login diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index b3f8f9c..0af682d 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,5 +1,5 @@ framework: - default_locale: en + default_locale: fr translator: default_path: '%kernel.project_dir%/translations' fallbacks: diff --git a/translations/security.fr.yaml b/translations/security.fr.yaml new file mode 100644 index 0000000..2645ca3 --- /dev/null +++ b/translations/security.fr.yaml @@ -0,0 +1 @@ +"Too many failed login attempts, please try again later.": "Trop de tentatives de connexion. Veuillez réessayer plus tard." \ No newline at end of file From 923d36ba4ec09f40c78a2d30782332700e82736e Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 14:24:09 +0100 Subject: [PATCH 33/43] Adapt action test to previous refactor --- tests/Service/ActionServiceTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Service/ActionServiceTest.php b/tests/Service/ActionServiceTest.php index 3f6ef0e..ce04d51 100644 --- a/tests/Service/ActionServiceTest.php +++ b/tests/Service/ActionServiceTest.php @@ -80,6 +80,7 @@ class ActionServiceTest extends TestCase $action2 = new Actions(); $action2->setDate(new \DateTimeImmutable('-2 hours')); // Old + $action2->setUsers($user); $action2->setActionType('LOGOUT'); $activities = [$action1, $action2]; @@ -91,8 +92,7 @@ class ActionServiceTest extends TestCase // Check first activity (Recent) $this->assertEquals('#086572', $result[0]['color']); $this->assertEquals('LOGIN', $result[0]['actionType']); - $this->assertSame($user, $result[0]['users']); - $this->assertSame($org, $result[0]['organization']); + $this->assertSame($user->getName(), $result[0]['userName']); // Check second activity (Old) $this->assertEquals('#cc664c', $result[1]['color']); From cb8eabef4df79b4d149497e3ba618eca9cf1e74e Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 15 Dec 2025 15:48:27 +0100 Subject: [PATCH 34/43] set up controller test for action --- tests/Controller/ActionControllerTest.php | 106 ++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/Controller/ActionControllerTest.php diff --git a/tests/Controller/ActionControllerTest.php b/tests/Controller/ActionControllerTest.php new file mode 100644 index 0000000..f320a2c --- /dev/null +++ b/tests/Controller/ActionControllerTest.php @@ -0,0 +1,106 @@ +client = static::createClient(); + + // Retrieve the EntityManager from the test container + $this->entityManager = static::getContainer()->get('doctrine')->getManager(); + } + + /** + * Helper to create a valid User entity with all required fields and log them in. + */ + private function authenticateUser(): User + { + $user = new User(); + $user->setEmail('test_' . uniqid() . '@example.com'); // Ensure uniqueness + $user->setPassword('secure_password'); + $user->setName('Test'); + $user->setSurname('User'); + $user->setRoles(['ROLE_USER']); + + // Defaults (isActive, isDeleted, dates) are handled by the User constructor + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $this->client->loginUser($user); + + return $user; + } + + #[Test] + public function fetch_activities_ajax_returns_json_response(): void + { + // 1. Arrange: Authenticate + $user = $this->authenticateUser(); + + // 2. Arrange: Create a valid Organization + $organization = new Organizations(); + $organization->setName('Test Corp'); + $organization->setEmail('contact@testcorp.com'); + $organization->setNumber(101); // Required int + $organization->setAddress('123 Main St'); // Required string + $organization->setLogoUrl('logo.png'); // Required string + // Defaults (isActive, isDeleted, collections) handled by Constructor + + $this->entityManager->persist($organization); + + // 3. Arrange: Create an Action linked to the Organization + $action = new Actions(); + $action->setOrganization($organization); // Link to the org + $action->setUsers($user); // Link to the user + $action->setActionType('UPDATE'); // Required string + $action->setDescription('Updated profile details'); + // Date is set automatically in __construct + + $this->entityManager->persist($action); + $this->entityManager->flush(); + + // 4. Act: Request the URL using the Organization ID + $url = sprintf('/actions/organization/%d/activities-ajax', $organization->getId()); + $this->client->request('GET', $url); + + // 5. Assert: Verify Success + $this->assertResponseIsSuccessful(); // Status 200 + $this->assertResponseHeaderSame('content-type', 'application/json'); + + // 6. Assert: Verify JSON Content + $responseContent = $this->client->getResponse()->getContent(); + $this->assertJson($responseContent); + + $data = json_decode($responseContent, true); + + // Since we created 1 action, we expect the array to be non-empty + $this->assertIsArray($data); + $this->assertNotEmpty($data); + } + + #[Test] + public function fetch_activities_returns_404_for_invalid_organization(): void + { + $this->authenticateUser(); + + // Act: Request with an ID that definitely doesn't exist (e.g., extremely high int) + $this->client->request('GET', '/actions/organization/99999999/activities-ajax'); + + // Assert: 404 Not Found (Standard Symfony ParamConverter behavior) + $this->assertResponseStatusCodeSame(404); + } +} \ No newline at end of file From 0ea4829940b4a5ad5239f680c8c0b0e98cc6d38f Mon Sep 17 00:00:00 2001 From: Charles Date: Thu, 18 Dec 2025 15:32:26 +0100 Subject: [PATCH 35/43] 60 sec between refresh --- assets/controllers/organization_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 126d9d3..21a2eea 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -16,7 +16,7 @@ export default class extends Controller { this.loadActivities(); setInterval(() => { this.loadActivities(); - }, 5000); // Refresh every 60 seconds + }, 60000); // Refresh every 60 seconds } if (this.tableValue){ this.table(); From 2d0eddaf51044238d0be0f362802ea2bf79c6c19 Mon Sep 17 00:00:00 2001 From: Charles Date: Thu, 18 Dec 2025 16:43:48 +0100 Subject: [PATCH 36/43] test on app controller --- .gitignore | 1 + composer.json | 1 + composer.lock | 71 ++++- config/bundles.php | 1 + .../packages/dama_doctrine_test_bundle.yaml | 5 + src/Controller/ApplicationController.php | 74 +++-- src/Entity/Apps.php | 1 + symfony.lock | 12 + templates/application/appSmall.html.twig | 2 +- templates/application/index.html.twig | 14 +- .../Controller/ApplicationControllerTest.php | 301 ++++++++++++++++++ tests/Functional/AbstractFunctionalTest.php | 72 +++++ 12 files changed, 521 insertions(+), 34 deletions(-) create mode 100644 config/packages/dama_doctrine_test_bundle.yaml create mode 100644 tests/Controller/ApplicationControllerTest.php create mode 100644 tests/Functional/AbstractFunctionalTest.php diff --git a/.gitignore b/.gitignore index 2fa901b..8d4195a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ ###> symfony/framework-bundle ### /.env.local +/.env.test /.env.local.php /.env.*.local /config/secrets/prod/prod.decrypt.private.php diff --git a/composer.json b/composer.json index 20e0ac9..3ab5270 100644 --- a/composer.json +++ b/composer.json @@ -107,6 +107,7 @@ } }, "require-dev": { + "dama/doctrine-test-bundle": "^8.3", "phpunit/phpunit": "^11.0", "symfony/browser-kit": "7.2.*", "symfony/css-selector": "7.2.*", diff --git a/composer.lock b/composer.lock index 8611449..4c2dcff 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "00e62b0a959e7b09d4b1fdb7e0501549", + "content-hash": "9c6693b9e0508ab0c1ff3ee95823be7d", "packages": [ { "name": "aws/aws-crt-php", @@ -9995,6 +9995,75 @@ } ], "packages-dev": [ + { + "name": "dama/doctrine-test-bundle", + "version": "v8.3.1", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "9bc47e02a0d67cbfef6773837249f71e65c95bf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/9bc47e02a0d67cbfef6773837249f71e65c95bf6", + "reference": "9bc47e02a0d67cbfef6773837249f71e65c95bf6", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.0", + "php": ">= 8.1", + "psr/cache": "^2.0 || ^3.0", + "symfony/cache": "^6.4 || ^7.2 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.2 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<10.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/process": "^6.4 || ^7.2 || ^8.0", + "symfony/yaml": "^6.4 || ^7.2 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.3.1" + }, + "time": "2025-08-05T17:55:02+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", diff --git a/config/bundles.php b/config/bundles.php index d17bbf8..c967433 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -19,4 +19,5 @@ return [ Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], Aws\Symfony\AwsBundle::class => ['all' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/packages/dama_doctrine_test_bundle.yaml b/config/packages/dama_doctrine_test_bundle.yaml new file mode 100644 index 0000000..3482cba --- /dev/null +++ b/config/packages/dama_doctrine_test_bundle.yaml @@ -0,0 +1,5 @@ +when@test: + dama_doctrine_test: + enable_static_connection: true + enable_static_meta_data_cache: true + enable_static_query_cache: true diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index a666a8e..0b4e776 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -12,6 +12,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; #[Route(path: '/application', name: 'application_')] @@ -41,8 +42,8 @@ class ApplicationController extends AbstractController $this->loggerService->logEntityNotFound('Application', [ 'applicationId' => $id, 'message' => "Application not found for editing." - ], $actingUser); - $this->addFlash('error', "L'application n'existe pas ou n'est pas reconnu."); + ], $actingUser->getId()); + $this->addFlash('danger', "L'application n'existe pas ou n'est pas reconnu."); return $this->redirectToRoute('application_index'); } $applicationData = [ @@ -61,13 +62,12 @@ class ApplicationController extends AbstractController $application->setDescription($data['description']); $application->setDescriptionSmall($data['descriptionSmall']); $this->entityManager->persist($application); - $this->actionService->createAction("Modification de l'application ", $actingUser->getId(), null, $application->getId()); + $this->actionService->createAction("Modification de l'application ", $actingUser, null, $application->getId()); $this->loggerService->logApplicationInformation('Application Edited', [ 'applicationId' => $application->getId(), 'applicationName' => $application->getName(), 'message' => "Application edited successfully." ], $actingUser->getId()); - $this->addFlash('success', "L'application a été mise à jour avec succès."); }catch (\Exception $e){ $this->loggerService->logError('Application Edit Failed', [ 'applicationId' => $application->getId(), @@ -75,7 +75,6 @@ class ApplicationController extends AbstractController 'error' => $e->getMessage(), 'message' => "Failed to edit application." ], $actingUser); - $this->addFlash('error', "Une erreur est survenue lors de la mise à jour de l'application."); } return $this->redirectToRoute('application_index'); @@ -91,38 +90,51 @@ class ApplicationController extends AbstractController { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - $application = $this->entityManager->getRepository(Apps::class)->find($id); - if (!$application) { - $this->loggerService->logEntityNotFound('Application', [ + try{ + $application = $this->entityManager->getRepository(Apps::class)->find($id); + if (!$application) { + $this->loggerService->logEntityNotFound('Application', [ + 'applicationId' => $id, + 'message' => "Application not found for authorization." + ], $actingUser->getId()); + throw $this->createNotFoundException("L'application n'existe pas."); + } + $orgId = $request->get('organizationId'); + + $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); + if (!$organization) { + $this->loggerService->logEntityNotFound('Organization', [ + 'Organization_id' => $orgId, + 'message' => "Organization not found for authorization." + ], $actingUser->getId()); + throw $this->createNotFoundException("L'Organization n'existe pas."); + } + $application->addOrganization($organization); + $this->loggerService->logApplicationInformation('Application Authorized', [ + 'applicationId' => $application->getId(), + 'applicationName' => $application->getName(), + 'organizationId' => $organization->getId(), + 'message' => "Application authorized for organization." + ], $actingUser->getId()); + $this->entityManager->persist($application); + $this->entityManager->flush(); + $this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName()); + return new Response('', Response::HTTP_OK); + }catch (\Exception $e){ + $this->loggerService->logError('Application Authorization Failed', [ 'applicationId' => $id, - 'message' => "Application not found for authorization." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'application n'existe pas."); + 'error' => $e->getMessage(), + 'message' => "Failed to authorize application.", + 'acting_user_id' => $actingUser->getId() + ]); + return new Response('Erreur lors de l\'autorisation de l\'application.', Response::HTTP_INTERNAL_SERVER_ERROR); } - $orgId = $request->get('organizationId'); - $organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); - if (!$organization) { - $this->loggerService->logEntityNotFound('Organization', [ - 'Organization_id' => $orgId, - 'message' => "Organization not found for authorization." - ], $actingUser->getId()); - throw $this->createNotFoundException("L'Organization n'existe pas."); - } - $application->addOrganization($organization); - $this->loggerService->logApplicationInformation('Application Authorized', [ - 'applicationId' => $application->getId(), - 'applicationName' => $application->getName(), - 'organizationId' => $organization->getId(), - 'message' => "Application authorized for organization." - ], $actingUser->getId()); - $this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName()); - return new Response('', Response::HTTP_OK); } - #[Route(path: '/remove/{id}', name: 'remove', methods: ['POST'])] - public function remove(int $id, Request $request) + #[Route(path: '/revoke/{id}', name: 'revoke', methods: ['POST'])] + public function revoke(int $id, Request $request) { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); diff --git a/src/Entity/Apps.php b/src/Entity/Apps.php index 1492d3b..99c6f7e 100644 --- a/src/Entity/Apps.php +++ b/src/Entity/Apps.php @@ -59,6 +59,7 @@ class Apps { $this->userOrganizatonApps = new ArrayCollection(); $this->organization = new ArrayCollection(); + $this->setIsActive(true); } public function getId(): ?int diff --git a/symfony.lock b/symfony.lock index ef4308d..0644b1a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -11,6 +11,18 @@ "config/packages/aws.yaml" ] }, + "dama/doctrine-test-bundle": { + "version": "8.3", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "8.3", + "ref": "dfc51177476fb39d014ed89944cde53dc3326d23" + }, + "files": [ + "config/packages/dama_doctrine_test_bundle.yaml" + ] + }, "doctrine/deprecations": { "version": "1.1", "recipe": { diff --git a/templates/application/appSmall.html.twig b/templates/application/appSmall.html.twig index 14faa93..77fd5e4 100644 --- a/templates/application/appSmall.html.twig +++ b/templates/application/appSmall.html.twig @@ -14,7 +14,7 @@ {% if application.hasAccess %} {% if is_granted("ROLE_SUPER_ADMIN") %}
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}

Bienvenue sur la suite Easy

-

Ici, vous pouvez trouver toutes nos applications à un seul endroit !

+

Ici, vous pouvez trouver toutes nos applications à un seul endroit !

+ {% if applications is empty %} + + {% endif %} {% for application in applications %}
{% include 'application/InformationCard.html.twig' with { diff --git a/tests/Controller/ApplicationControllerTest.php b/tests/Controller/ApplicationControllerTest.php new file mode 100644 index 0000000..4102e60 --- /dev/null +++ b/tests/Controller/ApplicationControllerTest.php @@ -0,0 +1,301 @@ +client->request('GET', '/application/'); + self::assertResponseRedirects('/login'); // Assuming your login route is /login + } + + #[Test] + public function index_lists_applications_for_authenticated_user(): void + { + // 1. Arrange: Create User and Data + $user = $this->createUser('user@test.com'); + $this->createApp('App One'); + $this->createApp('App Two'); + + // 2. Act: Login and Request + $this->client->loginUser($user); + $this->client->request('GET', '/application/'); + + // 3. Assert + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'App One'); + self::assertSelectorTextContains('body', 'App Two'); + } + + + #[Test] + public function index_no_application_found(): void + { + $user = $this->createUser('user@test.com'); + $this->client->loginUser($user); + $this->client->request('GET', '/application/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Aucune application disponible'); + } + + //endregion + + //region Edit Tests + + #[Test] + public function edit_page_denies_access_to_regular_users(): void + { + $user = $this->createUser('regular@test.com'); + $app = $this->createApp('Target App'); + + $this->client->loginUser($user); + $this->client->request('GET', '/application/edit/' . $app->getId()); + + self::assertResponseStatusCodeSame(403); + } + #[Test] + public function edit_page_denies_access_to_admin_users(): void + { + $user = $this->createUser('admin@test.com', ['ROLE_ADMIN']); + $app = $this->createApp('Target App'); + + $this->client->loginUser($user); + $this->client->request('GET', '/application/edit/' . $app->getId()); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function edit_page_loads_for_super_admin(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('Editable App'); + + $this->client->loginUser($admin); + $crawler = $this->client->request('GET', '/application/edit/' . $app->getId()); + + self::assertResponseIsSuccessful(); + $this->assertCount(1, $crawler->filter('input[name="name"]')); + } + + #[Test] + public function edit_submits_changes_successfully(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('Old Name'); + + $this->client->loginUser($admin); + + // Simulate POST request directly (mimicking form submission) + $this->client->request('POST', '/application/edit/' . $app->getId(), [ + 'name' => 'New Name', + 'description' => 'Updated Description', + 'descriptionSmall' => 'Updated Small', + ]); + + // Assert Redirection + self::assertResponseRedirects('/application/'); + $this->client->followRedirect(); + + // Assert Database Update + $this->entityManager->clear(); // Clear identity map to force fresh fetch + $updatedApp = $this->entityManager->getRepository(Apps::class)->find($app->getId()); + $this->assertEquals('New Name', $updatedApp->getName()); + $this->assertEquals('Updated Description', $updatedApp->getDescription()); + } + + #[Test] + public function edit_handles_non_existent_id_get(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + $this->client->request('GET', '/application/edit/999999'); + + self::assertResponseRedirects('/application/'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + + self::assertSelectorTextContains('.alert-danger', "n'existe pas"); + } + + #[Test] + public function edit_handles_non_existent_id_post(): void + { + // Arrange + $admin = $this->createUser('superAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('App With Issue'); + $this->client->loginUser($admin); + + $this->client->request('POST', '/application/edit/' . 99999, [ + 'name' => 'New Name', + 'description' => 'Updated Description', + 'descriptionSmall' => 'Updated Small', + ]); + + self::assertResponseRedirects('/application/'); + $this->client->followRedirect(); + self::assertSelectorExists('.alert-danger'); + self::assertSelectorTextContains('.alert-danger', "n'existe pas"); + } + + //endregion + + //region Authorize Tests + #[Test] + public function authorize_adds_organization_successfully(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('Auth App'); + $org = $this->createOrganization('Test Org'); + + $this->client->loginUser($admin); + + $this->client->request('POST', '/application/authorize/' . $app->getId(), [ + 'organizationId' => $org->getId() + ]); + + self::assertResponseStatusCodeSame(200); + + // Clear Doctrine memory to force fetching fresh data from DB + $this->entityManager->clear(); + + $updatedApp = $this->entityManager->getRepository(Apps::class)->find($app->getId()); + + $exists = $updatedApp->getOrganization()->exists(function($key, $element) use ($org) { + return $element->getId() === $org->getId(); + }); + + $this->assertTrue($exists, 'The application is not linked to the organization.'); + } + + #[Test] + public function authorize_fails_on_invalid_organization(): void + { + $admin = $this->createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('App For Org Test'); + + $this->client->loginUser($admin); + $this->client->request('POST', '/application/authorize/' . $app->getId(), [ + 'organizationId' => 99999 + ]); + + self::assertResponseStatusCodeSame(404); + + } + + #[Test] + public function authorize_fails_on_invalid_application(): void + { + $admin = $this->createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('POST', '/application/authorize/99999', [ + 'organizationId' => 1 + ]); + self::assertResponseStatusCodeSame(404); + } + //endregion + + //region revoke Tests + + #[Test] + public function revoke_denies_access_to_admins(): void + { + $user = $this->createUser('Admin@test.com', ['ROLE_ADMIN']); + $app = $this->createApp('App To Revoke'); + $org = $this->createOrganization('Org To Revoke'); + $this->client->loginUser($user); + $this->client->request('POST', '/application/revoke/'. $app->getId(), [ + 'organizationId' => $org->getId() + ]); + self::assertResponseStatusCodeSame(403); + + } + + #[Test] + public function revoke_denies_access_to_user(): void + { + $user = $this->createUser('user@test.com'); + $app = $this->createApp('App To Revoke'); + $org = $this->createOrganization('Org To Revoke'); + $this->client->loginUser($user); + $this->client->request('POST', '/application/revoke/'. $app->getId(), [ + 'organizationId' => $org->getId() + ]); + self::assertResponseStatusCodeSame(403); + + } + + #[Test] + public function revoke_removes_organization_successfully(): void + { + $admin = $this->createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('App To Revoke Org'); + $org = $this->createOrganization('Org To Be Revoked'); + // First, authorize the organization + $app->addOrganization($org); + $this->entityManager->persist($app); + $this->entityManager->flush(); + $this->client->loginUser($admin); + $this->client->request('POST', '/application/revoke/'. $app->getId(), [ + 'organizationId' => $org->getId() + ]); + self::assertResponseStatusCodeSame(200); + + // Clear Doctrine memory to force fetching fresh data from DB + $this->entityManager->clear(); + $updatedApp = $this->entityManager->getRepository(Apps::class)->find($app->getId()); + $exists = $updatedApp->getOrganization()->exists(function($key, $element) use ($org) { + return $element === $org; + }); + self::assertFalse($exists, 'The organization was removed from the application.'); + } + + #[Test] + public function revoke_fails_on_invalid_organization(): void + { + $admin = $this->createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $app = $this->createApp('App To Revoke Org'); + $org = $this->createOrganization('Org To Be Revoked'); + // First, authorize the organization + $app->addOrganization($org); + $this->entityManager->persist($app); + $this->entityManager->flush(); + $this->client->loginUser($admin); + $this->client->request('POST', '/application/revoke/' . $app-> +getId(), [ + 'organizationId' => 99999 + ]); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function revoke_fails_on_invalid_application(): void + { + $admin = $this->createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $org = $this->createOrganization('Org To Be Revoked'); + // First, authorize the organization + $this->client->loginUser($admin); + $this->client->request('POST', '/application/revoke/' . 9999, [ + 'organizationId' => 99999 + ]); + self::assertResponseStatusCodeSame(404, "L'application n'existe pas."); + } + //endregion + + + + +} \ No newline at end of file diff --git a/tests/Functional/AbstractFunctionalTest.php b/tests/Functional/AbstractFunctionalTest.php new file mode 100644 index 0000000..c5209f4 --- /dev/null +++ b/tests/Functional/AbstractFunctionalTest.php @@ -0,0 +1,72 @@ +client = static::createClient(); + + // Access the container to get the EntityManager + $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + } + + protected function createUser(string $email, array $roles = ['ROLE_USER']): User + { + $user = new User(); + $user->setEmail($email); + $user->setRoles($roles); + $user->setName('Test'); + $user->setSurname('User'); + $user->setPassword('$2y$13$...'); // Dummy hash, logic typically bypassed by loginUser + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } + + protected function createApp(string $name): Apps + { + // Based on your Entity, these fields are NOT nullable, so we must fill them + $app = new Apps(); + $app->setName($name); + $app->setTitle($name . ' Title'); + $app->setSubDomain(strtolower($name)); // Assuming valid subdomain logic + $app->setLogoUrl('https://example.com/logo.png'); + // $app->setDescription() is nullable, so we can skip or set it + + $this->entityManager->persist($app); + $this->entityManager->flush(); + + return $app; + } + + protected function createOrganization(string $name): Organizations + { + // I am assuming the Organizations entity structure here based on context + $org = new Organizations(); + $org->setName($name); + $org->setEmail('contact@' . strtolower($name) . '.com'); + $org->setNumber(100 + rand(1, 900)); // Example number + $org->setAddress('123 ' . $name . ' St'); // Example address + $org->setLogoUrl('https://example.com/org_logo.png'); + // Add other required fields if Organizations has non-nullable columns + + $this->entityManager->persist($org); + $this->entityManager->flush(); + + return $org; + } +} \ No newline at end of file From 86ef5fa6f6c10a47f7c6ccc3c2612f1eb55f210d Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 19 Dec 2025 11:14:48 +0100 Subject: [PATCH 37/43] Test on index controller --- tests/Controller/IndexControllerTest.php | 92 +++++++++++++++++++++ tests/Functional/AbstractFunctionalTest.php | 33 ++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/Controller/IndexControllerTest.php diff --git a/tests/Controller/IndexControllerTest.php b/tests/Controller/IndexControllerTest.php new file mode 100644 index 0000000..a2b7364 --- /dev/null +++ b/tests/Controller/IndexControllerTest.php @@ -0,0 +1,92 @@ +createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('GET', '/'); + + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + } + + #[Test] + public function test_index_successful_admin_single_org(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $app = $this->createApp('Test App'); + $app -> addOrganization($org); + $uo = $this->createUOLink($admin, $org); + $role = $this->createRole('ADMIN'); + $uoa = $this->createUOALink($uo , $app, $role); + + $this->client->request('GET', '/'); + self::assertResponseRedirects('/organization/'); + + + $this->client->followRedirect(); + + + self::assertResponseRedirects('/organization/view/' . $org->getId()); + + + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + } + + #[Test] + public function test_index_successful_admin_mutiple_org(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $org2 = $this->createOrganization('Test Org2'); + $app = $this->createApp('Test App'); + $app -> addOrganization($org); + $uo = $this->createUOLink($admin, $org); + $uo2 = $this->createUOLink($admin, $org2); + $role = $this->createRole('ADMIN'); + $uoa = $this->createUOALink($uo , $app, $role); + $uoa2 = $this->createUOALink($uo2 , $app, $role); + + $this->client->request('GET', '/'); + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + } + + #[Test] + public function test_index_successful_user(): void + { + $user = $this->createUser('user@test.com', ['ROLE_USER']); + $this->client->loginUser($user); + $org = $this->createOrganization('Test Org'); + $app = $this->createApp('Test App'); + $app -> addOrganization($org); + $uo = $this->createUOLink($user, $org); + $role = $this->createRole('USER'); + $uoa = $this->createUOALink($uo , $app, $role); + $this->client->request('GET', '/'); + self::assertResponseRedirects('/application/'); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + } + //endregion +} \ No newline at end of file diff --git a/tests/Functional/AbstractFunctionalTest.php b/tests/Functional/AbstractFunctionalTest.php index c5209f4..fd0e13a 100644 --- a/tests/Functional/AbstractFunctionalTest.php +++ b/tests/Functional/AbstractFunctionalTest.php @@ -4,7 +4,10 @@ namespace App\Tests\Functional; use App\Entity\Apps; use App\Entity\Organizations; +use App\Entity\Roles; use App\Entity\User; +use App\Entity\UserOrganizatonApp; +use App\Entity\UsersOrganizations; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -69,4 +72,34 @@ abstract class AbstractFunctionalTest extends WebTestCase return $org; } + + protected function createUOLink(User $user, Organizations $organization): UsersOrganizations{ + $uo = new UsersOrganizations(); + $uo->setUsers($user); + $uo->setOrganization($organization); + $this->entityManager->persist($uo); + $this->entityManager->flush(); + + return $uo; + } + + protected function createUOALink(UsersOrganizations $uo, Apps $app, Roles $role): UserOrganizatonApp{ + $uoa = new UserOrganizatonApp(); + $uoa->setUserOrganization($uo); + $uoa->setApplication($app); + $uoa->setRole($role); + $this->entityManager->persist($uoa); + $this->entityManager->flush(); + + return $uoa; + } + + protected function createRole(string $name): Roles{ + $role = new Roles(); + $role->setName($name); + $this->entityManager->persist($role); + $this->entityManager->flush(); + + return $role; + } } \ No newline at end of file From 9cc8dc83f373bf6f280b3168292a934c3d6f3e27 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 19 Dec 2025 14:05:42 +0100 Subject: [PATCH 38/43] Test on index controller --- tests/Controller/IndexControllerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Controller/IndexControllerTest.php b/tests/Controller/IndexControllerTest.php index a2b7364..94e1994 100644 --- a/tests/Controller/IndexControllerTest.php +++ b/tests/Controller/IndexControllerTest.php @@ -88,5 +88,12 @@ class IndexControllerTest extends AbstractFunctionalTest $this->client->followRedirect(); self::assertResponseIsSuccessful(); } + + #[Test] + public function test_index_unauthenticated(): void + { + $this->client->request('GET', '/'); + self::assertResponseRedirects('/login'); + } //endregion } \ No newline at end of file From 08ed90a7dcd9bdc2fdd030c019b41e732ca139a2 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 19 Dec 2025 15:45:23 +0100 Subject: [PATCH 39/43] Test on notification controller --- .../Controller/NotificationControllerTest.php | 108 ++++++++++++++++++ tests/Functional/AbstractFunctionalTest.php | 13 +++ 2 files changed, 121 insertions(+) create mode 100644 tests/Controller/NotificationControllerTest.php diff --git a/tests/Controller/NotificationControllerTest.php b/tests/Controller/NotificationControllerTest.php new file mode 100644 index 0000000..1a91872 --- /dev/null +++ b/tests/Controller/NotificationControllerTest.php @@ -0,0 +1,108 @@ +createUser('admin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->createNotification($admin, 'Test Notification 1'); + $this->createNotification($admin, 'Test Notification 2', true); + $this->client->request('GET', '/notifications/'); + self::assertResponseIsSuccessful(); + $responseData = json_decode($this->client->getResponse()->getContent(), true); + $this->assertArrayHasKey('notifications', $responseData); + $this->assertArrayHasKey('unreadCount', $responseData); + $this->assertCount(2, $responseData['notifications']); + $this->assertEquals(1, $responseData['unreadCount']); + } + + #[Test] + public function test_index_non_super_admin_forbidden(): void + { + $admin = $this->createUser('admin@test.com', ['ROLE_ADMIN']); + $user = $this->createUser('user@test.com', ['ROLE_USER']); + $this->client->loginUser($admin); + $this->client->request('GET', '/notifications/'); + self::assertResponseStatusCodeSame(403); + $this->client->loginUser($user); + $this->client->request('GET', '/notifications/'); + self::assertResponseStatusCodeSame(403); + } + + //endregion + + //region unread tests + #[Test] + public function test_unread_authenticated_user_success(): void + { + $user = $this->createUser('s', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($user); + $this->createNotification($user, 'Unread Notification 1'); + $this->createNotification($user, 'Read Notification 1', true); + $this->client->request('GET', '/notifications/unread'); + self::assertResponseIsSuccessful(); + $responseData = json_decode($this->client->getResponse()->getContent(), true); + $this->assertArrayHasKey('notifications', $responseData); + $this->assertArrayHasKey('unreadCount', $responseData); + $this->assertCount(1, $responseData['notifications']); + $this->assertEquals(1, $responseData['unreadCount']); + } + + #[Test] + public function test_unread_unauthenticated_user_forbidden(): void + { + $this->client->request('GET', '/notifications/unread'); + self::assertResponseStatusCodeSame(401); + } + + //endregion + + //region markAsRead tests + + #[Test] + public function test_markAsRead_authenticated_user_success(): void + { + $user = $this->createUser('user'); + $this->client->loginUser($user); + $notification = $this->createNotification($user, 'Notification to Mark Read'); + $this->client->request('POST', '/notifications/' . $notification->getId() . '/read'); + self::assertResponseIsSuccessful(); + $responseData = json_decode($this->client->getResponse()->getContent(), true); + $this->assertArrayHasKey('success', $responseData); + $this->assertTrue($responseData['success']); + } + + #[Test] + public function test_markAsRead_notification_not_found(): void + { + $user = $this->createUser('user'); + $this->client->loginUser($user); + $notification = $this->createNotification($user, 'Notification to Mark Read'); + $this->client->request('POST', '/notifications/9999/read'); // Non-existent ID + self::assertResponseStatusCodeSame(404); + $responseData = json_decode($this->client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $responseData); + $this->assertEquals('Notification not found', $responseData['error']); + } + + #[Test] + public function test_markAsRead_unauthenticated_user_forbidden(): void + { + $this->client->request('POST', '/notifications/1/read'); + self::assertResponseRedirects('/login'); + $this->client->followRedirect(); + self::assertResponseStatusCodeSame(200); // Login page + } + + + //endregion +} \ No newline at end of file diff --git a/tests/Functional/AbstractFunctionalTest.php b/tests/Functional/AbstractFunctionalTest.php index fd0e13a..a0d3542 100644 --- a/tests/Functional/AbstractFunctionalTest.php +++ b/tests/Functional/AbstractFunctionalTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Functional; use App\Entity\Apps; +use App\Entity\Notification; use App\Entity\Organizations; use App\Entity\Roles; use App\Entity\User; @@ -102,4 +103,16 @@ abstract class AbstractFunctionalTest extends WebTestCase return $role; } + + protected function createNotification($user, string $title, bool $isRead = false): Notification{ + $notification = new Notification(); + $notification->setUser($user); + $notification->setTitle($title); + $notification->setMessage('This is a test notification message.'); + $notification->setType('info'); + $notification->setIsRead($isRead); + $this->entityManager->persist($notification); + $this->entityManager->flush(); + return $notification; + } } \ No newline at end of file From 68864b399755760a5708ba4da22db56af4408e63 Mon Sep 17 00:00:00 2001 From: Charles Date: Mon, 22 Dec 2025 10:04:11 +0100 Subject: [PATCH 40/43] Correct organization display logic --- assets/controllers/organization_controller.js | 2 +- src/Controller/OrganizationController.php | 33 ++----------------- templates/organization/index.html.twig | 21 ++++-------- 3 files changed, 9 insertions(+), 47 deletions(-) diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 21a2eea..063d069 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -28,7 +28,7 @@ export default class extends Controller { const table = new Tabulator("#tabulator-org", { // Register locales here langs: TABULATOR_FR_LANG, - + placeholder: "Aucun résultat trouvé pour cette recherche", locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it) ajaxURL: "/organization/data", diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 45fadef..2988072 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -48,40 +48,11 @@ class OrganizationController extends AbstractController public function index(): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); - $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if ($this->isGranted("ROLE_SUPER_ADMIN")) { - $organizations = $this->organizationsRepository->findBy(['isDeleted' => false]); + $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - } else { - //get all the UO of the user - $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); - $organizations = []; - $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); - foreach ($uos as $uo) { - $uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]); - if ($uoaAdmin) { - $organizations[] = $uo->getOrganization(); - } - } - if (count($organizations) === 1 && $organizations[0]->isActive() === true) { - return $this->redirectToRoute('organization_show', ['id' => $organizations[0]->getId()]); - } - - } - // Map the entities for tabulator - $organizationsData = array_map(function ($org) { - return [ - 'id' => $org->getId(), - 'name' => $org->getName(), - 'email' => $org->getEmail(), - 'logoUrl' => $org->getLogoUrl() ? $org->getLogoUrl() : null, - 'active' => $org->isActive(), - 'showUrl' => $this->generateUrl('organization_show', ['id' => $org->getId()]), - ]; - }, $organizations); return $this->render('organization/index.html.twig', [ - 'organizationsData' => $organizationsData, + 'hasOrganizations' => $orgCount > 0 ]); } diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index 3e09ad9..19bf1fc 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -16,33 +16,24 @@
-
- {% if organizationsData|length == 0 %} - {% if is_granted('ROLE_SUPER_ADMIN') %} +
+ {% if is_granted('ROLE_SUPER_ADMIN') and not hasOrganizations %} -{# style présent juste pour créer de l'espace #}

Aucune organisation trouvée.

Créer une organisation
- {% else %} -
-

Aucune organisation trouvée. Merci de contacter votre administrateur

-
- {% endif %} - {% else %} -
- + data-organization-aws-value="{{ aws_url }}"> +
{% endif %}
-
From d26d1cb11835ef2bf9f1e8d5b73bb165b07aebc9 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 6 Jan 2026 14:29:29 +0100 Subject: [PATCH 41/43] Organization Controller Tests --- assets/controllers/organization_controller.js | 12 +- migrations/Version20260105152103.php | 32 ++ migrations/Version20260106080636.php | 32 ++ migrations/Version20260106084653.php | 32 ++ src/Controller/OrganizationController.php | 24 +- src/Entity/Organizations.php | 5 +- src/Entity/User.php | 1 - src/Form/OrganizationForm.php | 4 +- templates/organization/edit.html.twig | 3 - templates/organization/index.html.twig | 6 +- templates/organization/new.html.twig | 9 +- .../Controller/OrganizationControllerTest.php | 359 ++++++++++++++++++ tests/Functional/AbstractFunctionalTest.php | 6 +- 13 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 migrations/Version20260105152103.php create mode 100644 migrations/Version20260106080636.php create mode 100644 migrations/Version20260106084653.php create mode 100644 tests/Controller/OrganizationControllerTest.php diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 063d069..6271eb8 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -5,9 +5,11 @@ import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js"; export default class extends Controller { static values = {aws: String, - id: String, - activities: Boolean, - table: Boolean, + id: String, + activities: Boolean, + table: Boolean, + sadmin: Boolean, + user: Number }; static targets = ["activityList", "emptyMessage"] @@ -18,7 +20,7 @@ export default class extends Controller { this.loadActivities(); }, 60000); // Refresh every 60 seconds } - if (this.tableValue){ + if (this.tableValue && this.sadminValue) { this.table(); } @@ -31,7 +33,7 @@ export default class extends Controller { placeholder: "Aucun résultat trouvé pour cette recherche", locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it) - ajaxURL: "/organization/data", + ajaxURL: `/organization/data/${this.userValue}`, ajaxConfig: "GET", pagination: true, paginationMode: "remote", diff --git a/migrations/Version20260105152103.php b/migrations/Version20260105152103.php new file mode 100644 index 0000000..067cc16 --- /dev/null +++ b/migrations/Version20260105152103.php @@ -0,0 +1,32 @@ +addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON organizations (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_IDENTIFIER_EMAIL'); + } +} diff --git a/migrations/Version20260106080636.php b/migrations/Version20260106080636.php new file mode 100644 index 0000000..3f3a5e2 --- /dev/null +++ b/migrations/Version20260106080636.php @@ -0,0 +1,32 @@ +addSql('CREATE UNIQUE INDEX UNIQ_ORGANIZATION_EMAIL ON organizations (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_ORGANIZATION_EMAIL'); + } +} diff --git a/migrations/Version20260106084653.php b/migrations/Version20260106084653.php new file mode 100644 index 0000000..ab52dc6 --- /dev/null +++ b/migrations/Version20260106084653.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE organizations ALTER logo_url DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE organizations ALTER logo_url SET NOT NULL'); + } +} diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 2988072..ac8c076 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -16,7 +16,10 @@ use App\Service\LoggerService; use App\Service\OrganizationsService; use App\Service\UserOrganizationService; use App\Service\UserService; +use Doctrine\DBAL\Exception\NonUniqueFieldNameException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NonUniqueResultException; use Exception; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -48,15 +51,19 @@ class OrganizationController extends AbstractController public function index(): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + if($this->userService->hasAccessTo($actingUser, true)){ + $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - - return $this->render('organization/index.html.twig', [ - 'hasOrganizations' => $orgCount > 0 - ]); + return $this->render('organization/index.html.twig', [ + 'hasOrganizations' => $orgCount > 0 + ]); + } + $this->loggerService->logAccessDenied($actingUser->getId()); + throw new AccessDeniedHttpException('Access denied'); } - #[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])] + #[Route(path: '/create', name: 'create', methods: ['GET', 'POST'])] public function new(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); @@ -79,6 +86,7 @@ class OrganizationController extends AbstractController return $this->redirectToRoute('organization_index'); } catch (Exception $e) { $this->addFlash('error', 'Error creating organization: ' . $e->getMessage()); + $this->loggerService->logError('Error creating organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); } } return $this->render('organization/new.html.twig', [ @@ -146,7 +154,7 @@ class OrganizationController extends AbstractController } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); - } catch (Exception $e) { + }catch (Exception $e) { $this->addFlash('error', 'Error editing organization: ' . $e->getMessage()); } } @@ -266,7 +274,7 @@ class OrganizationController extends AbstractController } // API endpoint to fetch organization data for Tabulator - #[Route(path: '/data', name: 'data', methods: ['GET'])] + #[Route(path: '/data/{id}', name: 'data', methods: ['GET'])] public function data(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_ADMIN'); diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index bff31ba..883cb74 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -4,10 +4,13 @@ namespace App\Entity; use App\Repository\OrganizationsRepository; use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: OrganizationsRepository::class)] +#[ORM\UniqueConstraint(name: 'UNIQ_ORGANIZATION_EMAIL', fields: ['email'])] +#[UniqueEntity(fields: ['email'], message: 'Une organisation avec cet email existe déjà.')] class Organizations { #[ORM\Id] @@ -24,7 +27,7 @@ class Organizations #[ORM\Column(length: 255)] private ?string $address = null; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, nullable: true)] private ?string $logo_url = null; #[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])] diff --git a/src/Entity/User.php b/src/Entity/User.php index ca9d110..8709389 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -9,7 +9,6 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] diff --git a/src/Form/OrganizationForm.php b/src/Form/OrganizationForm.php index 571b773..da84a2d 100644 --- a/src/Form/OrganizationForm.php +++ b/src/Form/OrganizationForm.php @@ -17,8 +17,8 @@ class OrganizationForm extends AbstractType $builder ->add('email', EmailType::class, ['required' => true, 'label' => 'Email*']) ->add('name', TextType::class, ['required' => true, 'label' => 'Nom de l\'organisation*']) - ->add('address', TextType::class, ['required' => false, 'label' => 'Adresse']) - ->add('number', TextType::class, ['required' => false, 'label' => 'Numéro de téléphone']) + ->add('address', TextType::class, ['required' => true, 'label' => 'Adresse']) + ->add('number', TextType::class, ['required' => true, 'label' => 'Numéro de téléphone']) ->add('logoUrl', FileType::class, [ 'required' => false, 'label' => 'Logo', diff --git a/templates/organization/edit.html.twig b/templates/organization/edit.html.twig index aa9c754..bd1b225 100644 --- a/templates/organization/edit.html.twig +++ b/templates/organization/edit.html.twig @@ -5,9 +5,6 @@

Modifier l'organisation

- {% if is_granted("ROLE_SUPER_ADMIN") %} -{# Supprimer#} - {% endif %}
diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index 19bf1fc..2d8ff2f 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -11,7 +11,7 @@
{% if is_granted("ROLE_SUPER_ADMIN") %} - Ajouter une organisation + Ajouter une organisation {% endif %}
@@ -21,7 +21,7 @@

Aucune organisation trouvée.

- Créer une organisation + Créer une organisation
{% else %} @@ -29,6 +29,8 @@
diff --git a/templates/organization/new.html.twig b/templates/organization/new.html.twig index 2a9050c..e159fbc 100644 --- a/templates/organization/new.html.twig +++ b/templates/organization/new.html.twig @@ -6,13 +6,20 @@
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}

Ajouter une organisation

- + {{ form_start(form) }} {{ form_widget(form) }} diff --git a/tests/Controller/OrganizationControllerTest.php b/tests/Controller/OrganizationControllerTest.php new file mode 100644 index 0000000..4bb9065 --- /dev/null +++ b/tests/Controller/OrganizationControllerTest.php @@ -0,0 +1,359 @@ +createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // Create at least one org so 'hasOrganizations' becomes true + $this->createOrganization('Organization 1'); + $this->createOrganization('Organization 2'); + + $this->client->request('GET', '/organization/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + + self::assertSelectorExists('#tabulator-org'); + } + + #[Test] + public function test_index_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + + // 2. Act + $this->client->request('GET', '/organization/'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + + } + + #[Test] + public function test_index_no_organizations(): void + { + // 1. Arrange + $admin = $this->createUser('user@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/'); + // 3. Assert + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Aucune organisation trouvée'); + } + + //endregion + + //region CREATE tests + #[Test] + public function test_create_super_admin_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // 4. Act: Request the page + $this->client->request('GET', '/organization/create'); + + // 5. Submit Form with the FILE object and correct field name 'logoUrl' + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'New Organization', + 'organization_form[email]' => 'unique-' . uniqid('', true) . '@test.com', + 'organization_form[address]' => '123 Test Street', + 'organization_form[number]' => '0102030405', + 'organization_form[logoUrl]' => $logo, // Pass the OBJECT, not a string + ]); + + // 6. Assert + // Check for redirect (302) + self::assertResponseRedirects('/organization/'); + + $this->client->followRedirect(); + + // Ensure we see the success state + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + self::assertSelectorExists('#tabulator-org'); + } + + #[Test] + public function test_create_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/organization/create'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_create_super_admin_invalid_data(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/create'); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => '', // Invalid: name is required + 'organization_form[email]' => 'not-an-email', // Invalid email format + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + } + + #[Test] + public function test_create_super_admin_duplicate_email(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $existingOrg = $this->createOrganization('Existing Org'); + // 2. Act + $this->client->request('GET', '/organization/create'); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'New Org', + 'organization_form[email]' => $existingOrg->getEmail(), // Duplicate email + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + self::assertSelectorTextContains('body', 'Une organisation avec cet email existe déjà.'); + } + + //endregion + + //region EDIT tests + + + #[Test] + public function test_edit_super_admin_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 4. Act: Request the edit page + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + // 5. Submit Form with the FILE object and correct field name 'logoUrl' + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'Edited Organization', + 'organization_form[email]' => 'edited-' . uniqid('', true) . '@test.com', + 'organization_form[address]' => '456 Edited Street', + 'organization_form[number]' => '0504030201', + 'organization_form[logoUrl]' => $logo, // Pass the OBJECT, not a + ]); + // 6. Assert + // Check for redirect (302) + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + // Ensure we see the success state + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + self::assertSelectorExists('#tabulator-org'); + + } + + #[Test] + public function test_edit_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 2. Act + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_edit_super_admin_invalid_data(): void + { + // 1. Arrange + $admin = $this->createUser('admin@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 2. Act + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => '', // Invalid: name is required + 'organization_form[email]' => 'not-an-email', // Invalid email format + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + } + + #[Test] + public function test_edit_nonexistent_organization_not_found(): void + { + // 1. Arrange + $admin = $this->createUser('admin@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/edit/99999'); // Assuming + // 3. Assert + self::assertResponseStatusCodeSame(302); + + self::assertResponseRedirects('/organization/'); + + } + //endregion + + + //region DELETE tests + + #[Test] + public function test_delete_super_admin_success(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $organization = $this->createOrganization('Org to Delete'); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + self::assertSelectorTextNotContains('body', 'Org to Delete'); + self::assertTrue($this->entityManager->getRepository(Organizations::class)->find($organization->getId())->isDeleted()); + + } + + #[Test] + public function test_delete_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + $organization = $this->createOrganization('Org to Delete'); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_delete_nonexistent_organization_not_found(): void + { + // 1. Arrange + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('POST', '/organization/delete/99999'); // Assuming + // 3. Assert + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function test_delete_organization_with_dependencies(): void + { + // 1. Arrange + $admin = $this->createUser('user@admin.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $organization = $this->createOrganization('Org with Deps'); + $app = $this->createApp('Dependent App'); + $role = $this->createRole('ROLE_USER'); + $uoLink = $this->createUOLink($admin, $organization); + $uoaLink = $this->createUOALink($uoLink, $app, $role); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + + self::assertSelectorTextContains('body', 'Aucune organisation trouvée'); + //link should be deactivated, not deleted + self::assertCount(1, $this->entityManager->getRepository(Apps::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(Roles::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UsersOrganizations::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UserOrganizatonApp::class)->findAll()); + self::assertTrue($this->entityManager->getRepository(Organizations::class)->find($organization->getId())->isDeleted()); + self::assertFalse($this->entityManager->getRepository(UserOrganizatonApp::class)->find($uoLink->getId())->isActive()); + self::assertFalse($this->entityManager->getRepository(UserOrganizatonApp::class)->find($uoaLink->getId())->isActive()); + self::assertSelectorNotExists('#tabulator-org'); + } + + //endregion + + +} diff --git a/tests/Functional/AbstractFunctionalTest.php b/tests/Functional/AbstractFunctionalTest.php index a0d3542..4542a3b 100644 --- a/tests/Functional/AbstractFunctionalTest.php +++ b/tests/Functional/AbstractFunctionalTest.php @@ -66,7 +66,6 @@ abstract class AbstractFunctionalTest extends WebTestCase $org->setNumber(100 + rand(1, 900)); // Example number $org->setAddress('123 ' . $name . ' St'); // Example address $org->setLogoUrl('https://example.com/org_logo.png'); - // Add other required fields if Organizations has non-nullable columns $this->entityManager->persist($org); $this->entityManager->flush(); @@ -115,4 +114,9 @@ abstract class AbstractFunctionalTest extends WebTestCase $this->entityManager->flush(); return $notification; } + + protected function countEntities(string $entityClass): int + { + return $this->entityManager->getRepository($entityClass)->count([]); + } } \ No newline at end of file From 01f73c2ef40e6424b8de53f817205d17df36185b Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 21 Jan 2026 10:01:13 +0100 Subject: [PATCH 42/43] Test for User Controller --- assets/controllers/user_controller.js | 1 + src/Controller/OrganizationController.php | 25 +- src/Controller/UserController.php | 38 +- src/Service/OrganizationsService.php | 4 +- src/Service/UserOrganizationService.php | 17 +- src/Service/UserService.php | 7 +- templates/user/edit.html.twig | 2 + templates/user/index.html.twig | 2 +- templates/user/userInformation.html.twig | 5 +- tests/Controller/UserControllerTest.php | 604 ++++++++++++++++++++++ 10 files changed, 675 insertions(+), 30 deletions(-) create mode 100644 tests/Controller/UserControllerTest.php diff --git a/assets/controllers/user_controller.js b/assets/controllers/user_controller.js index e8fd81f..c94cd8e 100644 --- a/assets/controllers/user_controller.js +++ b/assets/controllers/user_controller.js @@ -58,6 +58,7 @@ export default class extends Controller { table() { const columns = [ { + placeholder: "Aucun utilisateur trouvé", title: "", field: "isConnected", width: 40, // small column diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index ac8c076..96df1a2 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -54,7 +54,18 @@ class OrganizationController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); if($this->userService->hasAccessTo($actingUser, true)){ $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - + if(!$this->isGranted("ROLE_SUPER_ADMIN")){ + $userUO = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $actingUser, 'isActive' => true]); + $uoAdmin = 0; + foreach($userUO as $u){ + if($this->userService->isAdminOfOrganization($u->getOrganization())){ + $uoAdmin++; + } + } + if($uoAdmin === 1){ + return $this->redirectToRoute('organization_show', ['id' => $userUO[0]->getOrganization()->getId()]); + } + } return $this->render('organization/index.html.twig', [ 'hasOrganizations' => $orgCount > 0 ]); @@ -297,6 +308,17 @@ class OrganizationController extends AbstractController $qb->andWhere('o.email LIKE :email') ->setParameter('email', '%' . $filters['email'] . '%'); } + if(!$this->isGranted('ROLE_SUPER_ADMIN')) { + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $actingUser]); + foreach ($uo as $item) { + if($this->userService->isAdminOfOrganization($item->getOrganization())) { + $qb->andWhere('o.id = :orgId') + ->setParameter('orgId', $item->getOrganization()->getId()); + } + } + } + // Count total $countQb = clone $qb; @@ -319,7 +341,6 @@ class OrganizationController extends AbstractController ]; }, $rows); - // Tabulator expects: data, last_page (total pages), or total row count depending on config $lastPage = (int)ceil($total / $size); return $this->json([ diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index e3af065..32dc466 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -77,6 +77,10 @@ class UserController extends AbstractController // Chargement de l'utilisateur cible à afficher $user = $this->userRepository->find($id); + if (!$user) { + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } if (!$this->userService->hasAccessTo($user)) { $this->loggerService->logAccessDenied($actingUser->getId()); throw new AccessDeniedHttpException (self::ACCESS_DENIED); @@ -166,7 +170,7 @@ class UserController extends AbstractController // ------------------------------------------------------------------- // Calcul du flag de modification : utilisateur admin ET exactement 1 UO - $canEdit = $this->userService->canEditRolesCheck($actingUser, $user, $organization, $this->isGranted('ROLE_ADMIN')); + $canEdit = $this->userService->canEditRolesCheck($actingUser, $user, $organization, $this->isGranted('ROLE_ADMIN'), $singleUo); } catch (\Exception $e) { // En cas d'erreur, désactiver l'édition et logger l'exception @@ -187,14 +191,15 @@ class UserController extends AbstractController public function edit(int $id, Request $request): Response { $this->denyAccessUnlessGranted('ROLE_USER'); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + $user = $this->userRepository->find($id); + if (!$user) { + $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + throw $this->createNotFoundException(self::NOT_FOUND); + } try { - $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); - if ($this->userService->hasAccessTo($actingUser)) { - $user = $this->userRepository->find($id); - if (!$user) { - $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); - throw $this->createNotFoundException(self::NOT_FOUND); - } + if ($this->userService->hasAccessTo($user)) { + $form = $this->createForm(UserForm::class, $user); $form->handleRequest($request); @@ -277,6 +282,13 @@ class UserController extends AbstractController $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); throw $this->createNotFoundException(self::NOT_FOUND); } + if($this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org) && !$this->isGranted('ROLE_SUPER_ADMIN')) { + $this->loggerService->logAccessDenied($actingUser->getId()); + throw $this->createAccessDeniedException(self::ACCESS_DENIED); + } + }elseif($this->isGranted('ROLE_ADMIN')) { + $this->loggerService->logAccessDenied($actingUser->getId()); + throw $this->createAccessDeniedException(self::ACCESS_DENIED); } if ($form->isSubmitted() && $form->isValid()) { @@ -334,7 +346,7 @@ class UserController extends AbstractController } // Case : Organization provided and user doesn't already exist - if ($org) { + if ($orgId) { $this->userService->linkUserToOrganization( $user, $org, @@ -541,7 +553,6 @@ class UserController extends AbstractController try { $user = $this->userRepository->find($id); - if (!$user) { // Security/audit log for missing user $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); @@ -550,10 +561,10 @@ class UserController extends AbstractController } // Soft delete the user + $user->setIsActive(false); $user->setIsDeleted(true); $user->setModifiedAt(new \DateTimeImmutable('now')); - // Deactivate all org links $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); $this->loggerService->logOrganizationInformation($user->getId(), $actingUser->getId(), 'All user organization links deactivated'); @@ -596,7 +607,7 @@ class UserController extends AbstractController // No rethrow here: deletion succeeded; only notifications failed } - return new Response('', Response::HTTP_NO_CONTENT); // 204 + return $this->redirectToRoute('user_index'); } catch (\Exception $e) { // Route-level error logging → error.log @@ -609,7 +620,7 @@ class UserController extends AbstractController throw $e; // keep 404 semantics } - return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->redirectToRoute('user_index'); } } @@ -722,7 +733,6 @@ class UserController extends AbstractController 'statut' => $user->isActive(), ]; }, $rows); - $lastPage = (int)ceil($total / $size); return $this->json([ diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index 5ce58ae..25e2400 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -76,7 +76,7 @@ class OrganizationsService } - public function notifyOrganizationAdmins(array $data, string $type): UsersOrganizations + public function notifyOrganizationAdmins(array $data, string $type): void { $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); @@ -166,8 +166,6 @@ class OrganizationsService } } - - return $adminUO; } } diff --git a/src/Service/UserOrganizationService.php b/src/Service/UserOrganizationService.php index afd2d42..b5caca9 100644 --- a/src/Service/UserOrganizationService.php +++ b/src/Service/UserOrganizationService.php @@ -42,14 +42,17 @@ readonly class UserOrganizationService $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['organization' => $organizations, 'isActive' => true]); } //deactivate all UO links - foreach ($uos as $uo) { - $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); - $this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getId(), - 'Uo link deactivated'); - $uo->setIsActive(false); - $this->entityManager->persist($uo); - $this->actionService->createAction("Deactivate UO link", $actingUser, $uo->getOrganization(), $uo->getOrganization()->getName() ); + if (!empty($uos)) { + foreach ($uos as $uo) { + $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); + $this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getId(), + 'Uo link deactivated'); + $uo->setIsActive(false); + $this->entityManager->persist($uo); + $this->actionService->createAction("Deactivate UO link", $actingUser, $uo->getOrganization(), $uo->getOrganization()->getName() ); + } } + } diff --git a/src/Service/UserService.php b/src/Service/UserService.php index a8078a4..211203e 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -407,15 +407,18 @@ class UserService return $rolesArray; } - public function canEditRolesCheck(User $actingUser, User $user, $org, bool $isAdmin): bool + public function canEditRolesCheck(User $actingUser, User $user, $org, bool $isAdmin, UsersOrganizations $uo = null): bool { $userRoles = $user->getRoles(); $actingUserRoles = $actingUser->getRoles(); // if acting user is admin, he can´t edit super admin roles - if (in_array('ROLE_SUPER_ADMIN', $userRoles, true) && !in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true)) { + if (!in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true) && in_array('ROLE_SUPER_ADMIN', $userRoles, true)) { return false; } + if ($uo && $this->isAdminOfOrganization($uo->getOrganization())) { + return true; + } return $isAdmin && !empty($org); } diff --git a/templates/user/edit.html.twig b/templates/user/edit.html.twig index a45edf6..6dee523 100644 --- a/templates/user/edit.html.twig +++ b/templates/user/edit.html.twig @@ -7,7 +7,9 @@

Modifier l'utilisateur

+ {% if is_granted('ROLE_SUPER_ADMIN') %} Supprimer + {% endif %}
diff --git a/templates/user/index.html.twig b/templates/user/index.html.twig index f2fb290..99550e6 100644 --- a/templates/user/index.html.twig +++ b/templates/user/index.html.twig @@ -9,7 +9,7 @@

Gestion Utilisateurs

- Ajouter un utilisateur +{# Ajouter un utilisateur#}
diff --git a/templates/user/userInformation.html.twig b/templates/user/userInformation.html.twig index c4f50c9..207638a 100644 --- a/templates/user/userInformation.html.twig +++ b/templates/user/userInformation.html.twig @@ -15,7 +15,10 @@ {% if canEdit %} Modifier - {% endif %} + {% elseif user.id == app.user.id %} + Modifier + {% endif %}
diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php new file mode 100644 index 0000000..c132178 --- /dev/null +++ b/tests/Controller/UserControllerTest.php @@ -0,0 +1,604 @@ +createUser('admin@admin.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + $this->client->request('GET', '/user/'); + self::assertResponseIsSuccessful(); + self::assertSelectorTextNotContains('body', 'Aucun utilisateur trouvé'); + self::assertSelectorExists('#tabulator-userList'); + + } + + #[Test] + public function test_index_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + + // 2. Act + $this->client->request('GET', '/user/'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + + } + + //Can't test for no users as page is designed to always have at least one user (the logged in one) + //endregion + + //region Show Tests + + #[Test] + public function test_view_super_admin(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + $role = $this->createRole('ADMIN'); + $role2 = $this->createRole('EMPTY'); + $app = $this->createApp('Test App'); + $organization = $this->createOrganization('Test Org'); + $uo = $this->createUOLink($admin, $organization); + $uoa = $this->createUOALink($uo, $app, $role); + + $this->client->request('GET', '/user/view/' . $admin->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $admin->getEmail()); + self::assertSelectorTextContains('body', $admin->getName()); + self::assertSelectorTextContains('body', $app->getName()); + self::assertSelectorTextContains('body', ucfirst(strtolower($role->getName()))); + self::assertCheckboxChecked("roles[]", ucfirst(strtolower($role->getName()))); + } + + #[Test] + public function test_view_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $user2 = $this->createUser('user2@email.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/user/view/' . $user2->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_view_admin(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_ADMIN']); + $user = $this->createUser('user@admin'); + $this->client->loginUser($admin); + + $role = $this->createRole('ADMIN'); + $role2 = $this->createRole('USER'); + $app = $this->createApp('Test App'); + $organization = $this->createOrganization('Test Org'); + $uo = $this->createUOLink($admin, $organization); + $uo2 = $this->createUOLink($user, $organization); + $uoa = $this->createUOALink($uo, $app, $role); + $uoa2 = $this->createUOALink($uo2, $app, $role2); + + $this->client->request('GET', '/user/view/' . $user->getId() . '?organizationId=' . $organization->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $user->getEmail()); + self::assertSelectorTextContains('body', $user->getName()); + self::assertSelectorTextContains('body', $app->getName()); + self::assertSelectorTextContains('body', ucfirst(strtolower($role->getName()))); + } + + #[Test] + public function test_view_admin_different_organization_forbidden(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_ADMIN']); + $user = $this->createUser('user@admin'); + $this->client->loginUser($admin); + + $role = $this->createRole('ADMIN'); + $role2 = $this->createRole('USER'); + $app = $this->createApp('Test App'); + $organization = $this->createOrganization('Test Org'); + $organization2 = $this->createOrganization('Test Org2'); + $uo = $this->createUOLink($admin, $organization); + $uo2 = $this->createUOLink($user, $organization2); + $uoa = $this->createUOALink($uo, $app, $role); + $uoa2 = $this->createUOALink($uo2, $app, $role2); + + $this->client->request('GET', '/user/view/' . $user->getId() . '?organizationId=' . $organization->getId()); + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_view_user_self_success(): void + { + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + $this->client->request('GET', '/user/view/' . $user->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $user->getEmail()); + } + + #[Test] + public function test_view_user_self_with_organization_success(): void + { + $user = $this->createUser('user@email.com'); + $organization = $this->createOrganization('Test Org'); + $uo = $this->createUOLink($user, $organization); + $this->client->loginUser($user); + $this->client->request('GET', '/user/view/' . $user->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', $user->getEmail()); + } + + #[Test] + public function test_view_user_not_found(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + $this->client->request('GET', '/user/view/999999'); + self::assertResponseStatusCodeSame(404); + } + //endregion + + //region Edit Tests + + #[Test] + public function test_edit_super_admin_success(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('GET', '/user/edit/' . $admin->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Modifier l\'utilisateur'); + } + + #[Test] + public function test_edit_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/user/edit/' . $user->getId()); + // 3. Assert + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Modifier l\'utilisateur'); + } + + #[Test] + public function test_edit_other_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $user2 = $this->createUser('user2@email.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/user/edit/' . $user2->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_edit_user_not_found(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + $this->client->request('GET', '/user/edit/999999'); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function test_edit_super_admin_edit_other_user_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // 4. Act: Submit the Edit Form + $this->client->request('GET', '/user/edit/' . $admin->getId()); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'new@mail.com', + 'user_form[name]' => 'New Name', + 'user_form[pictureUrl]' => $logo, + ]); + + // 5. Assert + self::assertResponseRedirects('/user/view/' . $admin->getId()); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'new@mail.com'); + + // Clean up the temporary file} + unlink($tempFile); + } + + #[Test] + public function test_edit_admin_user_not_found(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('GET', '/user/edit/999999'); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function test_edit_admin_edit_other_user_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_ADMIN']); + $user = $this->createUser('user@user.com'); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $uoAdmin = $this->createUOLink($admin, $org); + $uoUser = $this->createUOLink($user, $org); + $app = $this->createApp('Test App'); + $roleAdmin = $this->createRole('ADMIN'); + $roleUser = $this->createRole('USER'); + $this->createUOALink($uoAdmin, $app, $roleAdmin); + $this->createUOALink($uoUser, $app, $roleUser); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // 4. Act: Submit the Edit Form + $this->client->request('GET', '/user/edit/' . $user->getId() . '?organizationId=' . $org->getId()); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'new@mail.com', + 'user_form[name]' => 'New Name', + 'user_form[pictureUrl]' => $logo, + ]); + + // 5. Assert + self::assertResponseRedirects('/user/view/' . $user->getId() . '?organizationId=' . $org->getId()); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'new@mail.com'); + + // Clean up the temporary file} + unlink($tempFile); + } + + + #[Test] + public function test_edit_admin_edit_other_user_different_organization_forbidden(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_ADMIN']); + $user = $this->createUser('user@user.com'); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $org2 = $this->createOrganization('Test Org2'); + $uoAdmin = $this->createUOLink($admin, $org); + $uoUser = $this->createUOLink($user, $org2); + $app = $this->createApp('Test App'); + $roleAdmin = $this->createRole('ADMIN'); + $roleUser = $this->createRole('USER'); + $this->createUOALink($uoAdmin, $app, $roleAdmin); + $this->createUOALink($uoUser, $app, $roleUser); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // 4. Act: Submit the Edit Form + $this->client->request('GET', '/user/edit/' . $user->getId() . '?organizationId=' . $org2->getId()); + // 5. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_edit_user_not_found_admin(): void + { + $admin = $this->createUser('admin@admin', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + + $this->client->request('GET', '/user/edit/999999'); + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function test_edit_user_self_success(): void + { + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + $this->client->request('GET', '/user/edit/' . $user->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Modifier l\'utilisateur'); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'new@email.com', + 'user_form[name]' => 'New Name', + ]); + self::assertResponseRedirects('/user/view/' . $user->getId()); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'new@email.com'); + } + + #[Test] + public function test_edit_user_self_with_organization_success(): void + { + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + $org = $this->createOrganization('Test Org'); + $this->createUOLink($user, $org); + $this->client->request('GET', '/user/edit/' . $user->getId() . '?organizationId=' . $org->getId()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Modifier l\'utilisateur'); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'new@email.com', + 'user_form[name]' => 'New Name', + ]); + self::assertResponseRedirects('/user/view/' . $user->getId() . '?organizationId=' . $org->getId()); + $this->client->followRedirect(); + self::assertSelectorTextContains('body', 'new@email.com'); + } + //endregion + + //region Create Tests + + #[Test] + public function test_create_super_admin_forbidden(): void + { + $admin = $this->createUser('admin@admin.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('GET', '/user/new'); + $this->client->followRedirect(); + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_create_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/user/new'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_create_admin_forbidden(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/user/new'); + // 3. Assert + self::assertResponseRedirects('/user/'); + $this->client->followRedirect(); + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_create_super_admin_valid(): void + { + $admin = $this->createUser('admin@admin.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $uo = $this->createUOLink($admin, $org); + $app = $this->createApp('Test App'); + $role = $this->createRole('ADMIN'); + $this->createUOALink($uo, $app, $role); + $this->client->request('GET', '/user/new?organizationId=' . $org->getId()); + self::assertResponseIsSuccessful(); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'email@email.com', + 'user_form[name]' => 'name', + 'user_form[surname]' => 'surname' + ]); + self::assertResponseRedirects('/organization/view/' . $org->getId()); + $this->client->followRedirect(); + self::assertCount(2, $this->entityManager->getRepository(User::class)->findAll()); + self::assertCount(2, $this->entityManager->getRepository(UsersOrganizations::class)->findAll()); + } + + #[Test] + public function test_create_admin_valid(): void + { + $admin = $this->createUser('admin@admin.com', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $uo = $this->createUOLink($admin, $org); + $app = $this->createApp('Test App'); + $role = $this->createRole('ADMIN'); + $this->createUOALink($uo, $app, $role); + $this->client->request('GET', '/user/new?organizationId=' . $org->getId()); + self::assertResponseIsSuccessful(); + $this->client->submitForm('Enregistrer', [ + 'user_form[email]' => 'email@email.com', + 'user_form[name]' => 'name', + 'user_form[surname]' => 'surname' + ]); + self::assertResponseRedirects('/organization/view/' . $org->getId()); + $this->client->followRedirect(); + self::assertCount(2, $this->entityManager->getRepository(User::class)->findAll()); + self::assertCount(2, $this->entityManager->getRepository(UsersOrganizations::class)->findAll()); + } + + #[Test] + public function test_create_admin_no_organization_forbidden(): void + { + $admin = $this->createUser('user@email.com', ['ROLE_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('GET', '/user/new'); + self::assertResponseRedirects('/user/'); + $this->client->followRedirect(); + self::assertResponseStatusCodeSame(403); + } + + //endregion + + //region Delete Tests + #[Test] + public function test_delete_super_admin_success(): void + { + $admin = $this->createUser('admin@admin.com', ['ROLE_SUPER_ADMIN']); + $user = $this->createUser('user@emai.com'); + $this->client->loginUser($admin); + $org = $this->createOrganization('Test Org'); + $app = $this->createApp('Test App'); + $role = $this->createRole('USER'); + $uoUser = $this->createUOLink($user, $org); + $this->createUOALink($uoUser, $app, $role); + $this->client->request('POST', '/user/delete/' . $user->getId()); + self::assertResponseRedirects('/user/'); + $this->client->followRedirect(); + self::assertCount(2, $this->entityManager->getRepository(User::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UsersOrganizations::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UserOrganizatonApp::class)->findAll()); + } + + #[Test] + public function test_delete_admin_forbidden(): void + { + $admin = $this->createUser('admin@email.com', ['ROLE_ADMIN']); + $user = $this->createUser('user@email.com'); + $this->client->loginUser($admin); + $this->client->request('POST', '/user/delete/' . $user->getId()); + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_delete_not_found(): void + { + $admin = $this->createUser('admin@eamil.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $this->client->request('POST', '/user/delete/999999'); + self::assertResponseStatusCodeSame(404); + } + + //endregion + + // même erreur que pour la sécurité. Problème lié au SSO. + //region activate/deactivate tests + +// #[Test] +// public function test_deactivate_super_admin_success(): void +// { +// $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); +// $user = $this->createUser('user@email.com'); +// $this->client->loginUser($admin); +// $org = $this->createOrganization('Test Org'); +// $app = $this->createApp('Test App'); +// $role = $this->createRole('USER'); +// $uoUser = $this->createUOLink($user, $org); +// $this->createUOALink($uoUser, $app, $role); +// $this->client->request('POST', '/user/activeStatus/' . $user->getId(), ['status' => 'deactivate']); +// self::assertResponseRedirects('/user/'); +// $this->client->followRedirect(); +// +// } + + //endregion + // même erreur que pour la sécurité. Problème lié au SSO. + //region tabulator tests +// #[Test] +// public function test_tabulator_super_admin_success(): void{ +// $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); +// $this->client->loginUser($admin); +// $this->client->request('GET', '/user/data'); +// self::assertResponseIsSuccessful(); +// self::assertResponseHeaderSame('Content-Type', 'application/json'); +// +// $response = $this->client->getResponse(); +// $data = json_decode($response->getContent(), true); +// self::assertArrayHasKey('data', $data); +// } + + //endregion +} \ No newline at end of file From a1b92aebce2b00b211d92c5fc0dae4e1816febc2 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 21 Jan 2026 16:08:21 +0100 Subject: [PATCH 43/43] added flash to files --- assets/controllers/user_controller.js | 3 +- src/Controller/OrganizationController.php | 30 ++++++-- src/Controller/UserController.php | 90 +++++++++++++---------- src/Service/UserService.php | 7 +- templates/organization/index.html.twig | 7 ++ templates/organization/show.html.twig | 7 ++ templates/user/index.html.twig | 7 ++ templates/user/show.html.twig | 7 ++ templates/user/userInformation.html.twig | 2 +- 9 files changed, 106 insertions(+), 54 deletions(-) diff --git a/assets/controllers/user_controller.js b/assets/controllers/user_controller.js index c94cd8e..44847d0 100644 --- a/assets/controllers/user_controller.js +++ b/assets/controllers/user_controller.js @@ -366,7 +366,8 @@ export default class extends Controller { vertAlign: "middle", headerSort: false, formatter: (cell) => { - const url = cell.getValue(); + const url = cell.getValue() + '?organizationId=' + this.orgIdValue; + console.log(url); if (url) { return eyeIconLink(url); } diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 96df1a2..9691197 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -94,9 +94,10 @@ class OrganizationController extends AbstractController $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Created"); $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Created", $organization->getId()); $this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName()); + $this->addFlash('success', 'Organisation crée avec succès.'); return $this->redirectToRoute('organization_index'); } catch (Exception $e) { - $this->addFlash('error', 'Error creating organization: ' . $e->getMessage()); + $this->addFlash('error', 'Erreur lors de la création de l\'organization'); $this->loggerService->logError('Error creating organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); } } @@ -122,7 +123,7 @@ class OrganizationController extends AbstractController 'org_id' => $id, 'message' => 'Organization not found for edit'], $actingUser->getId() ); - $this->addFlash('error', self::NOT_FOUND); + $this->addFlash('error', 'Erreur, l\'organization est introuvable.'); return $this->redirectToRoute('organization_index'); } if (!$this->isGranted("ROLE_SUPER_ADMIN")) { @@ -134,7 +135,7 @@ class OrganizationController extends AbstractController 'org_id' => $organization->getId(), 'message' => 'UO link not found for edit organization' ], $actingUser->getId()); - $this->addFlash('error', self::ACCESS_DENIED); + $this->addFlash('error', 'Erreur, accès refusé.'); return $this->redirectToRoute('organization_index'); } $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); @@ -145,7 +146,7 @@ class OrganizationController extends AbstractController 'role_id' => $roleAdmin->getId(), 'message' => 'UOA link not found for edit organization, user is not admin of organization' ], $actingUser->getId()); - $this->addFlash('error', self::ACCESS_DENIED); + $this->addFlash('error', 'Erreur, accès refusé.'); return $this->redirectToRoute('organization_index'); } } @@ -164,9 +165,11 @@ class OrganizationController extends AbstractController $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Edited", $organization->getId()); } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); + $this->addFlash('success', 'Organisation modifiée avec succès.'); return $this->redirectToRoute('organization_index'); }catch (Exception $e) { - $this->addFlash('error', 'Error editing organization: ' . $e->getMessage()); + $this->addFlash('error', 'Erreur lors de la modification de l\'organization'); + $this->loggerService->logError('Error editing organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); } } return $this->render('organization/edit.html.twig', [ @@ -186,12 +189,13 @@ class OrganizationController extends AbstractController 'org_id' => $id, 'message' => 'Organization not found for view' ], $actingUser->getId()); - $this->addFlash('error', self::NOT_FOUND); + $this->addFlash('error', 'Erreur, l\'organization est introuvable.'); return $this->redirectToRoute('organization_index'); } //check if the user is admin of the organization if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_SUPER_ADMIN")) { $this->loggerService->logAccessDenied($actingUser->getId()); + $this->addFlash('error', 'Erreur, accès refusé.'); throw new AccessDeniedHttpException('Access denied'); } @@ -222,6 +226,7 @@ class OrganizationController extends AbstractController 'org_id' => $id, 'message' => 'Organization not found for delete' ], $actingUser->getId()); + $this->addFlash('error', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } try { @@ -232,9 +237,15 @@ class OrganizationController extends AbstractController $this->entityManager->persist($organization); $this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName()); + $this->entityManager->flush(); + $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Deleted'); + if ($this->isGranted("ROLE_SUPER_ADMIN")) { + $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Deleted', $organization->getId()); + } + $this->addFlash('success', 'Organisation supprimée avec succès.'); }catch (\Exception $e){ $this->loggerService->logError($actingUser->getId(), ['message' => 'Error deleting organization: '.$e->getMessage()]); - $this->addFlash('error', 'Error deleting organization: ' . $e->getMessage()); + $this->addFlash('error', 'Erreur lors de la suppression de l\'organization.'); } return $this->redirectToRoute('organization_index'); @@ -251,6 +262,7 @@ class OrganizationController extends AbstractController 'org_id' => $id, 'message' => 'Organization not found for deactivate' ], $actingUser->getId()); + $this->addFlash('error', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -259,7 +271,7 @@ class OrganizationController extends AbstractController $this->entityManager->persist($organization); $this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName()); $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization deactivated', $organization->getId()); - + $this->addFlash('success', 'Organisation désactivé avec succès.'); return $this->redirectToRoute('organization_index'); } @@ -274,6 +286,7 @@ class OrganizationController extends AbstractController 'org_id' => $id, 'message' => 'Organization not found for activate' ], $actingUser->getId()); + $this->addFlash('error', 'Erreur, l\'organization est introuvable.'); throw $this->createNotFoundException(self::NOT_FOUND); } $organization->setIsActive(true); @@ -281,6 +294,7 @@ class OrganizationController extends AbstractController $this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Activated'); $this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Activated', $organization->getId()); $this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName()); + $this->addFlash('success', 'Organisation activée avec succès.'); return $this->redirectToRoute('organization_index'); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 32dc466..1f4ef43 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -79,10 +79,12 @@ class UserController extends AbstractController $user = $this->userRepository->find($id); if (!$user) { $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + $this->addFlash('error', "L'utilisateur demandé n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } if (!$this->userService->hasAccessTo($user)) { $this->loggerService->logAccessDenied($actingUser->getId()); + $this->addFlash('error', "L'utilisateur demandé n'existe pas."); throw new AccessDeniedHttpException (self::ACCESS_DENIED); } try { @@ -111,6 +113,7 @@ class UserController extends AbstractController 'user_id' => $user->getId(), 'organization_id' => $orgId], $actingUser->getId()); + $this->addFlash('error', "L'utilisateur n'est pas actif dans cette organisation."); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -129,10 +132,10 @@ class UserController extends AbstractController 'user_id' => $user->getId(), 'organization_id' => $orgId], $actingUser->getId()); + $this->addFlash('error', "L'utilisateur n'est pas actif dans une organisation."); throw $this->createNotFoundException(self::NOT_FOUND); } } - // Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées // Load user-organization-app roles (can be empty) $uoa = $this->entityManager @@ -141,14 +144,6 @@ class UserController extends AbstractController 'userOrganization' => $uoList, 'isActive' => true, ]); - if (!$uoa) { - $this->loggerService->logEntityNotFound('UsersOrganizationApplication', [ - 'user_id' => $user->getId(), - 'organization_id' => $orgId], - $actingUser->getId()); - throw $this->createNotFoundException(self::NOT_FOUND); - } - // Group UOA by app and ensure every app has a group $data['uoas'] = $this->userOrganizationAppService ->groupUserOrganizationAppsByApplication( @@ -170,12 +165,13 @@ class UserController extends AbstractController // ------------------------------------------------------------------- // Calcul du flag de modification : utilisateur admin ET exactement 1 UO - $canEdit = $this->userService->canEditRolesCheck($actingUser, $user, $organization, $this->isGranted('ROLE_ADMIN'), $singleUo); + $canEdit = $this->userService->canEditRolesCheck($actingUser, $user,$this->isGranted('ROLE_ADMIN'), $singleUo, $organization); } catch (\Exception $e) { - // En cas d'erreur, désactiver l'édition et logger l'exception - $canEdit = false; $this->errorLogger->error($e->getMessage()); + $this->addFlash('error', 'Une erreur est survenue lors du chargement des informations utilisateur.'); + $referer = $request->headers->get('referer'); + return $this->redirect($referer ?? $this->generateUrl('app_index')); } return $this->render('user/show.html.twig', [ 'user' => $user, @@ -195,6 +191,7 @@ class UserController extends AbstractController $user = $this->userRepository->find($id); if (!$user) { $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); + $this->addFlash('error', "L'utilisateur demandé n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } try { @@ -227,9 +224,11 @@ class UserController extends AbstractController "Super Admin accessed user edit page", ); } + $this->addFlash('success', 'Information modifié avec success.'); return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $orgId]); } $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); + $this->addFlash('error', "L'organisation n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } if ($this->isGranted('ROLE_SUPER_ADMIN')) { @@ -239,11 +238,11 @@ class UserController extends AbstractController "Super Admin accessed user edit page", ); } + $this->addFlash('success', 'Information modifié avec success.'); $this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier()); return $this->redirectToRoute('user_show', ['id' => $user->getId()]); } - return $this->render('user/edit.html.twig', [ 'user' => $user, 'form' => $form->createView(), @@ -251,8 +250,10 @@ class UserController extends AbstractController ]); } $this->loggerService->logAccessDenied($actingUser->getId()); + $this->addFlash('error', "Accès non autorisé."); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } catch (\Exception $e) { + $this->addFlash('error', 'Une erreur est survenue lors de la modification des informations utilisateur.'); $this->errorLogger->critical($e->getMessage()); } // Default deny access. shouldn't reach here normally. @@ -280,14 +281,17 @@ class UserController extends AbstractController $org = $this->organizationRepository->find($orgId); if (!$org) { $this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId()); + $this->addFlash('error', "L'organisation n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } if($this->isGranted('ROLE_ADMIN') && !$this->userService->isAdminOfOrganization($org) && !$this->isGranted('ROLE_SUPER_ADMIN')) { $this->loggerService->logAccessDenied($actingUser->getId()); + $this->addFlash('error', "Accès non autorisé."); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } }elseif($this->isGranted('ROLE_ADMIN')) { $this->loggerService->logAccessDenied($actingUser->getId()); + $this->addFlash('error', "Accès non autorisé."); throw $this->createAccessDeniedException(self::ACCESS_DENIED); } @@ -310,28 +314,31 @@ class UserController extends AbstractController $org->getId(), ); } + $this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. '); return $this->redirectToRoute('organization_show', ['id' => $orgId]); } + //Code semi-mort : On ne peut plus créer un utilisateur sans organisation // Case : User exists but NO organization context -> throw error on email field. - if ($existingUser) { - $this->loggerService->logError('Attempt to create user with existing email without organization', [ - 'target_user_email' => $user->getid(), - 'acting_user_id' => $actingUser->getId(), - ]); - $form->get('email')->addError( - new \Symfony\Component\Form\FormError( - 'This email is already in use. Add the user to an organization instead.' - ) - ); - - return $this->render('user/new.html.twig', [ - 'user' => $user, - 'form' => $form->createView(), - 'organizationId' => $orgId, - ]); - } +// if ($existingUser) { +// $this->loggerService->logError('Attempt to create user with existing email without organization', [ +// 'target_user_email' => $user->getid(), +// 'acting_user_id' => $actingUser->getId(), +// ]); +// +// $form->get('email')->addError( +// new \Symfony\Component\Form\FormError( +// 'This email is already in use. Add the user to an organization instead.' +// ) +// ); +// +// return $this->render('user/new.html.twig', [ +// 'user' => $user, +// 'form' => $form->createView(), +// 'organizationId' => $orgId, +// ]); +// } $picture = $form->get('pictureUrl')->getData(); $this->userService->createNewUser($user, $actingUser, $picture); @@ -361,10 +368,10 @@ class UserController extends AbstractController $org->getId() ); } - + $this->addFlash('success', 'Nouvel utilisateur créé et ajouté à l\'organisation avec succès. '); return $this->redirectToRoute('organization_show', ['id' => $orgId]); } - + $this->addFlash('success', 'Nouvel utilisateur créé avec succès. '); return $this->redirectToRoute('user_index'); } @@ -378,9 +385,10 @@ class UserController extends AbstractController $this->errorLogger->critical($e->getMessage()); if ($orgId) { + $this->addFlash('error', 'Une erreur est survenue lors de la création de l\'utilisateur pour l\'organisation .'); return $this->redirectToRoute('organization_show', ['id' => $orgId]); } - + $this->addFlash('error', 'Une erreur est survenue lors de la création de l\'utilisateur.'); return $this->redirectToRoute('user_index'); } } @@ -393,7 +401,6 @@ class UserController extends AbstractController $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $status = $request->get('status'); - try { // Access control if (!$this->userService->hasAccessTo($actingUser, true)) { @@ -465,7 +472,7 @@ class UserController extends AbstractController 'target_user_id' => $id, ]); - return new JsonResponse(['error' => 'Invalid status'], Response::HTTP_BAD_REQUEST); + return new JsonResponse(['error' => 'Status invalide'], Response::HTTP_BAD_REQUEST); } catch (\Throwable $e) { // Application-level error logging → error.log (via error channel) @@ -476,7 +483,7 @@ class UserController extends AbstractController throw $e; } - return new JsonResponse(['error' => 'An error occurred'], Response::HTTP_INTERNAL_SERVER_ERROR); + return new JsonResponse(['error' => 'Une erreur est survenue'], Response::HTTP_INTERNAL_SERVER_ERROR); } } @@ -556,7 +563,7 @@ class UserController extends AbstractController if (!$user) { // Security/audit log for missing user $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId()); - + $this->addFlash('error', "L'utilisateur demandé n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -604,9 +611,8 @@ class UserController extends AbstractController 'target_user_id' => $id, 'acting_user_id' => $actingUser?->getId(), ]); - // No rethrow here: deletion succeeded; only notifications failed } - + $this->addFlash('success', 'Utilisateur supprimé avec succès.'); return $this->redirectToRoute('user_index'); } catch (\Exception $e) { @@ -619,7 +625,7 @@ class UserController extends AbstractController if ($e instanceof NotFoundHttpException) { throw $e; // keep 404 semantics } - + $this->addFlash('error', 'Erreur lors de la suppression de l\'utilisateur\.'); return $this->redirectToRoute('user_index'); } } @@ -634,11 +640,13 @@ class UserController extends AbstractController $uo = $this->entityManager->getRepository(UsersOrganizations::class)->find($id); if (!$uo) { $this->loggerService->logEntityNotFound('UsersOrganization', ['id' => $id], $actingUser->getId()); + $this->addFlash('error', "La liaison utilisateur-organisation n'existe pas."); throw new NotFoundHttpException("UserOrganization not found"); } $application = $this->entityManager->getRepository(Apps::class)->find($request->get('appId')); if (!$application) { $this->loggerService->logEntityNotFound('Application', ['id' => $request->get('appId')], $actingUser->getId()); + $this->addFlash('error', "L'application demandée n'existe pas."); throw $this->createNotFoundException(self::NOT_FOUND); } @@ -646,6 +654,7 @@ class UserController extends AbstractController $roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']); if (!$roleUser) { $this->loggerService->logEntityNotFound('Role', ['name' => 'USER'], $actingUser->getId()); + $this->addFlash('error', "Le role de l'utilisateur n'existe pas."); throw $this->createNotFoundException('User role not found'); } @@ -667,6 +676,7 @@ class UserController extends AbstractController } $user = $uo->getUsers(); + $this->addFlash('success', 'Rôles mis à jour avec succès.'); return $this->redirectToRoute('user_show', [ 'user' => $user, 'id' => $user->getId(), diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 211203e..3e7ae08 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -94,7 +94,7 @@ class UserService $userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); if ($userOrganizations) { foreach ($userOrganizations as $uo) { - if ($this->isAdminOfOrganization($uo->getOrganization())) { + if ($this->isAdminOfOrganization($uo->getOrganization()) && $uo->getStatut() === "ACCEPTED" && $uo->isActive()) { return true; } } @@ -109,7 +109,7 @@ class UserService * entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity * (if he is admin for any application of the organization). * - * @param UsersOrganizations $usersOrganizations + * @param Organizations $organizations * @return bool * @throws Exception */ @@ -407,12 +407,11 @@ class UserService return $rolesArray; } - public function canEditRolesCheck(User $actingUser, User $user, $org, bool $isAdmin, UsersOrganizations $uo = null): bool + public function canEditRolesCheck(User $actingUser, User $user, bool $isAdmin, UsersOrganizations $uo = null, $org = null): bool { $userRoles = $user->getRoles(); $actingUserRoles = $actingUser->getRoles(); // if acting user is admin, he can´t edit super admin roles - if (!in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true) && in_array('ROLE_SUPER_ADMIN', $userRoles, true)) { return false; } diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index 2d8ff2f..a8b1e71 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -4,6 +4,13 @@ {% block body %}
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}
diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index fe48db6..95b045c 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -2,6 +2,13 @@ {% block body %}
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}
{% if organization.logoUrl %} diff --git a/templates/user/index.html.twig b/templates/user/index.html.twig index 99550e6..2d48030 100644 --- a/templates/user/index.html.twig +++ b/templates/user/index.html.twig @@ -5,6 +5,13 @@ {% block body %} {% if is_granted('ROLE_SUPER_ADMIN') %}
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}
diff --git a/templates/user/show.html.twig b/templates/user/show.html.twig index 6e85102..ccb255e 100644 --- a/templates/user/show.html.twig +++ b/templates/user/show.html.twig @@ -4,6 +4,13 @@
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %} {% if is_granted("ROLE_ADMIN") %}
diff --git a/templates/user/userInformation.html.twig b/templates/user/userInformation.html.twig index 207638a..4d1366e 100644 --- a/templates/user/userInformation.html.twig +++ b/templates/user/userInformation.html.twig @@ -15,7 +15,7 @@ {% if canEdit %} Modifier - {% elseif user.id == app.user.id %} + {% elseif user.id == app.user.id or is_granted("ROLE_SUPER_ADMIN") %} Modifier {% endif %}