Merge branch 'develop' into dockerize-portal

This commit is contained in:
qadamscqueezy 2026-01-21 16:33:37 +01:00
commit 3113313ad3
72 changed files with 6736 additions and 862 deletions

6
.gitignore vendored
View File

@ -1,6 +1,7 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local
/.env.test
/.env.local.php /.env.local.php
/.env.*.local /.env.*.local
/config/secrets/prod/prod.decrypt.private.php /config/secrets/prod/prod.decrypt.private.php
@ -15,11 +16,6 @@
.phpunit.result.cache .phpunit.result.cache
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/asset-mapper ### ###> symfony/asset-mapper ###
/public/assets/ /public/assets/
/assets/vendor/ /assets/vendor/

View File

@ -18,6 +18,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/mtdowling/jmespath.php" /> <excludeFolder url="file://$MODULE_DIR$/vendor/mtdowling/jmespath.php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" /> <excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@ -179,6 +179,8 @@
<path value="$PROJECT_DIR$/vendor/mtdowling/jmespath.php" /> <path value="$PROJECT_DIR$/vendor/mtdowling/jmespath.php" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" /> <path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" /> <path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
</include_path> </include_path>
</component> </component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" /> <component name="PhpProjectSharedConfiguration" php_language_level="8.2" />

View File

@ -36,3 +36,6 @@
``` html ``` html
<div class="card p-3"> <div class="card p-3">
``` ```
php bin/console messenger:consume async -vv

View File

@ -4,20 +4,36 @@ import {TabulatorFull as Tabulator} from 'tabulator-tables';
import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js"; import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js";
export default class extends Controller { export default class extends Controller {
static values = {aws: String}; static values = {aws: String,
id: String,
activities: Boolean,
table: Boolean,
sadmin: Boolean,
user: Number
};
static targets = ["activityList", "emptyMessage"]
connect() { connect() {
if(this.activitiesValue){
this.loadActivities();
setInterval(() => {
this.loadActivities();
}, 60000); // Refresh every 60 seconds
}
if (this.tableValue && this.sadminValue) {
this.table(); this.table();
} }
}
table(){ table(){
const table = new Tabulator("#tabulator-org", { const table = new Tabulator("#tabulator-org", {
// Register locales here // Register locales here
langs: TABULATOR_FR_LANG, 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) 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", ajaxConfig: "GET",
pagination: true, pagination: true,
paginationMode: "remote", paginationMode: "remote",
@ -82,4 +98,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 = `<div class="text-danger">Erreur lors du chargement.</div>`;
}
}
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 `
<div class="card shadow-sm mb-3 border-0 bg-white rounded-end"
style="border-left: 6px solid ${activity.color} !important;">
<div class="card-header bg-transparent border-0 pb-0 pt-3">
<h6 class="text-muted text-uppercase fw-bold mb-0" style="font-size: 0.85rem;">
${activity.date}
</h6>
</div>
<div class="card-body pt-2 pb-4">
<div class="card-text fs-5 lh-sm">
<span class="fw-bold text-dark">${activity.userName}</span>
<div class="text-secondary mt-1">${activity.actionType}</div>
</div>
</div>
</div>
`;
}).join('');
this.activityListTarget.innerHTML = html;
}
} }

View File

@ -58,6 +58,7 @@ export default class extends Controller {
table() { table() {
const columns = [ const columns = [
{ {
placeholder: "Aucun utilisateur trouvé",
title: "", title: "",
field: "isConnected", field: "isConnected",
width: 40, // small column width: 40, // small column
@ -365,7 +366,8 @@ export default class extends Controller {
vertAlign: "middle", vertAlign: "middle",
headerSort: false, headerSort: false,
formatter: (cell) => { formatter: (cell) => {
const url = cell.getValue(); const url = cell.getValue() + '?organizationId=' + this.orgIdValue;
console.log(url);
if (url) { if (url) {
return eyeIconLink(url); return eyeIconLink(url);
} }

View File

@ -39,6 +39,7 @@
"symfony/process": "7.2.*", "symfony/process": "7.2.*",
"symfony/property-access": "7.2.*", "symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*", "symfony/property-info": "7.2.*",
"symfony/rate-limiter": "7.2.*",
"symfony/runtime": "7.2.*", "symfony/runtime": "7.2.*",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"symfony/security-bundle": "7.2.*", "symfony/security-bundle": "7.2.*",
@ -107,7 +108,8 @@
} }
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^9.5", "dama/doctrine-test-bundle": "^8.3",
"phpunit/phpunit": "^11.0",
"symfony/browser-kit": "7.2.*", "symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*", "symfony/css-selector": "7.2.*",
"symfony/debug-bundle": "7.2.*", "symfony/debug-bundle": "7.2.*",

814
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,4 +19,5 @@ return [
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true], Knp\Bundle\TimeBundle\KnpTimeBundle::class => ['all' => true],
Aws\Symfony\AwsBundle::class => ['all' => true], Aws\Symfony\AwsBundle::class => ['all' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
]; ];

View File

@ -0,0 +1,5 @@
when@test:
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: true

View File

@ -9,28 +9,91 @@ monolog:
- security - security
- php - php
- error - error
- aws_management
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev: when@dev:
monolog: monolog:
handlers: handlers:
main: critical_errors:
type: stream type: fingers_crossed
path: "%kernel.logs_dir%/%kernel.environment%.log" action_level: critical
handler: error_nested
buffer_size: 50
error_nested:
type: rotating_file
path: "%kernel.logs_dir%/error.log"
level: debug level: debug
channels: ["!event"] max_files: 30
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration error:
#firephp: type: rotating_file
# type: firephp path: "%kernel.logs_dir%/error.log"
# level: info level: error # logs error, critical, alert, emergency
#chromephp: max_files: 30
# type: chromephp channels: [ error ]
# level: info php_errors:
console: type: rotating_file
type: console path: "%kernel.logs_dir%/php_error.log"
process_psr_3_messages: false level: warning # warnings, errors, fatals…
channels: ["!event", "!doctrine", "!console"] 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: when@test:
monolog: monolog:
@ -57,7 +120,7 @@ when@prod:
error_nested: error_nested:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/error.log" path: "%kernel.logs_dir%/critical.log"
level: debug level: debug
max_files: 30 max_files: 30
@ -76,12 +139,18 @@ when@prod:
channels: [ php ] channels: [ php ]
# User Management # User Management
user_management: user_management:
type: stream type: rotating_file
path: "%kernel.logs_dir%/user_management.log" path: "%kernel.logs_dir%/user_management.log"
level: info level: info
channels: [user_management] channels: [user_management]
max_files: 30 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
authentication: authentication:
type: rotating_file type: rotating_file

View File

@ -42,6 +42,9 @@ security:
user_checker: App\Security\UserChecker user_checker: App\Security\UserChecker
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
login_throttling:
max_attempts: 3
interval: '1 minute'
form_login: form_login:
login_path: app_login login_path: app_login
check_path: app_login check_path: app_login

View File

@ -1,5 +1,5 @@
framework: framework:
default_locale: en default_locale: fr
translator: translator:
default_path: '%kernel.project_dir%/translations' default_path: '%kernel.project_dir%/translations'
fallbacks: fallbacks:

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260105152103 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260106080636 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260106084653 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@ -1,20 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false" colors="true"
> cacheDirectory=".phpunit.cache">
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php> </php>
<testsuites> <testsuites>
@ -23,16 +18,9 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage processUncoveredFiles="true"> <source>
<include> <include>
<directory suffix=".php">src</directory> <directory suffix=".php">src</directory>
</include> </include>
</coverage> </source>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions>
</extensions>
</phpunit> </phpunit>

View File

@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use App\Entity\Actions;
use App\Entity\Organizations;
use App\Service\ActionService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/actions', name: 'actions_')]
class ActionController extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
private ActionService $actionService
) {
}
#[Route('/organization/{id}/activities-ajax', name: 'app_organization_activities_ajax', methods: ['GET'])]
public function fetchActivitiesAjax(Organizations $organization): JsonResponse
{
$actions = $this->entityManager->getRepository(Actions::class)->findBy(
['Organization' => $organization],
['date' => 'DESC'],
15
);
$formattedActivities = $this->actionService->formatActivities($actions);
return new JsonResponse($formattedActivities);
}
}

View File

@ -5,18 +5,20 @@ namespace App\Controller;
use App\Entity\Apps; use App\Entity\Apps;
use App\Entity\Organizations; use App\Entity\Organizations;
use App\Service\ActionService; use App\Service\ActionService;
use App\Service\LoggerService;
use App\Service\UserService; use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
#[Route(path: '/application', name: 'application_')] #[Route(path: '/application', name: 'application_')]
class ApplicationController extends AbstractController 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,7 +39,11 @@ class ApplicationController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$application = $this->entityManager->getRepository(Apps::class)->find($id); $application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) { if (!$application) {
$this->addFlash('error', "L'application n'existe pas ou n'est pas reconnu."); $this->loggerService->logEntityNotFound('Application', [
'applicationId' => $id,
'message' => "Application not found for editing."
], $actingUser->getId());
$this->addFlash('danger', "L'application n'existe pas ou n'est pas reconnu.");
return $this->redirectToRoute('application_index'); return $this->redirectToRoute('application_index');
} }
$applicationData = [ $applicationData = [
@ -50,12 +56,26 @@ class ApplicationController extends AbstractController
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
try{
$data = $request->request->all(); $data = $request->request->all();
$application->setName($data['name']); $application->setName($data['name']);
$application->setDescription($data['description']); $application->setDescription($data['description']);
$application->setDescriptionSmall($data['descriptionSmall']); $application->setDescriptionSmall($data['descriptionSmall']);
$this->entityManager->persist($application); $this->entityManager->persist($application);
$this->actionService->createAction("Modification de l'application ", $actingUser, 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());
}catch (\Exception $e){
$this->loggerService->logError('Application Edit Failed', [
'applicationId' => $application->getId(),
'applicationName' => $application->getName(),
'error' => $e->getMessage(),
'message' => "Failed to edit application."
], $actingUser);
}
return $this->redirectToRoute('application_index'); return $this->redirectToRoute('application_index');
} }
@ -66,36 +86,82 @@ class ApplicationController extends AbstractController
} }
#[Route(path: '/authorize/{id}', name: 'authorize', methods: ['POST'])] #[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'); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
try{
$application = $this->entityManager->getRepository(Apps::class)->find($id); $application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) { if (!$application) {
$this->loggerService->logEntityNotFound('Application', [
'applicationId' => $id,
'message' => "Application not found for authorization."
], $actingUser->getId());
throw $this->createNotFoundException("L'application n'existe pas."); throw $this->createNotFoundException("L'application n'existe pas.");
} }
$orgId = $request->get('organizationId'); $orgId = $request->get('organizationId');
$organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); $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); $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()); $this->actionService->createAction("Authorization d'accès", $actingUser, $organization, $application->getName());
return new Response('', Response::HTTP_OK); return new Response('', Response::HTTP_OK);
}catch (\Exception $e){
$this->loggerService->logError('Application Authorization Failed', [
'applicationId' => $id,
'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);
} }
#[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'); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$application = $this->entityManager->getRepository(Apps::class)->find($id); $application = $this->entityManager->getRepository(Apps::class)->find($id);
if (!$application) { 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."); throw $this->createNotFoundException("L'application n'existe pas.");
} }
$orgId = $request->get('organizationId'); $orgId = $request->get('organizationId');
$organization = $this->entityManager->getRepository(Organizations::Class)->find($orgId); $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); $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()); $this->actionService->createAction("Authorization retirer", $actingUser, $organization, $application->getName());
return new Response('', Response::HTTP_OK); return new Response('', Response::HTTP_OK);

View File

@ -28,7 +28,7 @@ class NotificationController extends AbstractController
#[Route(path: '/', name: 'index', methods: ['GET'])] #[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): JsonResponse public function index(): JsonResponse
{ {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notifications = $this->notificationRepository->findRecentByUser($user, 50); $notifications = $this->notificationRepository->findRecentByUser($user, 50);

View File

@ -3,6 +3,8 @@
namespace App\Controller; namespace App\Controller;
use App\Service\AccessTokenService; use App\Service\AccessTokenService;
use App\Service\LoggerService;
use App\Service\UserService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 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'])] #[Route('/oauth2/userinfo', name: 'userinfo', methods: ['GET'])]
public function userinfo(Request $request): JsonResponse public function userinfo(Request $request): JsonResponse
{ {
$user = $this->getUser(); $user = $this->getUser();
// dd($user);
if (!$user) { if (!$user) {
$this->loggerService->logAccessDenied($user->getId());
return new JsonResponse(['error' => 'Unauthorized'], 401); return new JsonResponse(['error' => 'Unauthorized'], 401);
} }
$this->loggerService->logUserAction($user->getId(), $user->getId(), 'Accessed userinfo endpoint');
return new JsonResponse([ return new JsonResponse([
'id' => $user->getId(), 'id' => $user->getId(),
'name' => $user->getName(), 'name' => $user->getName(),
@ -66,7 +71,7 @@ class OAuth2Controller extends AbstractController
if (!$userIdentifier) { if (!$userIdentifier) {
return new JsonResponse(["ERROR" => "User identifier is required"], Response::HTTP_BAD_REQUEST); return new JsonResponse(["ERROR" => "User identifier is required"], Response::HTTP_BAD_REQUEST);
} }
$accessTokenService->revokeTokens($userIdentifier); $accessTokenService->revokeUserTokens($userIdentifier);
$logger->info("Revoke tokens successfully"); $logger->info("Revoke tokens successfully");
return new JsonResponse(["SUCCESS" => "Tokens revoked successfully"], Response::HTTP_OK); return new JsonResponse(["SUCCESS" => "Tokens revoked successfully"], Response::HTTP_OK);

View File

@ -12,14 +12,20 @@ use App\Form\OrganizationForm;
use App\Repository\OrganizationsRepository; use App\Repository\OrganizationsRepository;
use App\Service\ActionService; use App\Service\ActionService;
use App\Service\AwsService; use App\Service\AwsService;
use App\Service\LoggerService;
use App\Service\OrganizationsService; use App\Service\OrganizationsService;
use App\Service\UserOrganizationService; use App\Service\UserOrganizationService;
use App\Service\UserService; use App\Service\UserService;
use Doctrine\DBAL\Exception\NonUniqueFieldNameException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Exception; use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use App\Entity\Organizations; use App\Entity\Organizations;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -37,7 +43,7 @@ class OrganizationController extends AbstractController
private readonly ActionService $actionService, private readonly ActionService $actionService,
private readonly UserOrganizationService $userOrganizationService, private readonly UserOrganizationService $userOrganizationService,
private readonly OrganizationsRepository $organizationsRepository, private readonly OrganizationsRepository $organizationsRepository,
private readonly AwsService $awsService) private readonly AwsService $awsService, private readonly LoggerService $loggerService, private readonly LoggerInterface $logger)
{ {
} }
@ -45,45 +51,30 @@ class OrganizationController extends AbstractController
public function index(): Response public function index(): Response
{ {
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if($this->userService->hasAccessTo($actingUser, true)){
if ($this->isGranted("ROLE_SUPER_ADMIN")) { $orgCount = $this->organizationsRepository->count(['isDeleted' => false]);
$organizations = $this->organizationsRepository->findBy(['isDeleted' => false]); if(!$this->isGranted("ROLE_SUPER_ADMIN")){
$userUO = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $actingUser, 'isActive' => true]);
$uoAdmin = 0;
} else { foreach($userUO as $u){
//get all the UO of the user if($this->userService->isAdminOfOrganization($u->getOrganization())){
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); $uoAdmin++;
$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) { if($uoAdmin === 1){
return $this->redirectToRoute('organization_show', ['id' => $organizations[0]->getId()]); return $this->redirectToRoute('organization_show', ['id' => $userUO[0]->getOrganization()->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', [ return $this->render('organization/index.html.twig', [
'organizationsData' => $organizationsData, '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 public function new(Request $request): Response
{ {
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
@ -100,10 +91,14 @@ class OrganizationController extends AbstractController
try { try {
$this->entityManager->persist($organization); $this->entityManager->persist($organization);
$this->entityManager->flush(); $this->entityManager->flush();
$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->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName());
$this->addFlash('success', 'Organisation crée avec succès.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} catch (Exception $e) { } 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()]);
} }
} }
return $this->render('organization/new.html.twig', [ return $this->render('organization/new.html.twig', [
@ -124,21 +119,34 @@ class OrganizationController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id); $organization = $this->organizationsRepository->find($id);
if (!$organization) { if (!$organization) {
$this->addFlash('error', self::NOT_FOUND); $this->loggerService->logEntityNotFound('Organization', [
'org_id' => $id,
'message' => 'Organization not found for edit'], $actingUser->getId()
);
$this->addFlash('error', 'Erreur, l\'organization est introuvable.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
if (!$this->isGranted("ROLE_SUPER_ADMIN")) { if (!$this->isGranted("ROLE_SUPER_ADMIN")) {
//check if the user is admin of the organization //check if the user is admin of the organization
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organization]);
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $user, 'organization' => $organization]);
if (!$uo) { if (!$uo) {
$this->addFlash('error', self::ACCESS_DENIED); $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', 'Erreur, accès refusé.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']); $roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]); $uoaAdmin = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo, 'role' => $roleAdmin]);
if (!$uoaAdmin) { if (!$uoaAdmin) {
$this->addFlash('error', self::ACCESS_DENIED); $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', 'Erreur, accès refusé.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
} }
@ -152,10 +160,16 @@ class OrganizationController extends AbstractController
try { try {
$this->entityManager->persist($organization); $this->entityManager->persist($organization);
$this->entityManager->flush(); $this->entityManager->flush();
$this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Edited");
if ($this->isGranted("ROLE_SUPER_ADMIN")) {
$this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Edited", $organization->getId());
}
$this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName());
$this->addFlash('success', 'Organisation modifiée avec succès.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
}catch (Exception $e) { }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', [ return $this->render('organization/edit.html.twig', [
@ -171,43 +185,31 @@ class OrganizationController extends AbstractController
$organization = $this->organizationsRepository->find($id); $organization = $this->organizationsRepository->find($id);
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if (!$organization) { if (!$organization) {
$this->addFlash('error', self::NOT_FOUND); $this->loggerService->logEntityNotFound('Organization', [
'org_id' => $id,
'message' => 'Organization not found for view'
], $actingUser->getId());
$this->addFlash('error', 'Erreur, l\'organization est introuvable.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
//check if the user is admin of the organization //check if the user is admin of the organization
if (!$this->isGranted("ROLE_SUPER_ADMIN") && !$this->userService->isAdminOfOrganization($organization)) { if (!$this->userService->isAdminOfOrganization($organization) && !$this->isGranted("ROLE_SUPER_ADMIN")) {
$this->createNotFoundException(self::NOT_FOUND); $this->loggerService->logAccessDenied($actingUser->getId());
$this->addFlash('error', 'Erreur, accès refusé.');
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 $allApps = $this->entityManager->getRepository(Apps::class)->findAll(); // appsAll
$orgApps = $organization->getApps()->toArray(); // apps $orgApps = $organization->getApps()->toArray(); // apps
$apps = $this->organizationsService->appsAccess($allApps, $orgApps); $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); $activities = $this->actionService->formatActivities($actions);
$this->actionService->createAction("View Organization", $actingUser, $organization, $organization->getName()); $this->actionService->createAction("View Organization", $actingUser, $organization, $organization->getName());
return $this->render('organization/show.html.twig', [ return $this->render('organization/show.html.twig', [
'organization' => $organization, 'organization' => $organization,
'newUsers' => $newUsers,
'adminUsers' => $adminUsers,
'users' => $users,
'applications' => $apps, 'applications' => $apps,
'activities' => $activities, 'activities' => $activities,
]); ]);
@ -220,8 +222,14 @@ class OrganizationController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id); $organization = $this->organizationsRepository->find($id);
if (!$organization) { if (!$organization) {
$this->loggerService->logEntityNotFound('Organization', [
'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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
try {
$organization->setIsActive(false); $organization->setIsActive(false);
$organization->setIsDeleted(true); $organization->setIsDeleted(true);
// Deactivate all associated UsersOrganizations // Deactivate all associated UsersOrganizations
@ -229,6 +237,17 @@ class OrganizationController extends AbstractController
$this->entityManager->persist($organization); $this->entityManager->persist($organization);
$this->actionService->createAction("Delete Organization", $actingUser, $organization, $organization->getName()); $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', 'Erreur lors de la suppression de l\'organization.');
}
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
@ -239,12 +258,20 @@ class OrganizationController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id); $organization = $this->organizationsRepository->find($id);
if (!$organization) { if (!$organization) {
$this->loggerService->logEntityNotFound('Organization', [
'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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$organization->setIsActive(false); $organization->setIsActive(false);
// $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization); // $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
$this->entityManager->persist($organization); $this->entityManager->persist($organization);
$this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName()); $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'); return $this->redirectToRoute('organization_index');
} }
@ -255,16 +282,24 @@ class OrganizationController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$organization = $this->organizationsRepository->find($id); $organization = $this->organizationsRepository->find($id);
if (!$organization) { if (!$organization) {
$this->loggerService->logEntityNotFound('Organization', [
'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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$organization->setIsActive(true); $organization->setIsActive(true);
$this->entityManager->persist($organization); $this->entityManager->persist($organization);
$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->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName());
$this->addFlash('success', 'Organisation activée avec succès.');
return $this->redirectToRoute('organization_index'); return $this->redirectToRoute('organization_index');
} }
// API endpoint to fetch organization data for Tabulator // 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 public function data(Request $request): JsonResponse
{ {
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
@ -276,8 +311,6 @@ class OrganizationController extends AbstractController
$filters = $request->query->all('filter'); $filters = $request->query->all('filter');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$qb = $this->organizationsRepository->createQueryBuilder('o') $qb = $this->organizationsRepository->createQueryBuilder('o')
->where('o.isDeleted = :del')->setParameter('del', false); ->where('o.isDeleted = :del')->setParameter('del', false);
@ -289,6 +322,17 @@ class OrganizationController extends AbstractController
$qb->andWhere('o.email LIKE :email') $qb->andWhere('o.email LIKE :email')
->setParameter('email', '%' . $filters['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 // Count total
$countQb = clone $qb; $countQb = clone $qb;
@ -311,7 +355,6 @@ class OrganizationController extends AbstractController
]; ];
}, $rows); }, $rows);
// Tabulator expects: data, last_page (total pages), or total row count depending on config
$lastPage = (int)ceil($total / $size); $lastPage = (int)ceil($total / $size);
return $this->json([ return $this->json([

View File

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository; use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService; use App\Service\AccessTokenService;
use App\Service\LoggerService;
use App\Service\OrganizationsService; use App\Service\OrganizationsService;
use App\Service\UserService; use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -30,7 +31,7 @@ class SecurityController extends AbstractController
private readonly UsersOrganizationsRepository $uoRepository, private readonly UsersOrganizationsRepository $uoRepository,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly OrganizationsService $organizationsService) private readonly OrganizationsService $organizationsService, private readonly LoggerService $loggerService, private readonly Security $security)
{ {
$this->cguUserService = $cguUserService; $this->cguUserService = $cguUserService;
} }
@ -51,10 +52,12 @@ class SecurityController extends AbstractController
public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response
{ {
try { try {
$user = $this->userService->getUserByIdentifier($this->security->getUser()->getUserIdentifier());
$id = $user->getId();
if ($stack->getSession()->invalidate()) { if ($stack->getSession()->invalidate()) {
$accessTokenService->revokeTokens($security->getUser()->getUserIdentifier()); $accessTokenService->revokeUserTokens($security->getUser()->getUserIdentifier());
$security->logout(false); $security->logout(false);
$logger->info("Logout successfully"); $this->loggerService->logUserConnection('User logged out', ['user_id' => $id]);
return $this->redirect('/'); return $this->redirect('/');
} }
} catch (\Exception $e) { } catch (\Exception $e) {
@ -69,6 +72,7 @@ class SecurityController extends AbstractController
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
if (!$request->request->has('decline')) { if (!$request->request->has('decline')) {
$this->cguUserService->acceptLatestCgu($this->getUser()); $this->cguUserService->acceptLatestCgu($this->getUser());
$this->loggerService->logCGUAcceptance($this->getUser()->getId());
} }
return $this->redirectToRoute('oauth2_authorize', $request->query->all()); return $this->redirectToRoute('oauth2_authorize', $request->query->all());
@ -83,12 +87,24 @@ class SecurityController extends AbstractController
$error = $request->get('error'); $error = $request->get('error');
$user = $this->userRepository->find($id); $user = $this->userRepository->find($id);
if (!$user) { 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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$token = $request->get('token'); $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.'; $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', [ return $this->render('security/password_setup.html.twig', [
'id' => $id, 'id' => $id,
@ -98,17 +114,19 @@ class SecurityController extends AbstractController
} }
#[Route('/password_reset/{id}', name: 'password_reset', methods: ['POST'])] #[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); $user = $this->userRepository->find($id);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User', ['user_id' => $id,
'message' => 'user not found for password reset'], $id);
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$newPassword = $_POST['_password'] ?? null; $newPassword = $_POST['_password'] ?? null;
$confirmPassword = $_POST['_passwordConfirm'] ?? null; $confirmPassword = $_POST['_passwordConfirm'] ?? null;
if ($newPassword !== $confirmPassword) { if ($newPassword !== $confirmPassword) {
$error = 'Les mots de passe ne correspondent pas. Veuillez réessayer.'; $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', [ return $this->redirectToRoute('password_setup', [
'id' => $id, 'id' => $id,
'token' => $_POST['token'] ?? '', 'token' => $_POST['token'] ?? '',
@ -116,10 +134,11 @@ class SecurityController extends AbstractController
} }
if (!$this->userService->isPasswordStrong($newPassword)) { if (!$this->userService->isPasswordStrong($newPassword)) {
$error = 'Le mot de passe ne respecte pas les critères de sécurité. Veuillez en choisir un autre.'; $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."); $this->loggerService->logUserAction($id, $id, ' provided a weak password during password reset.');
return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error' => $error]); return $this->redirectToRoute('password_setup', ['id' => $id, 'token' => $_POST['token'] ?? '', 'error' => $error]);
} }
$this->userService->updateUserPassword($user, $newPassword); $this->userService->updateUserPassword($user, $newPassword);
$this->loggerService->logUserAction($id, $id, 'Password reset user successfully.');
$orgId = $this->userService->getOrgFromToken($_POST['token']); $orgId = $this->userService->getOrgFromToken($_POST['token']);
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
if ($uo) { if ($uo) {
@ -127,7 +146,11 @@ class SecurityController extends AbstractController
$uo->setIsActive(true); $uo->setIsActive(true);
$this->entityManager->persist($uo); $this->entityManager->persist($uo);
$this->entityManager->flush(); $this->entityManager->flush();
$data = ['user' => $user, 'organization' => $uo->getOrganization()]; $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(),
];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED"); $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED");
} }

View File

@ -13,26 +13,25 @@ use App\Repository\OrganizationsRepository;
use App\Repository\RolesRepository; use App\Repository\RolesRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository; use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService;
use App\Service\ActionService; use App\Service\ActionService;
use App\Service\AwsService; use App\Service\AwsService;
use App\Service\EmailService; use App\Service\EmailService;
use App\Service\LoggerService;
use App\Service\OrganizationsService; use App\Service\OrganizationsService;
use App\Service\UserOrganizationAppService; use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService; use App\Service\UserOrganizationService;
use App\Service\UserService; use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use mysql_xdevapi\Exception;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 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\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[Route(path: '/user', name: 'user_')] #[Route(path: '/user', name: 'user_')]
class UserController extends AbstractController class UserController extends AbstractController
@ -51,16 +50,14 @@ class UserController extends AbstractController
private readonly OrganizationsRepository $organizationRepository, private readonly OrganizationsRepository $organizationRepository,
private readonly LoggerInterface $userManagementLogger, private readonly LoggerInterface $userManagementLogger,
private readonly LoggerInterface $organizationManagementLogger, private readonly LoggerInterface $organizationManagementLogger,
private readonly LoggerInterface $accessControlLogger,
private readonly LoggerInterface $EmailNotificationLogger,
private readonly LoggerInterface $adminActionsLogger,
private readonly LoggerInterface $errorLogger, private readonly LoggerInterface $errorLogger,
private readonly LoggerInterface $SecurityLogger, private readonly LoggerInterface $securityLogger,
private readonly LoggerService $loggerService,
private readonly EmailService $emailService, private readonly EmailService $emailService,
private readonly AwsService $awsService, private readonly AwsService $awsService,
private readonly OrganizationsService $organizationsService, private readonly OrganizationsService $organizationsService,
private readonly AppsRepository $appsRepository, private readonly AppsRepository $appsRepository,
private readonly RolesRepository $rolesRepository, private readonly RolesRepository $rolesRepository, private readonly AccessTokenService $accessTokenService,
) )
{ {
} }
@ -76,13 +73,20 @@ class UserController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
// Vérification des droits d'accès supplémentaires // Vérification des droits d'accès supplémentaires
if (!$this->userService->hasAccessTo($actingUser)) {
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
// Chargement de l'utilisateur cible à afficher // Chargement de l'utilisateur cible à afficher
$user = $this->userRepository->find($id); $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 { try {
// Paramètre optionnel de contexte organisationnel // Paramètre optionnel de contexte organisationnel
$orgId = $request->query->get('organizationId'); $orgId = $request->query->get('organizationId');
@ -105,6 +109,11 @@ class UserController extends AbstractController
]); ]);
if (!$uoList) { if (!$uoList) {
$this->loggerService->logEntityNotFound('UsersOrganization', [
'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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
@ -118,8 +127,15 @@ class UserController extends AbstractController
'users' => $user, 'users' => $user,
'isActive' => true, 'isActive' => true,
]); ]);
if (!$uoList) {
$this->loggerService->logEntityNotFound('UsersOrganization', [
'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 // Charger les liens UserOrganizationApp (UOA) actifs pour les UO trouvées
// Load user-organization-app roles (can be empty) // Load user-organization-app roles (can be empty)
$uoa = $this->entityManager $uoa = $this->entityManager
@ -128,7 +144,6 @@ class UserController extends AbstractController
'userOrganization' => $uoList, 'userOrganization' => $uoList,
'isActive' => true, 'isActive' => true,
]); ]);
// Group UOA by app and ensure every app has a group // Group UOA by app and ensure every app has a group
$data['uoas'] = $this->userOrganizationAppService $data['uoas'] = $this->userOrganizationAppService
->groupUserOrganizationAppsByApplication( ->groupUserOrganizationAppsByApplication(
@ -150,12 +165,13 @@ class UserController extends AbstractController
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Calcul du flag de modification : utilisateur admin ET exactement 1 UO // 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,$this->isGranted('ROLE_ADMIN'), $singleUo, $organization);
} catch (\Exception $e) { } catch (\Exception $e) {
// En cas d'erreur, désactiver l'édition et logger l'exception
$canEdit = false;
$this->errorLogger->error($e->getMessage()); $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', [ return $this->render('user/show.html.twig', [
'user' => $user, 'user' => $user,
@ -171,64 +187,61 @@ class UserController extends AbstractController
public function edit(int $id, Request $request): Response public function edit(int $id, Request $request): Response
{ {
$this->denyAccessUnlessGranted('ROLE_USER'); $this->denyAccessUnlessGranted('ROLE_USER');
try{
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
$user = $this->userRepository->find($id); $user = $this->userRepository->find($id);
if (!$user) { if (!$user) {
$this->userManagementLogger->notice('User not found for edit', [ $this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId());
'target_user_id' => $user->getId(), $this->addFlash('error', "L'utilisateur demandé n'existe pas.");
'acting_user_id' => $actingUser->getId(),
'ip' => $request->getClientIp(),
'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM),
]);
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
try {
if ($this->userService->hasAccessTo($user)) {
$form = $this->createForm(UserForm::class, $user); $form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request); $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()) { if ($form->isSubmitted() && $form->isValid()) {
// Handle user edit // Handle user edit
$picture = $form->get('pictureUrl')->getData(); $picture = $form->get('pictureUrl')->getData();
$this->userService->formatNewUserData($user, $picture); $this->userService->formatUserData($user, $picture);
$user->setModifiedAt(new \DateTimeImmutable('now')); $user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
//log and action //log and action
$this->userManagementLogger->notice('User information edited', [ $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User information edited');
'target_user_id' => $user->getId(), $orgId = $request->get('organizationId');
'acting_user_id' => $actingUser->getId(), if ($orgId) {
'organization_id' => $request->get('organizationId'), $org = $this->organizationRepository->find($orgId);
'ip' => $request->getClientIp(),
'timestamp' => (new \DateTimeImmutable('now'))->format(DATE_ATOM),
]);
if ($request->get('organizationId')) {
$org = $this->organizationRepository->find($request->get('organizationId'));
if ($org) { if ($org) {
$this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier()); $this->actionService->createAction("Edit user information", $actingUser, $org, $user->getUserIdentifier());
$this->organizationManagementLogger->info('User edited within organization context', [ $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User information edited');
'target_user_id' => $user->getId(), if ($this->isGranted('ROLE_SUPER_ADMIN')) {
'organization_id' => $org->getId(), $this->loggerService->logSuperAdmin(
'acting_user' => $actingUser->getUserIdentifier(), $user->getId(),
'ip' => $request->getClientIp(), $actingUser->getId(),
]); "Super Admin accessed user edit page",
return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $request->get('organizationId')]); );
} }
} else { $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')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$actingUser->getId(),
"Super Admin accessed user edit page",
);
}
$this->addFlash('success', 'Information modifié avec success.');
$this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier()); $this->actionService->createAction("Edit user information", $actingUser, null, $user->getUserIdentifier());
return $this->redirectToRoute('user_show', ['id' => $user->getId()]); return $this->redirectToRoute('user_show', ['id' => $user->getId()]);
} }
}
return $this->render('user/edit.html.twig', [ return $this->render('user/edit.html.twig', [
'user' => $user, 'user' => $user,
@ -236,15 +249,16 @@ class UserController extends AbstractController
'organizationId' => $request->get('organizationId') 'organizationId' => $request->get('organizationId')
]); ]);
} }
$this->loggerService->logAccessDenied($actingUser->getId());
$this->addFlash('error', "Accès non autorisé.");
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->addFlash('error', 'Une erreur est survenue lors de la modification des informations utilisateur.');
$this->errorLogger->critical($e->getMessage()); $this->errorLogger->critical($e->getMessage());
} }
$this->SecurityLogger->warning('Access denied on user edit', [ // Default deny access. shouldn't reach here normally.
'target_user_id' => $id,
'acting_user' => $actingUser?->getId(),
'ip' => $request->getClientIp(),
]);
throw $this->createAccessDeniedException(self::ACCESS_DENIED); throw $this->createAccessDeniedException(self::ACCESS_DENIED);
} }
#[Route('/new', name: 'new', methods: ['GET', 'POST'])] #[Route('/new', name: 'new', methods: ['GET', 'POST'])]
@ -253,70 +267,128 @@ class UserController extends AbstractController
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
try { try {
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) { if (!$this->userService->hasAccessTo($actingUser)) {
$this->loggerService->logAccessDenied($actingUser->getId());
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
$user = new User(); $user = new User();
$form = $this->createForm(UserForm::class, $user); $form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request); $form->handleRequest($request);
$orgId = $request->get('organizationId'); $orgId = $request->get('organizationId');
if ($orgId) { if ($orgId) {
$org = $this->organizationRepository->find($orgId) ?? throw new NotFoundHttpException(sprintf('%s not found', $orgId)); $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);
} }
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]); $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()); // Case : User exists + has organization context
$this->logger->notice("User added to organization " . $org->getName()); if ($existingUser && $org) {
$this->emailService->sendExistingUserNotificationEmail($existingUser, $org); $this->userService->addExistingUserToOrganization(
$this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier()); $existingUser,
$data = ['user' => $existingUser, 'organization' => $org]; $org,
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); $actingUser,
);
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$existingUser->getId(),
$actingUser->getId(),
"Super Admin linked user to organization",
$org->getId(),
);
}
$this->addFlash('success', 'Utilisateur ajouté avec succès à l\'organisation. ');
return $this->redirectToRoute('organization_show', ['id' => $orgId]); 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,
// ]);
// }
// Handle file upload
$picture = $form->get('pictureUrl')->getData(); $picture = $form->get('pictureUrl')->getData();
$this->userService->formatNewUserData($user, $picture, true); $this->userService->createNewUser($user, $actingUser, $picture);
if ($orgId) { if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$uo = new UsersOrganizations(); $this->loggerService->logSuperAdmin(
$uo->setUsers($user); $user->getId(),
$uo->setOrganization($org); $actingUser->getId(),
$uo->setStatut("INVITED"); "Super Admin created new user",
$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]);
} }
// Case : Organization provided and user doesn't already exist
if ($orgId) {
$this->userService->linkUserToOrganization(
$user,
$org,
$actingUser,
);
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$actingUser->getId(),
"Super Admin linked user to organization during creation",
$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'); return $this->redirectToRoute('user_index');
} }
}
return $this->render('user/new.html.twig', [ return $this->render('user/new.html.twig', [
'user' => $user, 'user' => $user,
'form' => $form->createView(), 'form' => $form->createView(),
'organizationId' => $orgId 'organizationId' => $orgId,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error($e->getMessage()); $this->errorLogger->critical($e->getMessage());
if ($orgId) { 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]); 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'); return $this->redirectToRoute('user_index');
} }
} }
@ -326,44 +398,98 @@ class UserController extends AbstractController
public function activeStatus(int $id, Request $request): JsonResponse public function activeStatus(int $id, Request $request): JsonResponse
{ {
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$status = $request->get('status');
try { try {
if ($this->userService->hasAccessTo($actingUser, true)) { // Access control
if (!$this->userService->hasAccessTo($actingUser, true)) {
$this->loggerService->logAccessDenied($actingUser->getId());
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
// Load target user
$user = $this->userRepository->find($id); $user = $this->userRepository->find($id);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$status = $request->get('status');
// Deactivate
if ($status === 'deactivate') { if ($status === 'deactivate') {
$user->setIsActive(false); $user->setIsActive(false);
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user); $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user);
if ($this->userService->isUserConnected($user->getUserIdentifier())) { if ($this->userService->isUserConnected($user->getUserIdentifier())) {
$this->userService->revokeUserTokens($user->getUserIdentifier()); $this->accessTokenService->revokeUserTokens($user->getUserIdentifier());
} }
$user->setModifiedAt(new \DateTimeImmutable('now')); $user->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
$this->logger->notice("User deactivated " . $user->getUserIdentifier()); $this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User deactivated');
$this->actionService->createAction("Deactivate user", $actingUser, null, $user->getUserIdentifier());
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$actingUser->getId(),
'Super admin deactivated user'
);
}
$this->actionService->createAction('Deactivate user', $actingUser, null, $user->getUserIdentifier());
return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK);
} }
// Activate
if ($status === 'activate') { if ($status === 'activate') {
$user->setIsActive(true); $user->setIsActive(true);
$user->setModifiedAt(new \DateTimeImmutable('now')); $user->setModifiedAt(new \DateTimeImmutable('now'));
$this->logger->notice("User activated " . $user->getUserIdentifier()); $this->entityManager->persist($user);
$this->actionService->createAction("Activate user", $actingUser, null, $user->getUserIdentifier()); $this->entityManager->flush();
$this->loggerService->logUserAction($user->getId(), $actingUser->getId(), 'User activated');
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$actingUser->getId(),
'Super admin activated user'
);
}
$this->actionService->createAction('Activate user', $actingUser, null, $user->getUserIdentifier());
return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); return new JsonResponse(['status' => 'activated'], Response::HTTP_OK);
} }
// Invalid status
$this->loggerService->logError('Invalid status provided for activeStatus', [
'requested_status' => $status,
'target_user_id' => $id,
]);
return new JsonResponse(['error' => 'Status invalide'], Response::HTTP_BAD_REQUEST);
} catch (\Throwable $e) {
// Application-level error logging → error.log (via error channel)
$this->errorLogger->critical($e->getMessage());
// Preserve 403/404 semantics, 500 for everything else
if ($e instanceof NotFoundHttpException || $e instanceof AccessDeniedException) {
throw $e;
} }
}catch (\Exception $e){
$this->logger->error($e->getMessage()); return new JsonResponse(['error' => 'Une erreur est survenue'], Response::HTTP_INTERNAL_SERVER_ERROR);
} }
throw $this->createNotFoundException(self::NOT_FOUND);
} }
#[Route('/organization/activateStatus/{id}', name: 'activate_organization', methods: ['GET', 'POST'])] #[Route('/organization/activateStatus/{id}', name: 'activate_organization', methods: ['GET', 'POST'])]
public function activateStatusOrganization(int $id, Request $request): JsonResponse{ public function activateStatusOrganization(int $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN'); $this->denyAccessUnlessGranted('ROLE_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
try { try {
@ -371,15 +497,19 @@ class UserController extends AbstractController
$orgId = $request->get('organizationId'); $orgId = $request->get('organizationId');
$org = $this->organizationRepository->find($orgId); $org = $this->organizationRepository->find($orgId);
if (!$org) { if (!$org) {
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$user = $this->userRepository->find($id); $user = $this->userRepository->find($id);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User', ['id' => $id], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$uo = $this->uoRepository->findOneBy(['users' => $user, $uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org]); 'organization' => $org]);
if (!$uo) { if (!$uo) {
$this->loggerService->logEntityNotFound('UsersOrganization', ['user_id' => $user->getId(),
'organization_id' => $org->getId()], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$status = $request->get('status'); $status = $request->get('status');
@ -391,7 +521,7 @@ class UserController extends AbstractController
$data = ['user' => $user, $data = ['user' => $user,
'organization' => $org]; 'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_DEACTIVATED"); $this->organizationsService->notifyOrganizationAdmins($data, "USER_DEACTIVATED");
$this->logger->notice("User Organizaton deactivated " . $user->getUserIdentifier()); $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()); $this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK); return new JsonResponse(['status' => 'deactivated'], Response::HTTP_OK);
} }
@ -399,47 +529,107 @@ class UserController extends AbstractController
$uo->setIsActive(true); $uo->setIsActive(true);
$this->entityManager->persist($uo); $this->entityManager->persist($uo);
$this->entityManager->flush(); $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()); $this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
$data = ['user' => $user, $data = ['user' => $user,
'organization' => $org]; 'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_ACTIVATED"); $this->organizationsService->notifyOrganizationAdmins($data, "USER_ACTIVATED");
return new JsonResponse(['status' => 'activated'], Response::HTTP_OK); 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) { } catch (\Exception $exception) {
$this->logger->error($exception->getMessage()); $this->loggerService->logCritical($exception->getMessage());
} }
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
//TODO : MONOLOG + remove picture from bucket //TODO :remove picture from bucket
#[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])] #[Route('/delete/{id}', name: 'delete', methods: ['GET', 'POST'])]
public function delete(int $id, Request $request): Response public function delete(int $id, Request $request): Response
{ {
$this->denyAccessUnlessGranted("ROLE_SUPER_ADMIN"); $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
try {
$user = $this->userRepository->find($id); $user = $this->userRepository->find($id);
if (!$user) { 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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
// Soft delete the user
$user->setIsActive(false); $user->setIsActive(false);
$user->setModifiedAt(new \DateTimeImmutable('now'));
$this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, $user);
$user->setIsDeleted(true); $user->setIsDeleted(true);
if ($this->userService->isUserConnected($user)) { $user->setModifiedAt(new \DateTimeImmutable('now'));
$this->userService->revokeUserTokens($user->getUserIdentifier()); // 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->accessTokenService->revokeUserTokens($user->getUserIdentifier());
} }
$this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
$this->actionService->createAction("Delete user", $actingUser, null, $user->getUserIdentifier());
$data = ['user' => $user,
'organization' => null];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_DELETED");
return new Response('', Response::HTTP_NO_CONTENT); //204 // 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(),
$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(),
]);
}
$this->addFlash('success', 'Utilisateur supprimé avec succès.');
return $this->redirectToRoute('user_index');
} 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
}
$this->addFlash('error', 'Erreur lors de la suppression de l\'utilisateur\.');
return $this->redirectToRoute('user_index');
}
} }
//TODO : MONOLOG
#[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])] #[Route(path: '/application/roles/{id}', name: 'application_role', methods: ['GET', 'POST'])]
public function applicationRole(int $id, Request $request): Response public function applicationRole(int $id, Request $request): Response
{ {
@ -447,19 +637,29 @@ class UserController extends AbstractController
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true)) { 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());
$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')); $application = $this->entityManager->getRepository(Apps::class)->find($request->get('appId'));
if (!$application) { 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); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$selectedRolesIds = $request->get('roles', []); $selectedRolesIds = $request->get('roles', []);
$roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']); $roleUser = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'USER']);
if (!$roleUser) { if (!$roleUser) {
throw $this->createNotFoundException('Default role not found'); $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');
} }
if (!empty($selectedRolesIds)) { 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)) { if (!in_array((string)$roleUser->getId(), $selectedRolesIds, true)) {
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo, $application); $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo, $application);
} else { } else {
@ -476,6 +676,7 @@ class UserController extends AbstractController
} }
$user = $uo->getUsers(); $user = $uo->getUsers();
$this->addFlash('success', 'Rôles mis à jour avec succès.');
return $this->redirectToRoute('user_show', [ return $this->redirectToRoute('user_show', [
'user' => $user, 'user' => $user,
'id' => $user->getId(), 'id' => $user->getId(),
@ -542,7 +743,6 @@ class UserController extends AbstractController
'statut' => $user->isActive(), 'statut' => $user->isActive(),
]; ];
}, $rows); }, $rows);
$lastPage = (int)ceil($total / $size); $lastPage = (int)ceil($total / $size);
return $this->json([ return $this->json([
@ -555,6 +755,7 @@ class UserController extends AbstractController
#[Route(path: '/', name: 'index', methods: ['GET'])] #[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): Response public function index(): Response
{ {
$this->isGranted('ROLE_SUPER_ADMIN');
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) { if ($this->userService->hasAccessTo($actingUser, true) && $this->isGranted("ROLE_ADMIN")) {
$totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]); $totalUsers = $this->userRepository->count(['isDeleted' => false, 'isActive' => true]);
@ -562,6 +763,9 @@ class UserController extends AbstractController
'users' => $totalUsers 'users' => $totalUsers
]); ]);
} }
//shouldn't be reached normally
$this->loggerService->logAccessDenied($actingUser->getId());
throw $this->createAccessDeniedException(self::ACCESS_DENIED); throw $this->createAccessDeniedException(self::ACCESS_DENIED);
} }
@ -715,27 +919,38 @@ class UserController extends AbstractController
$orgId = $request->get('organizationId'); $orgId = $request->get('organizationId');
$org = $this->organizationRepository->find($orgId); $org = $this->organizationRepository->find($orgId);
if (!$org) { if (!$org) {
$this->loggerService->logEntityNotFound('Organization', ['id' => $orgId], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$user = $this->userRepository->find($userId); $user = $this->userRepository->find($userId);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User', ['id' => $user->getId()], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$uo = $this->uoRepository->findOneBy(['users' => $user, $uo = $this->uoRepository->findOneBy(['users' => $user,
'organization' => $org, 'organization' => $org,
'statut' => "INVITED"]); 'statut' => "INVITED"]);
if (!$uo) { if (!$uo) {
$this->loggerService->logEntityNotFound('UsersOrganization', [
'user_id' => $user->getId(),
'organization_id' => $orgId], $actingUser->getId());
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
$uo->setModifiedAt(new \DateTimeImmutable()); $uo->setModifiedAt(new \DateTimeImmutable());
try { try {
$data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()]; $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->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED'); $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK); return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK);
} catch (\Exception $e) { } 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); return $this->json(['message' => 'Erreur lors de l\'envoie du mail.'], Response::HTTP_INTERNAL_SERVER_ERROR);
} }
} }
@ -749,19 +964,35 @@ class UserController extends AbstractController
$userId = $request->get('id'); $userId = $request->get('id');
if (!$token || !$userId) { 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.'); throw $this->createNotFoundException('Invalid invitation link.');
} }
$user = $this->userRepository->find($userId); $user = $this->userRepository->find($userId);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User not found in accept invitation', [
'user_id' => $userId
],null);
throw $this->createNotFoundException(self::NOT_FOUND); throw $this->createNotFoundException(self::NOT_FOUND);
} }
if (!$this->userService->isPasswordTokenValid($user, $token)) { 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.'); throw $this->createNotFoundException('Invalid or expired invitation token.');
} }
$orgId = $this->userService->getOrgFromToken($token); $orgId = $this->userService->getOrgFromToken($token);
if ($orgId) {
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]); $uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
if (!$uo || $uo->getStatut() !== 'INVITED') { 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.'); throw $this->createNotFoundException('No pending invitation found for this user and organization.');
} }
$uo->setModifiedAt(new \DateTimeImmutable()); $uo->setModifiedAt(new \DateTimeImmutable());
@ -769,9 +1000,10 @@ class UserController extends AbstractController
$uo->setIsActive(true); $uo->setIsActive(true);
$this->entityManager->persist($uo); $this->entityManager->persist($uo);
$this->entityManager->flush(); $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('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]); }
return $this->render('security/login.html.twig');
} }
} }

View File

@ -59,6 +59,7 @@ class Apps
{ {
$this->userOrganizatonApps = new ArrayCollection(); $this->userOrganizatonApps = new ArrayCollection();
$this->organization = new ArrayCollection(); $this->organization = new ArrayCollection();
$this->setIsActive(true);
} }
public function getId(): ?int public function getId(): ?int

View File

@ -4,10 +4,13 @@ namespace App\Entity;
use App\Repository\OrganizationsRepository; use App\Repository\OrganizationsRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OrganizationsRepository::class)] #[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 class Organizations
{ {
#[ORM\Id] #[ORM\Id]
@ -24,7 +27,7 @@ class Organizations
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private ?string $address = null; private ?string $address = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255, nullable: true)]
private ?string $logo_url = null; private ?string $logo_url = null;
#[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])] #[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])]

View File

@ -9,7 +9,6 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')] #[ORM\Table(name: '`user`')]

View File

@ -0,0 +1,25 @@
<?php
namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
class UserCreatedEvent extends Event
{
public function __construct(
private readonly User $newUser,
private readonly User $actingUser
) {
}
public function getNewUser(): User
{
return $this->newUser;
}
public function getActingUser(): User
{
return $this->actingUser;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\EventSubscriber;
use App\Event\UserCreatedEvent;
use App\Service\ActionService;
use App\Service\EmailService;
use App\Service\LoggerService;
use App\Service\UserService; // Only if you need helper methods, otherwise avoid to prevent circular ref
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class UserSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EmailService $emailService,
private readonly LoggerService $loggerService,
private readonly ActionService $actionService,
) {
}
public static function getSubscribedEvents(): array
{
return [
UserCreatedEvent::class => '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()
);
}
}

View File

@ -17,8 +17,8 @@ class OrganizationForm extends AbstractType
$builder $builder
->add('email', EmailType::class, ['required' => true, 'label' => 'Email*']) ->add('email', EmailType::class, ['required' => true, 'label' => 'Email*'])
->add('name', TextType::class, ['required' => true, 'label' => 'Nom de l\'organisation*']) ->add('name', TextType::class, ['required' => true, 'label' => 'Nom de l\'organisation*'])
->add('address', TextType::class, ['required' => false, 'label' => 'Adresse']) ->add('address', TextType::class, ['required' => true, 'label' => 'Adresse'])
->add('number', TextType::class, ['required' => false, 'label' => 'Numéro de téléphone']) ->add('number', TextType::class, ['required' => true, 'label' => 'Numéro de téléphone'])
->add('logoUrl', FileType::class, [ ->add('logoUrl', FileType::class, [
'required' => false, 'required' => false,
'label' => 'Logo', 'label' => 'Logo',

View File

@ -11,17 +11,38 @@ class AccessTokenService
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager) public function __construct(EntityManagerInterface $entityManager,
private readonly LoggerService $loggerService)
{ {
$this->entityManager = $entityManager; $this->entityManager = $entityManager;
} }
public function revokeTokens(String $userIdentifier): void { public function revokeUserTokens(string $userIdentifier): void
$accessTokens = $this->entityManager->getRepository(AccessToken::class)->findBy(['userIdentifier' => $userIdentifier, 'revoked' => false]); {
foreach($accessTokens as $accessToken) { $tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
$accessToken->revoke(); 'userIdentifier' => $userIdentifier,
$this->entityManager->persist($accessToken); 'revoked' => false
$this->entityManager->flush(); ]);
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(),
]
);
}
} }
} }

View File

@ -40,11 +40,11 @@ readonly class ActionService
{ {
return array_map(function (Actions $activity) { return array_map(function (Actions $activity) {
return [ return [
'date' => $activity->getDate(), 'date' => $activity->getDate()->format('d/m/Y H:i'),
'actionType' => $activity->getActionType(), 'actionType' => $activity->getActionType(),
'users' => $activity->getUsers(), 'userName' => $activity->getUsers()->getName(),
'organization' => $activity->getOrganization(), // 'organization' => $activity->getOrganization(),
'description' => $activity->getDescription(), // 'description' => $activity->getDescription(),
'color' => $this->getActivityColor($activity->getDate()) 'color' => $this->getActivityColor($activity->getDate())
]; ];
}, $activities); }, $activities);

View File

@ -2,6 +2,7 @@
namespace App\Service; namespace App\Service;
use App\Service\LoggerService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use App\Entity\Cgu; use App\Entity\Cgu;
@ -9,7 +10,7 @@ use App\Entity\CguUser;
class CguUserService 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]); $cguUser = $this->entityManager->getRepository(CguUser::class)->findOneBy(['users' => $user, 'cgu' => $latestCgu]);
if (!$cguUser) { if (!$cguUser) {
try{
// Create a new CguUser relation if it doesn't exist // Create a new CguUser relation if it doesn't exist
$cguUser = new CguUser(); $cguUser = new CguUser();
$cguUser->setUsers($user); $cguUser->setUsers($user);
$cguUser->setCgu($latestCgu); $cguUser->setCgu($latestCgu);
$this->entityManager->persist($cguUser); $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); $cguUser->setIsAccepted(true);

View File

@ -4,6 +4,7 @@ namespace App\Service;
use App\Entity\Organizations; use App\Entity\Organizations;
use App\Entity\User; use App\Entity\User;
use App\Service\LoggerService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
@ -14,15 +15,12 @@ class EmailService
{ {
public function __construct( public function __construct(
private readonly MailerInterface $mailer, private readonly MailerInterface $mailer,
private readonly UserService $userService,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private UrlGeneratorInterface $urlGenerator private UrlGeneratorInterface $urlGenerator, private readonly LoggerService $loggerService
) {} ) {}
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 // Generate absolute URL for the password setup route
$link = $this->urlGenerator->generate( $link = $this->urlGenerator->generate(
'password_setup', 'password_setup',
@ -46,15 +44,16 @@ class EmailService
]); ]);
try { try {
$orgId = $this->getOrgFromToken($token);
$this->mailer->send($email); $this->mailer->send($email);
$this->loggerService->logEmailSent($user->getId(), $orgId, 'Password setup email sent.');
} catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) { } catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) {
$this->logger->error('Failed to send password setup email: ' . $e->getMessage()); $this->logger->error('Failed to send password setup email: ' . $e->getMessage());
} }
} }
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',[ $link = $this->urlGenerator->generate('user_accept',[
'id' => $existingUser->getId(), 'id' => $existingUser->getId(),
'token' => $token 'token' => $token
@ -73,10 +72,26 @@ class EmailService
]); ]);
try{ try{
$orgId = $org->getId();
$this->loggerService->logEmailSent($existingUser->getId(), $orgId, 'Existing user notification email sent.');
$this->mailer->send($email); $this->mailer->send($email);
} catch (TransportExceptionInterface $e) { } catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send existing user notification email: ' . $e->getMessage()); $this->logger->error('Failed to send existing user notification email: ' . $e->getMessage());
} }
} }
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;
}
} }

View File

@ -0,0 +1,263 @@
<?php
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 $awsLogger,
private RequestStack $requestStack,
) {}
// User Management Logs
public function logUserCreated(int $userId, int $actingUserId): void
{
$this->userManagementLogger->notice("New user created: $userId", [
'target_user_id' => $userId,
'acting_user_id' => $actingUserId,
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
// Organization Management Logs
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' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
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' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
// Email Notification Logs
public function logEmailSent(int $userId, ?int $orgId, string $message): void
{
$this->emailNotificationLogger->notice($message, [
'target_user_id' => $userId,
'organization_id' => $orgId,
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
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' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
public function logAdminNotified(array $array): void
{
$this->emailNotificationLogger->notice('Organization admin notified', array_merge($array, [
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]));
}
public function logSuperAdmin(int $userId, int $actingUserId, string $message, ?int $orgId = null): void
{
$this->adminActionsLogger->notice($message, [
'target_user_id' => $userId,
'organization_id' => $orgId,
'acting_user_id' => $actingUserId,
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]);
}
// Error Logs
public function logError(string $message, array $context = []): void
{
$this->errorLogger->error($message, array_merge($context, [
'timestamp' => $this->now(),
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
]));
}
public function logCritical(string $message, array $context = []): void
{
$this->errorLogger->critical($message, array_merge($context, [
'timestamp' => $this->now(),
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
]));
}
// Security Logs
public function logAccessDenied(?int $actingUserId): void
{
$this->securityLogger->warning('Access denied', [
'acting_user_id' => $actingUserId,
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
'page_accessed' => $_SERVER['REQUEST_URI'] ?? 'unknown',
]);
}
// Helper
private function now(): string
{
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->error('Entity not found', array_merge($criteria, [
'entity_type' => $entityType,
'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->awsLogger->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->warning($message, array_merge($array, [
'ip' => $this->requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown',
'timestamp' => $this->now(),
]));
}
public function logUOALinkDeactivated(int $uoaId, int $appId, int $roleId): void
{
$this->organizationManagementLogger->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(),
]);
}
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',
]));
}
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(),
]));
}
}

View File

@ -65,7 +65,7 @@ class NotificationService
$this->send( $this->send(
recipient: $recipient, recipient: $recipient,
type: self::TYPE_USER_DEACTIVATED, 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()), message: sprintf('%s %s a été désactivé de %s', $removedUser->getName(), $removedUser->getSurname(), $organization->getName()),
data: [ data: [
'userId' => $removedUser->getId(), 'userId' => $removedUser->getId(),

View File

@ -6,8 +6,11 @@ use App\Entity\Apps;
use App\Entity\Organizations; use App\Entity\Organizations;
use App\Entity\Roles; use App\Entity\Roles;
use App\Entity\UserOrganizatonApp; use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Repository\UsersOrganizationsRepository; use App\Repository\UsersOrganizationsRepository;
use App\Service\LoggerService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
class OrganizationsService class OrganizationsService
@ -18,7 +21,8 @@ class OrganizationsService
string $logoDirectory, private readonly AwsService $awsService, string $logoDirectory, private readonly AwsService $awsService,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly UsersOrganizationsRepository $uoRepository, private readonly UsersOrganizationsRepository $uoRepository,
private readonly NotificationService $notificationService private readonly NotificationService $notificationService,
private readonly LoggerInterface $emailNotificationLogger, private readonly LoggerService $loggerService,
) )
{ {
$this->logoDirectory = $logoDirectory; $this->logoDirectory = $logoDirectory;
@ -32,8 +36,18 @@ class OrganizationsService
try { try {
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $logoFile, $customFilename, $extension, 'logo/'); $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); $organization->setLogoUrl('logo/' . $customFilename);
} catch (FileException $e) { } 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()); throw new FileException('Failed to upload logo to S3: ' . $e->getMessage());
} }
} }
@ -85,6 +99,10 @@ class OrganizationsService
$newUser, $newUser,
$data['organization'] $data['organization']
); );
$this->loggerService->logAdminNotified([
'admin_user_id' =>$adminUO->getUsers()->getId(),
'target_user_id' => $newUser->getId(),
'organization_id' => $data['organization']->getId(),'case' =>$type]);
} }
break; break;
case 'USER_INVITED': case 'USER_INVITED':
@ -95,7 +113,12 @@ class OrganizationsService
$invitedUser, $invitedUser,
$data['organization'] $data['organization']
); );
$this->loggerService->logAdminNotified([
'admin_user_id' =>$adminUO->getUsers()->getId(),
'target_user_id' => $invitedUser->getId(),
'organization_id' => $data['organization']->getId(),'case' =>$type]);
} }
break; break;
case 'USER_DEACTIVATED': case 'USER_DEACTIVATED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
@ -105,7 +128,12 @@ class OrganizationsService
$removedUser, $removedUser,
$data['organization'] $data['organization']
); );
$this->loggerService->logAdminNotified([
'admin_user_id' =>$adminUO->getUsers()->getId(),
'target_user_id' => $removedUser->getId(),
'organization_id' => $data['organization']->getId(),'case' =>$type]);
} }
break; break;
case 'USER_DELETED': case 'USER_DELETED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) { if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
@ -115,6 +143,10 @@ class OrganizationsService
$removedUser, $removedUser,
$data['organization'] $data['organization']
); );
$this->loggerService->logAdminNotified([
'admin_user_id' =>$adminUO->getUsers()->getId(),
'target_user_id' => $removedUser->getId(),
'organization_id' => $data['organization']->getId(),'case' =>$type]);
} }
break; break;
case 'USER_ACTIVATED': case 'USER_ACTIVATED':
@ -125,6 +157,10 @@ class OrganizationsService
$activatedUser, $activatedUser,
$data['organization'] $data['organization']
); );
$this->loggerService->logAdminNotified([
'admin_user_id' =>$adminUO->getUsers()->getId(),
'target_user_id' => $activatedUser->getId(),
'organization_id' => $data['organization']->getId(),'case' =>$type]);
} }
break; break;
} }

View File

@ -8,13 +8,15 @@ use App\Entity\User;
use App\Entity\UserOrganizatonApp; use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations; use App\Entity\UsersOrganizations;
use App\Service\ActionService; use App\Service\ActionService;
use App\Service\LoggerService;
use App\Service\UserService; use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
class UserOrganizationAppService 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]); $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $userOrganization, 'isActive' => true]);
} }
foreach ($uoas as $uoa) { foreach ($uoas as $uoa) {
try{
$uoa->setIsActive(false); $uoa->setIsActive(false);
$this->actionService->createAction("Deactivate UOA link", $userOrganization->getUsers(), $this->actionService->createAction("Deactivate UOA link", $userOrganization->getUsers(),
$userOrganization->getOrganization(), "App: " . $uoa->getApplication()->getName() . ", Role: " . $uoa->getRole()->getName()); $userOrganization->getOrganization(), "App: " . $uoa->getApplication()->getName() . ", Role: " . $uoa->getRole()->getName());
$this->entityManager->persist($uoa); $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()) { if (!$uoa->isActive()) {
$uoa->setIsActive(true); $uoa->setIsActive(true);
$this->entityManager->persist($uoa); $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( $this->actionService->createAction(
"Re-activate user role for application", "Re-activate user role for application",
$actingUser, $actingUser,
@ -148,7 +165,11 @@ class UserOrganizationAppService
if ($uoa->isActive()) { if ($uoa->isActive()) {
$uoa->setIsActive(false); $uoa->setIsActive(false);
$this->entityManager->persist($uoa); $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( $this->actionService->createAction(
"Deactivate user role for application", "Deactivate user role for application",
$actingUser, $actingUser,
@ -185,6 +206,11 @@ class UserOrganizationAppService
$this->ensureAdminRoleForSuperAdmin($newUoa); $this->ensureAdminRoleForSuperAdmin($newUoa);
} }
$this->entityManager->persist($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", $this->actionService->createAction("New user role for application",
$actingUser, $actingUser,
$uo->getOrganization(), $uo->getOrganization(),

View File

@ -7,6 +7,7 @@ use App\Entity\Organizations;
use App\Entity\User; use App\Entity\User;
use App\Entity\UsersOrganizations; use App\Entity\UsersOrganizations;
use App\Service\ActionService; use App\Service\ActionService;
use App\Service\LoggerService;
use \App\Service\UserOrganizationAppService; use \App\Service\UserOrganizationAppService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -19,7 +20,7 @@ readonly class UserOrganizationService
{ {
public function __construct( public function __construct(
private userOrganizationAppService $userOrganizationAppService, private EntityManagerInterface $entityManager, private ActionService $actionService, private userOrganizationAppService $userOrganizationAppService, private EntityManagerInterface $entityManager, private ActionService $actionService, private LoggerService $loggerService,
) { ) {
} }
@ -41,22 +42,19 @@ readonly class UserOrganizationService
$uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['organization' => $organizations, 'isActive' => true]); $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['organization' => $organizations, 'isActive' => true]);
} }
//deactivate all UO links //deactivate all UO links
if (!empty($uos)) {
foreach ($uos as $uo) { foreach ($uos as $uo) {
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo); $this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo);
$this->loggerService->logOrganizationInformation($uo->getOrganization()->getId(), $actingUser->getId(),
'Uo link deactivated');
$uo->setIsActive(false); $uo->setIsActive(false);
$this->entityManager->persist($uo); $this->entityManager->persist($uo);
$this->actionService->createAction("Deactivate UO link", $actingUser, $uo->getOrganization(), $uo->getOrganization()->getName() ); $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;
} }
} }

View File

@ -8,7 +8,6 @@ use App\Entity\Roles;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserOrganizatonApp; use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations; use App\Entity\UsersOrganizations;
use App\Service\AwsService;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -16,9 +15,11 @@ use Doctrine\ORM\EntityNotFoundException;
use Exception; use Exception;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use Random\RandomException; use Random\RandomException;
use SebastianBergmann\CodeCoverage\Util\DirectoryCouldNotBeCreatedException; use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
use App\Event\UserCreatedEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class UserService class UserService
{ {
@ -27,7 +28,12 @@ class UserService
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly Security $security, 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,
private readonly EventDispatcherInterface $eventDispatcher
) )
{ {
@ -39,16 +45,7 @@ class UserService
*/ */
public function generateRandomPassword(): string public function generateRandomPassword(): string
{ {
$length = 50; // Length of the password return bin2hex(random_bytes(32));
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+';
$charactersLength = strlen($characters);
$randomPassword = '';
for ($i = 0; $i < $length; $i++) {
$randomPassword .= $characters[random_int(0, $charactersLength - 1)];
}
return $randomPassword;
} }
@ -88,20 +85,20 @@ class UserService
*/ */
public function hasAccessTo(User $user, bool $skipSelfCheck = false): bool 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()) { if (!$skipSelfCheck && $user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) {
return true; return true;
} }
$userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); $userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
if ($userOrganizations) { if ($userOrganizations) {
foreach ($userOrganizations as $uo) { foreach ($userOrganizations as $uo) {
if ($this->isAdminOfOrganization($uo->getOrganization())) { if ($this->isAdminOfOrganization($uo->getOrganization()) && $uo->getStatut() === "ACCEPTED" && $uo->isActive()) {
return true; return true;
} }
} }
} }
if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
return true;
}
return false; return false;
} }
@ -112,7 +109,7 @@ class UserService
* entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity * entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity
* (if he is admin for any application of the organization). * (if he is admin for any application of the organization).
* *
* @param UsersOrganizations $usersOrganizations * @param Organizations $organizations
* @return bool * @return bool
* @throws Exception * @throws Exception
*/ */
@ -144,6 +141,7 @@ class UserService
{ {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $userIdentifier]); $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $userIdentifier]);
if (!$user) { if (!$user) {
$this->loggerService->logEntityNotFound('User', ['user_identifier' => $userIdentifier], null);
throw new EntityNotFoundException(self::NOT_FOUND); throw new EntityNotFoundException(self::NOT_FOUND);
} }
return $user; return $user;
@ -175,18 +173,20 @@ class UserService
return ['none' => $group]; return ['none' => $group];
} }
//TODO: reset function
public function handleProfilePicture(User $user, $picture): void public function handleProfilePicture(User $user, $picture): void
{ {
// Get file extension // Get file extension
$extension = $picture->guessExtension(); $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() . '_' . date('dmyHis') . '.' . $extension;
// $customFilename = $user->getName() . $user->getSurname() . "." .$extension;
try { try {
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/'); $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); $user->setPictureUrl('profile/' . $customFilename);
} catch (FileException $e) { } catch (FileException $e) {
// Handle upload error // Handle upload error
@ -242,12 +242,18 @@ class UserService
if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) { if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) {
$user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN'])); $user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN']));
} }
$this->loggerService->logRoleAssignment(
'Role assigned to user',
[
'user_id' => $user->getId(),
'role' => $roleFormatted,
]
);
} else { } else {
// Remove the role if present and not used elsewhere // Remove the role if present and not used elsewhere
if (in_array($roleFormatted, $user->getRoles(), true)) { if (in_array($roleFormatted, $user->getRoles(), true)) {
$uos = $this->entityManager->getRepository(UsersOrganizations::class) $uos = $this->entityManager->getRepository(UsersOrganizations::class)
->findBy(['users' => $user, 'isActive' => true]); ->findBy(['users' => $user, 'isActive' => true]);
$hasRole = false; $hasRole = false;
foreach ($uos as $uo) { foreach ($uos as $uo) {
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class) $uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
@ -257,7 +263,6 @@ class UserService
'role' => $this->entityManager->getRepository(Roles::class) 'role' => $this->entityManager->getRepository(Roles::class)
->findOneBy(['name' => $role]), ->findOneBy(['name' => $role]),
]); ]);
if ($uoa) { if ($uoa) {
$hasRole = true; $hasRole = true;
break; break;
@ -287,17 +292,6 @@ class UserService
return 'ROLE_' . $role; return 'ROLE_' . $role;
} }
public function revokeUserTokens(string $userIdentifier)
{
$tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
'userIdentifier' => $userIdentifier,
'revoked' => false
]);
foreach ($tokens as $token) {
$token->revoke();
}
}
public function formatStatutForOrganizations(array $rows): array public function formatStatutForOrganizations(array $rows): array
{ {
@ -330,7 +324,7 @@ class UserService
return $formatted; return $formatted;
} }
public function generatePasswordToken(User $user, int $orgId): string public function generatePasswordToken(User $user, int $orgId = null): string
{ {
$orgString = "o" . $orgId . "@"; $orgString = "o" . $orgId . "@";
$token = $orgString . bin2hex(random_bytes(32)); $token = $orgString . bin2hex(random_bytes(32));
@ -413,15 +407,17 @@ class UserService
return $rolesArray; return $rolesArray;
} }
public function canEditRolesCheck(User $actingUser, User $user, $org, bool $isAdmin): bool public function canEditRolesCheck(User $actingUser, User $user, bool $isAdmin, UsersOrganizations $uo = null, $org = null): bool
{ {
$userRoles = $user->getRoles(); $userRoles = $user->getRoles();
$actingUserRoles = $actingUser->getRoles(); $actingUserRoles = $actingUser->getRoles();
// if acting user is admin, he can´t edit super admin roles // 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)) {
if (in_array('ROLE_SUPER_ADMIN', $userRoles, true) && !in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true)) {
return false; return false;
} }
if ($uo && $this->isAdminOfOrganization($uo->getOrganization())) {
return true;
}
return $isAdmin && !empty($org); return $isAdmin && !empty($org);
} }
@ -434,7 +430,7 @@ class UserService
* @param Organizations $organization * @param Organizations $organization
* @return void * @return void
*/ */
public function handleExistingUser(User $user, Organizations $organization): void public function handleExistingUser(User $user, Organizations $organization): int
{ {
if (!$user->isActive()) { if (!$user->isActive()) {
$user->setIsActive(true); $user->setIsActive(true);
@ -448,6 +444,8 @@ class UserService
$uo->setModifiedAt(new \DateTimeImmutable('now')); $uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo); $this->entityManager->persist($uo);
$this->entityManager->flush(); $this->entityManager->flush();
return $uo->getId();
} }
/** /**
@ -460,7 +458,7 @@ class UserService
* @param User $user * @param User $user
* @return void * @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 // capitalize name and surname
$user->setName(ucfirst(strtolower($user->getName()))); $user->setName(ucfirst(strtolower($user->getName())));
@ -472,11 +470,161 @@ class UserService
$user->setEmail(trim($user->getEmail())); $user->setEmail(trim($user->getEmail()));
if ($setPassword) { if ($setPassword) {
//FOR SETTING A DEFAULT RANDOM PASSWORD OF 50 CHARACTERS until user set his own password //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) { if ($picture) {
$this->handleProfilePicture($user, $picture); $this->handleProfilePicture($user, $picture);
} }
} }
/**
* Handle existing user being added to an organization
*/
public function addExistingUserToOrganization(
User $existingUser,
Organizations $org,
User $actingUser,
): int
{
try {
$uoId = $this->handleExistingUser($existingUser, $org);
$this->loggerService->logExistingUserAddedToOrg(
$existingUser->getId(),
$org->getId(),
$actingUser->getId(),
$uoId,
);
$this->actionService->createAction(
"Add existing user to organization",
$actingUser,
$org,
"Added user {$existingUser->getUserIdentifier()} to {$org->getName()}"
);
$this->sendExistingUserNotifications($existingUser, $org, $actingUser);
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(),
]);
throw $e;
}
}
/**
* Create a brand-new user
*/
public function createNewUser(User $user, User $actingUser, $picture): void
{
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));
} 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(),
]);
throw $e;
}
}
/**
* Link newly created user to an organization
*/
public function linkUserToOrganization(
User $user,
Organizations $org,
User $actingUser,
): 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(),
);
$this->actionService->createAction(
"Link user to organization",
$actingUser,
$org,
"Added {$user->getUserIdentifier()} to {$org->getName()}"
);
$this->sendNewUserNotifications($user, $org, $actingUser);
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(),
]);
throw $e;
}
}
// Private helpers for email notifications
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());
$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(),
]);
}
}
private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser): void
{
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(),
]);
}
}
} }

View File

@ -11,6 +11,18 @@
"config/packages/aws.yaml" "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": { "doctrine/deprecations": {
"version": "1.1", "version": "1.1",
"recipe": { "recipe": {

View File

@ -14,7 +14,7 @@
{% if application.hasAccess %} {% if application.hasAccess %}
{% if is_granted("ROLE_SUPER_ADMIN") %} {% if is_granted("ROLE_SUPER_ADMIN") %}
<form method="POST" <form method="POST"
action="{{ path('application_remove', {'id': application.entity.id}) }}" action="{{ path('application_revoke', {'id': application.entity.id}) }}"
data-controller="application" data-controller="application"
data-application-application-value="{{ application.entity.name }}" data-application-application-value="{{ application.entity.name }}"
data-application-organization-value="{{ organization.name|capitalize }}" data-application-organization-value="{{ organization.name|capitalize }}"

View File

@ -8,10 +8,22 @@
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
<div class="row m-5"> <div class="row m-5">
<div class="container mt-5"> <div class="container mt-5">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<h1 class="mb-4">Bienvenue sur la suite Easy</h1> <h1 class="mb-4">Bienvenue sur la suite Easy</h1>
<p class="lead">Ici, vous pouvez trouver toutes nos applications à un seul endroit!</p> <p class="lead">Ici, vous pouvez trouver toutes nos applications à un seul endroit !</p>
</div> </div>
{% if applications is empty %}
<div class="alert alert-info w-100 text-center" role="alert">
Aucune application disponible pour le moment. Veuillez revenir plus tard.
</div>
{% endif %}
{% for application in applications %} {% for application in applications %}
<div class="col-6 mb-3"> <div class="col-6 mb-3">
{% include 'application/InformationCard.html.twig' with { {% include 'application/InformationCard.html.twig' with {

View File

@ -1,25 +0,0 @@
{% block body %}
<div class="card border-0">
<div class="card-header d-flex justify-content-between align-items-center border-0">
<h3>{{ title }}</h3>
</div>
<div class="card-body">
{% if activities|length == 0 %}
<p>Aucune activité récente.</p>
{% else %}
{% set sortedActivities = activities|sort((a, b) => a.date <=> b.date)|reverse %}
<ul class="list-group gap-2">
{% 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 %}
</ul>
{% endif %}
</div>
{% endblock %}

View File

@ -5,9 +5,6 @@
<div class="card no-header-bg p-3 m-3"> <div class="card no-header-bg p-3 m-3">
<div class="card-header border-0"> <div class="card-header border-0">
<h2>Modifier l'organisation</h2> <h2>Modifier l'organisation</h2>
{% if is_granted("ROLE_SUPER_ADMIN") %}
{# <a href="{{ path('organization_delete', {'id': organization.id}) }}" class="btn btn-danger">Supprimer</a>#}
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">

View File

@ -4,6 +4,13 @@
{% block body %} {% block body %}
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="card no-header-bg p-3 m-3 border-0"> <div class="card no-header-bg p-3 m-3 border-0">
<div class="card-header d-flex justify-content-between align-items-center border-0"> <div class="card-header d-flex justify-content-between align-items-center border-0">
<div class="card-title"> <div class="card-title">
@ -11,30 +18,31 @@
</div> </div>
{% if is_granted("ROLE_SUPER_ADMIN") %} {% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('organization_new') }}" class="btn btn-primary">Ajouter une organisation</a> <a href="{{ path('organization_create') }}" class="btn btn-primary">Ajouter une organisation</a>
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
{% if organizationsData|length == 0 %} {% if is_granted('ROLE_SUPER_ADMIN') and not hasOrganizations %}
{# style présent juste pour créer de l'espace #}
<div class="div text-center my-5 py-5"> <div class="div text-center my-5 py-5">
<h1 class="my-5 ty-5"> Aucune organisation trouvée. </h1> <h1 class="my-5 ty-5"> Aucune organisation trouvée. </h1>
<a href="{{ path('organization_new') }}" class="btn btn-primary">Créer une organisation</a> <a href="{{ path('organization_create') }}" class="btn btn-primary">Créer une organisation</a>
</div> </div>
{% else %} {% else %}
<div id="tabulator-org" data-controller="organization" <div id="tabulator-org"
data-organization-data-value="{{ organizationsData|json_encode(constant("JSON_UNESCAPED_UNICODE"))|e("html_attr") }}" data-controller="organization"
data-organization-aws-value="{{ aws_url }}"></div> data-organization-table-value="true"
data-organization-user-value={{ app.user.getId() }}
data-organization-sadmin-value="{{ is_granted('ROLE_SUPER_ADMIN') ? true : false }}"
data-organization-aws-value="{{ aws_url }}">
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,13 +6,20 @@
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
<div class="card no-header-bg p-3 m-3"> <div class="card no-header-bg p-3 m-3">
<div class="card-header border-0"> <div class="card-header border-0">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="card-title d-flex justify-content-between align-items-center"> <div class="card-title d-flex justify-content-between align-items-center">
<h1>Ajouter une organisation</h1> <h1>Ajouter une organisation</h1>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post" action="{{ path('organization_new') }}" enctype="multipart/form-data"> <form method="post" action="{{ path('organization_create') }}" enctype="multipart/form-data">
{{ form_start(form) }} {{ form_start(form) }}
{{ form_widget(form) }} {{ form_widget(form) }}
<button type="submit" class="btn btn-primary">Enregistrer</button> <button type="submit" class="btn btn-primary">Enregistrer</button>

View File

@ -2,6 +2,13 @@
{% block body %} {% block body %}
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="col d-flex justify-content-between align-items-center"> <div class="col d-flex justify-content-between align-items-center">
<div class="d-flex "> <div class="d-flex ">
{% if organization.logoUrl %} {% if organization.logoUrl %}
@ -49,7 +56,8 @@
<h2> <h2>
Nouveaux utilisateurs Nouveaux utilisateurs
</h2> </h2>
<a href="{{ path('user_new', {'organizationId': organization.id}) }}" class="btn btn-primary">Ajouter un utilisateur</a> <a href="{{ path('user_new', {'organizationId': organization.id}) }}"
class="btn btn-primary">Ajouter un utilisateur</a>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="tabulator-userListSmall" data-controller="user" <div id="tabulator-userListSmall" data-controller="user"
@ -106,14 +114,34 @@
</div> </div>
{# Activities col #} {# Activities col #}
<div class="col-3 m-auto"> <div class="col-3 m-auto">
{% include 'organization/activity.html.twig' with { <div class="card border-0"
title: 'Activités récentes', data-controller="organization"
empty_message: 'Aucune activité récente.' data-organization-activities-value = "true"
} %} data-organization-id-value="{{ organization.id }}">
<div class="card-header d-flex justify-content-between align-items-center border-0">
<h3>Activité récente</h3>
<button class="btn btn-sm btn-outline-secondary" data-action="organization#loadActivities">
<i class="fas fa-sync"></i> Rafraîchir
</button>
</div> </div>
<div class="card-body bg-light">
<div class="d-flex flex-column" data-organization-target="activityList">
<div class="text-center text-muted p-5">
<span class="spinner-border" aria-hidden="true"></span>
</div> </div>
{# Ne pas enlever le 2ème /div#} </div>
{# Empty state #}
<div class="d-none" data-organization-target="emptyMessage">
<div class="alert alert-light text-center shadow-sm">Aucune activité récente.</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,9 @@
<div class="card-title"> <div class="card-title">
<h2>Modifier l'utilisateur</h2> <h2>Modifier l'utilisateur</h2>
</div> </div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a href="{{ path('user_delete', {'id': user.id}) }}" class="btn btn-danger m-3">Supprimer</a> <a href="{{ path('user_delete', {'id': user.id}) }}" class="btn btn-danger m-3">Supprimer</a>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">

View File

@ -5,11 +5,18 @@
{% block body %} {% block body %}
{% if is_granted('ROLE_SUPER_ADMIN') %} {% if is_granted('ROLE_SUPER_ADMIN') %}
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
<div class="card p-3 m-3 border-0"> <div class="card p-3 m-3 border-0">
<div class="card-header border-0"> <div class="card-header border-0">
<div class="d-flex justify-content-between align-items-center mb-3 "> <div class="d-flex justify-content-between align-items-center mb-3 ">
<h1>Gestion Utilisateurs</h1> <h1>Gestion Utilisateurs</h1>
<a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a> {# <a href="{{ path('user_new') }}" class="btn btn-primary">Ajouter un utilisateur</a>#}
</div> </div>
</div> </div>

View File

@ -1,17 +0,0 @@
{% block body %}
<div class="card border">
<div class="card-header d-flex align-items-center border-0">
<div class="row align-items-center">
<h4 class="mb-0">
<span style="display:inline-block; width:16px; height:16px; border-radius:50%; background:{{ color }}; margin-right:10px;"></span>
{{ activityTime|ago }}</h4>
</div>
</div>
<div class="card-body">
<p>{{ userName }} - {{ action }}</p>
</div>
</div>
{% endblock %}

View File

@ -4,6 +4,13 @@
<div class="w-100 h-100 p-5 m-auto"> <div class="w-100 h-100 p-5 m-auto">
<div class="card p-3 m-3 border-0 no-header-bg"> <div class="card p-3 m-3 border-0 no-header-bg">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
{% if is_granted("ROLE_ADMIN") %} {% if is_granted("ROLE_ADMIN") %}
<div class="card-header border-0 d-flex justify-content-between align-items-center "> <div class="card-header border-0 d-flex justify-content-between align-items-center ">
@ -14,13 +21,6 @@
{% if is_granted("ROLE_SUPER_ADMIN") %} {% if is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('user_delete', {'id': user.id}) }}" <a href="{{ path('user_delete', {'id': user.id}) }}"
class="btn btn-secondary">Supprimer</a> class="btn btn-secondary">Supprimer</a>
{% if user.active %}
<a href="{{ path('user_deactivate', {'id': user.id}) }}"
class="btn btn-secondary">Désactiver l'utilisateur</a>
{% else %}
<a href="{{ path('user_activate', {'id': user.id}) }}" class="btn btn-primary ">Activer
l'utilisateur</a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -13,28 +13,11 @@
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if canEdit %} {% if canEdit %}
{% if organizationId is not null %}
{% if uoActive %}
<form method="post" action="{{ path('user_deactivate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez retirer l\'utilisateur de cette organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-secondary" type="submit">Désactiver l'utilisateur de
l'organisation
</button>
</form>
{% else %}
<form method="post" action="{{ path('user_activate_organization', {'id': user.id}) }}"
onsubmit="return confirm('Vous allez activer cette utilisateur dans votre organisation, êtes vous sûre?');">
<input type="hidden" name="organizationId" value="{{ organizationId }}">
<button class="btn btn-primary" type="submit">Activer l'utilisateur de l'organisation
</button>
</form>
{% endif %}
{% endif %}
<a href="{{ path('user_edit', {'id': user.id, 'organizationId': organizationId}) }}" <a href="{{ path('user_edit', {'id': user.id, 'organizationId': organizationId}) }}"
class="btn btn-primary">Modifier</a> class="btn btn-primary">Modifier</a>
{% elseif user.id == app.user.id or is_granted("ROLE_SUPER_ADMIN") %}
<a href="{{ path('user_edit', {'id': user.id}) }}"
class="btn btn-primary">Modifier</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,106 @@
<?php
namespace App\Tests\Controller;
use App\Entity\Actions;
use App\Entity\Organizations;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use PHPUnit\Framework\Attributes\Test;
class ActionControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace App\Tests\Controller;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Service\ActionService;
use App\Service\LoggerService;
use App\Tests\Functional\AbstractFunctionalTest;
use PHPUnit\Framework\Attributes\Test;
class ApplicationControllerTest extends AbstractFunctionalTest
{
//region Index Tests
#[Test]
public function index_redirects_unauthenticated_user(): void
{
$this->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
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Tests\Controller;
use App\Tests\Functional\AbstractFunctionalTest;
use PHPUnit\Framework\Attributes\Test;
class IndexControllerTest extends AbstractFunctionalTest
{
//Region dashboard tests
//endregion
//region index tests
#[Test]
public function test_index_successful_super_admin(): void
{
$admin = $this->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();
}
#[Test]
public function test_index_unauthenticated(): void
{
$this->client->request('GET', '/');
self::assertResponseRedirects('/login');
}
//endregion
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Tests\Controller;
use App\Tests\Functional\AbstractFunctionalTest;
use PHPUnit\Framework\Attributes\Test;
class NotificationControllerTest extends AbstractFunctionalTest{
//region index tests
#[Test]
public function test_index_super_admin_success(): void
{
$admin = $this->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
}

View File

@ -0,0 +1,359 @@
<?php
namespace App\Tests\Controller;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Service\AwsService;
use App\Tests\Functional\AbstractFunctionalTest;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class OrganizationControllerTest extends AbstractFunctionalTest
{
//region INDEX tests
#[Test]
public function test_index_super_admin_success(): void
{
// 1. Arrange
$admin = $this->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
}

View File

@ -0,0 +1,604 @@
<?php
namespace App\Tests\Controller;
use App\Service\AwsService;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\Entity\User;
use App\Entity\Apps;
use App\Entity\Roles;
use App\Entity\Organizations;
use App\Entity\UsersOrganizations;
use App\Entity\UserOrganizatonApp;
use App\Tests\Functional\AbstractFunctionalTest;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
//This test will generate warning, ignore it
class UserControllerTest extends AbstractFunctionalTest
{
//region Index Tests
#[Test]
public function test_index_super_admin_success(): void
{
$admin = $this->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
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Tests\Functional;
use App\Entity\Apps;
use App\Entity\Notification;
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;
abstract class AbstractFunctionalTest extends WebTestCase
{
protected KernelBrowser $client;
protected EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->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');
$this->entityManager->persist($org);
$this->entityManager->flush();
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;
}
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;
}
protected function countEntities(string $entityClass): int
{
return $this->entityManager->getRepository($entityClass)->count([]);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Tests\Service;
use App\Service\AccessTokenService;
use App\Service\LoggerService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class AccessTokenServiceTest extends TestCase
{
private AccessTokenService $service;
// Mocks
private MockObject|EntityManagerInterface $entityManager;
private MockObject|LoggerService $loggerService;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace App\Tests\Service;
use App\Entity\Actions;
use App\Entity\Organizations;
use App\Entity\User;
use App\Service\ActionService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ActionServiceTest extends TestCase
{
private ActionService $service;
private MockObject|EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->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->setUsers($user);
$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->getName(), $result[0]['userName']);
// 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');
}
}

View File

@ -0,0 +1,235 @@
<?php
namespace App\Service;
if (!function_exists('App\Service\uuid_create')) {
function uuid_create($type) {
// Return a fixed dummy UUID for testing reliability
return '78832168-3015-4673-952c-745143093202';
}
}
if (!function_exists('App\Service\uuid_is_valid')) {
function uuid_is_valid($uuid) {
return true;
}
}
namespace App\Tests\Service;
use App\Service\AwsService;
use Aws\CommandInterface;
use Aws\Middleware;
use Aws\MockHandler;
use Aws\History;
use Aws\Result;
use Aws\S3\S3Client;
use PHPUnit\Framework\TestCase;
class AwsServiceTest extends TestCase
{
private AwsService $service;
private MockHandler $mockHandler;
private History $history;
protected function setUp(): void
{
// 1. Create a MockHandler to queue up fake responses
$this->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']);
}
}

View File

@ -0,0 +1,219 @@
<?php
namespace App\Tests\Service;
use App\Entity\Cgu;
use App\Entity\CguUser;
use App\Entity\User;
use App\Repository\CguRepository; // <--- Import your actual repository
use App\Service\CguUserService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class CguUserServiceTest extends TestCase
{
private CguUserService $service;
private MockObject|EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace App\Tests\Service;
use App\Entity\Organizations;
use App\Entity\User;
use App\Service\EmailService;
use App\Service\LoggerService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class EmailServiceTest extends TestCase
{
private EmailService $service;
// Mocks
private MockObject|MailerInterface $mailer;
private MockObject|LoggerInterface $logger; // PSR Logger
private MockObject|UrlGeneratorInterface $urlGenerator;
private MockObject|LoggerService $loggerService; // Custom Business Logger
protected function setUp(): void
{
$this->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');
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace App\Tests\Service;
use App\Service\LoggerService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class LoggerServiceTest extends TestCase
{
private LoggerService $service;
// Mocks for all the channels
private MockObject|LoggerInterface $userManagementLogger;
private MockObject|LoggerInterface $organizationManagementLogger;
private MockObject|LoggerInterface $accessControlLogger;
private MockObject|LoggerInterface $emailNotificationLogger;
private MockObject|LoggerInterface $adminActionsLogger;
private MockObject|LoggerInterface $securityLogger;
private MockObject|LoggerInterface $errorLogger;
private MockObject|LoggerInterface $awsLogger;
private MockObject|RequestStack $requestStack;
protected function setUp(): void
{
// Create mocks for all dependencies
$this->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');
}
}

View File

@ -0,0 +1,239 @@
<?php
namespace App\Tests\Service;
use App\Entity\Organizations;
use App\Entity\User;
use App\Message\NotificationMessage;
use App\Service\NotificationService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
class NotificationServiceTest extends TestCase
{
private NotificationService $service;
private MockObject|MessageBusInterface $messageBus;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,271 @@
<?php
namespace App\Tests\Service;
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 App\Repository\UsersOrganizationsRepository;
use App\Service\AwsService;
use App\Service\LoggerService;
use App\Service\NotificationService;
use App\Service\OrganizationsService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class OrganizationsServiceTest extends TestCase
{
private OrganizationsService $service;
// Mocks
private MockObject|AwsService $awsService;
private MockObject|EntityManagerInterface $entityManager;
private MockObject|UsersOrganizationsRepository $uoRepository;
private MockObject|NotificationService $notificationService;
private MockObject|LoggerInterface $emailNotificationLogger;
private MockObject|LoggerService $loggerService;
private string $logoDirectory = '/tmp/logos';
protected function setUp(): void
{
$this->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');
}
}

View File

@ -0,0 +1,320 @@
<?php
namespace App\Tests\Service;
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 App\Service\ActionService;
use App\Service\LoggerService;
use App\Service\UserOrganizationAppService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
class UserOrganizationAppServiceTest extends TestCase
{
private UserOrganizationAppService $service;
// Mocks
private MockObject|EntityManagerInterface $entityManager;
private MockObject|ActionService $actionService;
private MockObject|Security $security;
private MockObject|UserService $userService;
private MockObject|LoggerInterface $psrLogger;
private MockObject|LoggerService $loggerService;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,186 @@
<?php
namespace App\Tests\Service;
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 App\Service\UserOrganizationService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class UserOrganizationServiceTest extends TestCase
{
private UserOrganizationService $service;
private MockObject|UserOrganizationAppService $userOrganizationAppService;
private MockObject|EntityManagerInterface $entityManager;
private MockObject|ActionService $actionService;
private MockObject|LoggerService $loggerService;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,369 @@
<?php
namespace App\Tests\Service;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
use App\Service\LoggerService;
use App\Service\OrganizationsService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\EntityRepository;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class UserServiceTest extends TestCase
{
private UserService $userService;
// Mocks
private MockObject|EntityManagerInterface $entityManager;
private MockObject|Security $security;
private MockObject|AwsService $awsService;
private MockObject|LoggerService $loggerService;
private MockObject|ActionService $actionService;
private MockObject|EmailService $emailService;
private MockObject|OrganizationsService $organizationsService;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1 @@
"Too many failed login attempts, please try again later.": "Trop de tentatives de connexion. Veuillez réessayer plus tard."