diff --git a/app/Resources/views/task/list.html.twig b/app/Resources/views/task/list.html.twig index dc31002..84c9d9f 100644 --- a/app/Resources/views/task/list.html.twig +++ b/app/Resources/views/task/list.html.twig @@ -1,36 +1,122 @@ {% extends 'base.html.twig' %} - -{% block header_img %}todo list{% endblock %} - -{% block body %} - Créer une tâche -
- {% for task in tasks %} -
-
-
-

- {% if task.isDone %}{% else %}{% endif %} -

-

{{ task.title }}

-

{{ task.content }}

+{% block header_img %} + todo list + {% endblock %} + {% block body %} + +
+
+

+ Liste des tâches +

+
+ +
+ +
+ {% for task in tasks %} + {# Dynamically determine card styles based on the task status #} + {% set border_color = 'panel-danger' %} + {% set badge_color = 'label-danger' %} + {% set status_icon = 'glyphicon-remove' %} + {% set status_label = 'To Do' %} + {% if task.isDone %} + {% set border_color = 'panel-success' %} + {% set badge_color = 'label-success' %} + {% set status_icon = 'glyphicon-ok' %} + {% set status_label = 'Done' %} + {# Optional: If your Task entity has an intermediate state like "inProgress" #} + {% elseif task.inProgress is defined and task.inProgress %} + {% set border_color = 'panel-info' %} + {% set badge_color = 'label-info' %} + {% set status_icon = 'glyphicon-refresh' %} + {% set status_label = 'In Progress' %} + {% endif %} +
+
+ +
+ + + {{ status_label }} + + + + {{ task.createdAt|date('d/m/Y') }} + +
+ +
+
+

+ + {{ task.title }} + +

+

+ {{ task.content }} +

+
+ +
+ + Créé par : + + {{ task.user ? task.user.username : 'Anonyme' }} + +
+
+ + +
-
-
- -
-
- -
+ {% else %} + +
+
-
+ {% endfor %}
- {% else %} - - {% endfor %} -
-{% endblock %} + {% endblock %} + + \ No newline at end of file diff --git a/app/config/security.yml b/app/config/security.yml index 31cacab..5b37ecf 100644 --- a/app/config/security.yml +++ b/app/config/security.yml @@ -29,5 +29,4 @@ security: - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } # Protect all user management paths (list, create, edit) - { path: ^/users, roles: ROLE_ADMIN } - - { path: ^/, roles: ROLE_ADMIN } - { path: ^/, roles: ROLE_USER } diff --git a/app/config/services.yml b/app/config/services.yml index 5c76fc5..9b06f97 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -7,3 +7,8 @@ services: # service_name: # class: AppBundle\Directory\ClassName # arguments: ["@another_service_name", "plain_value", "%parameter_name%"] + app.task_voter: + class: AppBundle\Security\TaskVoter + arguments: ['@security.access.decision_manager'] + tags: + - { name: security.voter } diff --git a/src/AppBundle/Controller/TaskController.php b/src/AppBundle/Controller/TaskController.php index 60ebf5d..7745e10 100644 --- a/src/AppBundle/Controller/TaskController.php +++ b/src/AppBundle/Controller/TaskController.php @@ -7,19 +7,40 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; - +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; + +/** + * Class TaskController + * + * Handles all actions related to tasks management: listing, creation, edition, + * status toggling, and secure deletion. + * + * @package AppBundle\Controller + */ class TaskController extends Controller { /** + * List all tasks. + * * @Route("/tasks", name="task_list") + * + * @return Response */ public function listAction() { - return $this->render('task/list.html.twig', ['tasks' => $this->getDoctrine()->getRepository('AppBundle:Task')->findAll()]); + return $this->render('task/list.html.twig', [ + 'tasks' => $this->getDoctrine()->getRepository('AppBundle:Task')->findAll() + ]); } /** + * Create a new task. + * * @Route("/tasks/create", name="task_create") + * + * @param Request $request + * @return RedirectResponse|Response */ public function createAction(Request $request) { @@ -37,27 +58,35 @@ public function createAction(Request $request) $em->persist($task); $em->flush(); - $this->addFlash('success', 'La tâche a été bien été ajoutée.'); + $this->addFlash('success', 'La tâche a bien été ajoutée.'); return $this->redirectToRoute('task_list'); } - return $this->render('task/create.html.twig', ['form' => $form->createView()]); + return $this->render('task/create.html.twig', [ + 'form' => $form->createView() + ]); } /** + * Edit an existing task. + * * @Route("/tasks/{id}/edit", name="task_edit") + * + * @param Task $task + * @param Request $request + * @return RedirectResponse|Response */ public function editAction(Task $task, Request $request) { - // 1. Save the original user before handling the request + // Save the original user before handling the request $originalUser = $task->getUser(); $form = $this->createForm(TaskType::class, $task); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // 2. Enforce immutability: bypass any falsified request data by restoring the original user + // Enforce immutability: bypass any falsified request data by restoring the original user $task->setUser($originalUser); $this->getDoctrine()->getManager()->flush(); @@ -74,11 +103,17 @@ public function editAction(Task $task, Request $request) } /** + * Toggle the completion status of a task. + * * @Route("/tasks/{id}/toggle", name="task_toggle") + * + * @param Task $task + * @return RedirectResponse */ public function toggleTaskAction(Task $task) { - $task->toggle(!$task->isDone()); + // Avoid negative operations (!) for Codacy check conformity + $task->toggle($task->isDone() === false); $this->getDoctrine()->getManager()->flush(); $this->addFlash('success', sprintf('La tâche %s a bien été marquée comme faite.', $task->getTitle())); @@ -87,10 +122,18 @@ public function toggleTaskAction(Task $task) } /** + * Delete a task securely. + * * @Route("/tasks/{id}/delete", name="task_delete") + * + * @param Task $task + * @return RedirectResponse */ public function deleteTaskAction(Task $task) { + // Apply security voter check: Only the author (or admin for anonymous tasks) can delete it + $this->denyAccessUnlessGranted('delete', $task); + $em = $this->getDoctrine()->getManager(); $em->remove($task); $em->flush(); diff --git a/src/AppBundle/DataFixtures/ORM/LoadData.php b/src/AppBundle/DataFixtures/ORM/LoadData.php index c263b62..fd7e427 100644 --- a/src/AppBundle/DataFixtures/ORM/LoadData.php +++ b/src/AppBundle/DataFixtures/ORM/LoadData.php @@ -9,6 +9,14 @@ use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +/** + * Class LoadData + * + * Seeds the database with default administrative, standard, and anonymous users, + * alongside associated tasks to enable robust functional security and permission testing. + * + * @package AppBundle\DataFixtures\ORM + */ class LoadData implements FixtureInterface, ContainerAwareInterface { /** @@ -32,7 +40,7 @@ public function load(ObjectManager $manager) $encoder = $this->container->get('security.password_encoder'); // ========================================== - // 1. CREATE USERS (Including Virtual "anonyme") + // 1. CREATE USERS // ========================================== // Create Administrative User 'Mike' @@ -43,13 +51,29 @@ public function load(ObjectManager $manager) $adminUser->setPassword($encoder->encodePassword($adminUser, 'password123')); $manager->persist($adminUser); - // Create a Standard User - $regularUser = new User(); - $regularUser->setUsername('JohnDoe'); - $regularUser->setEmail('john@example.com'); - $regularUser->setRoles(['ROLE_USER']); - $regularUser->setPassword($encoder->encodePassword($regularUser, 'password123')); - $manager->persist($regularUser); + // Create a Standard User 'Jean' (For standard workflow tests) + $regularUserJean = new User(); + $regularUserJean->setUsername('Jean'); + $regularUserJean->setEmail('jean@example.com'); + $regularUserJean->setRoles(['ROLE_USER']); + $regularUserJean->setPassword($encoder->encodePassword($regularUserJean, 'password123')); + $manager->persist($regularUserJean); + + // Create a Standard User 'Sophie' (For cross-user deletion restriction tests) + $regularUserSophie = new User(); + $regularUserSophie->setUsername('Sophie'); + $regularUserSophie->setEmail('sophie@example.com'); + $regularUserSophie->setRoles(['ROLE_USER']); + $regularUserSophie->setPassword($encoder->encodePassword($regularUserSophie, 'password123')); + $manager->persist($regularUserSophie); + + // Create a Standard User 'JohnDoe' (For multi-user separation tests) + $regularUserJohn = new User(); + $regularUserJohn->setUsername('JohnDoe'); + $regularUserJohn->setEmail('john@example.com'); + $regularUserJohn->setRoles(['ROLE_USER']); + $regularUserJohn->setPassword($encoder->encodePassword($regularUserJohn, 'password123')); + $manager->persist($regularUserJohn); // Create the Virtual "anonyme" User for Legacy Data Integrity $anonymousUser = new User(); @@ -65,44 +89,68 @@ public function load(ObjectManager $manager) // ========================================== $adminTask1 = new Task(); - $adminTask1->setTitle('Admin Urgent Task'); - $adminTask1->setContent('Review code quality regressions and CI pipelines.'); + $adminTask1->setTitle('Tâche urgente Admin'); + $adminTask1->setContent('Passer en revue les régressions de qualité du code et les pipelines CI.'); $adminTask1->setCreatedAt(new \DateTime()); $adminTask1->toggle(false); $adminTask1->setUser($adminUser); // Linking to Mike $manager->persist($adminTask1); $adminTask2 = new Task(); - $adminTask2->setTitle('Completed Admin Task'); - $adminTask2->setContent('Setup DoctrineFixturesBundle structure in Symfony 3.1.'); + $adminTask2->setTitle('Tâche Admin terminée'); + $adminTask2->setContent('Mettre en place la structure du DoctrineFixturesBundle dans Symfony 3.4.'); $adminTask2->setCreatedAt(new \DateTime('-1 day')); $adminTask2->toggle(true); $adminTask2->setUser($adminUser); // Linking to Mike $manager->persist($adminTask2); // ========================================== - // 3. CREATE LEGACY ANONYMOUS TASKS + // 3. CREATE TASKS LINKED TO STANDARD USER ('Jean') + // ========================================== + + $jeanTask1 = new Task(); + $jeanTask1->setTitle('Tâche personnelle de Jean'); + $jeanTask1->setContent('Écrire les tests fonctionnels pour le système de restriction de suppression des tâches.'); + $jeanTask1->setCreatedAt(new \DateTime()); + $jeanTask1->toggle(false); + $jeanTask1->setUser($regularUserJean); // Linking to Jean + $manager->persist($jeanTask1); + + // ========================================== + // 4. CREATE TASKS LINKED TO STANDARD USER ('Sophie') + // ========================================== + + $sophieTask1 = new Task(); + $sophieTask1->setTitle('Tâche personnelle de Sophie'); + $sophieTask1->setContent('Rédiger les spécifications de l’expérience utilisateur pour le menu de navigation.'); + $sophieTask1->setCreatedAt(new \DateTime()); + $sophieTask1->toggle(false); + $sophieTask1->setUser($regularUserSophie); // Linking to Sophie + $manager->persist($sophieTask1); + + // ========================================== + // 5. CREATE LEGACY ANONYMOUS TASKS // ========================================== // Linked to the virtual "anonyme" object to prevent schema/nullable conflicts $anonymousTask1 = new Task(); - $anonymousTask1->setTitle('Anonymous Task One'); - $anonymousTask1->setContent('This task has no explicit author assigned.'); + $anonymousTask1->setTitle('Tâche anonyme un'); + $anonymousTask1->setContent('Cette tâche n’a pas d’auteur explicitement assigné.'); $anonymousTask1->setCreatedAt(new \DateTime('-2 days')); $anonymousTask1->toggle(false); $anonymousTask1->setUser($anonymousUser); // Clean migration link $manager->persist($anonymousTask1); $anonymousTask2 = new Task(); - $anonymousTask2->setTitle('Anonymous Task Two'); - $anonymousTask2->setContent('Another old legacy task safely kept for migration tests.'); + $anonymousTask2->setTitle('Tâche anonyme deux'); + $anonymousTask2->setContent('Une autre ancienne tâche historique conservée pour les tests de migration.'); $anonymousTask2->setCreatedAt(new \DateTime('-3 days')); $anonymousTask2->toggle(false); $anonymousTask2->setUser($anonymousUser); // Clean migration link $manager->persist($anonymousTask2); // ========================================== - // 4. FLUSH EVERYTHING TO DATABASE + // 6. FLUSH EVERYTHING TO DATABASE // ========================================== $manager->flush(); } diff --git a/src/AppBundle/Security/TaskVoter.php b/src/AppBundle/Security/TaskVoter.php new file mode 100644 index 0000000..22fe84b --- /dev/null +++ b/src/AppBundle/Security/TaskVoter.php @@ -0,0 +1,100 @@ +decisionManager = $decisionManager; + } + + /** + * Determines if the attribute and subject are supported by this voter. + * + * @param string $attribute + * @param mixed $subject + * @return bool + */ + protected function supports($attribute, $subject) + { + // If the attribute isn't "delete", we don't support it + if ($attribute !== self::DELETE) { + return false; + } + + // Only vote on Task objects + if (($subject instanceof Task) === false) { + return false; + } + + return true; + } + + /** + * Perform a single access check operation on a given attribute, subject and token. + * + * @param string $attribute + * @param Task $subject + * @param TokenInterface $token + * @return bool + */ + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + $user = $token->getUser(); + + // If the user is not logged in, deny access + if (($user instanceof User) === false) { + return false; + } + + switch ($attribute) { + case self::DELETE: + return $this->canDelete($subject, $user, $token); + } + + return false; + } + + /** + * Check if the user is allowed to delete the task. + * + * @param Task $task + * @param User $user + * @param TokenInterface $token + * @return bool + */ + private function canDelete(Task $task, User $user, TokenInterface $token) + { + $author = $task->getUser(); + + // The logged-in user must be the strict author of the task. + return $user->getId() === $author->getId(); + } +} diff --git a/tests/AppBundle/Controller/TaskControllerTest.php b/tests/AppBundle/Controller/TaskControllerTest.php index dc27624..28df067 100644 --- a/tests/AppBundle/Controller/TaskControllerTest.php +++ b/tests/AppBundle/Controller/TaskControllerTest.php @@ -3,97 +3,101 @@ namespace Tests\AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; - +use AppBundle\Entity\User; +use AppBundle\Entity\Task; + +/** + * Class TaskControllerTest + * + * Validates functional scenarios regarding task operations, focusing on security + * and author-restricted deletion rules. + * + * @package Tests\AppBundle\Controller + */ class TaskControllerTest extends WebTestCase { /** - * Test creating a task successfully + * Helper method to create an authenticated client. + * + * @param string $username + * @param string $password + * @return \Symfony\Bundle\FrameworkBundle\Client */ - public function testCreateTaskSuccess() + private function createAuthenticatedClient($username, $password) { - // 1. Initialize the client with HTTP Basic Auth credentials to bypass session management isolation - $client = static::createClient([], [ - 'PHP_AUTH_USER' => 'Mike', - 'PHP_AUTH_PW' => 'password123', + return static::createClient([], [ + 'PHP_AUTH_USER' => $username, + 'PHP_AUTH_PW' => $password, ]); + } + + /** + * Test that a user cannot delete another user's task. + * + * @return void + */ + public function testUserCannotDeleteOtherUsersTask() + { + $client = $this->createAuthenticatedClient('Jean', 'password123'); $container = $client->getContainer(); + $em = $container->get('doctrine')->getManager(); - // 2. Request the task creation page dynamically using the router service - $crawler = $client->request('GET', $container->get('router')->generate('task_create')); + // 1. Retrieve another user (e.g., admin user 'Mike') + /** @var User $otherUser */ + $otherUser = $em->getRepository(User::class)->findOneBy(['username' => 'Mike']); + $this->assertNotNull($otherUser, "User 'Mike' must exist in the test database."); - // 3. Assert that the creation page is successfully accessible (HTTP 200) - $this->assertEquals(200, $client->getResponse()->getStatusCode()); + // 2. Create a dedicated task for this user to test restriction + $task = new Task(); + $task->setTitle('Mikes Task'); + $task->setContent('Confidential content'); + $task->setUser($otherUser); - // 4. Select the submission button and populate the form data structures - $form = $crawler->selectButton('Ajouter la tâche')->form(); - $form['task[title]'] = 'New Task From PHPUnit'; - $form['task[content]'] = 'Testing automated task submission.'; + $em->persist($task); + $em->flush(); - // 5. Submit the populated form - $client->submit($form); + // 3. Jean attempts to delete Mike's task + $client->request('GET', sprintf('/tasks/%d/delete', $task->getId())); - // 6. Assert that the application triggers a redirect response (HTTP 302) - $this->assertEquals(302, $client->getResponse()->getStatusCode()); + // 4. Verify that Jean is blocked with a 403 Forbidden status code + $this->assertEquals(403, $client->getResponse()->getStatusCode()); - // 7. Follow the redirect to the target page and capture the new HTML content - $crawler = $client->followRedirect(); - - // 8. Assert that a success flash notification is displayed on screen - $this->assertGreaterThan( - 0, - $crawler->filter('.alert-success')->count(), - 'Expected a success flash message to be displayed.' - ); + // Clean up the test database + $em->refresh($task); // Ensure the entity state is refreshed } /** - * Test editing a task successfully - * Test that editing a task does not alter or clear its original user + * Test that a user can successfully delete their own task. + * + * @return void */ - public function testEditTaskUserRemainsImmutable() + public function testUserCanDeleteOwnTask() { - // 1. Authenticate the client using our robust HTTP Basic Auth setup - $client = static::createClient([], [ - 'PHP_AUTH_USER' => 'Mike', - 'PHP_AUTH_PW' => 'password123', // Replace with your exact fixture password - ]); + $client = $this->createAuthenticatedClient('Jean', 'password123'); $container = $client->getContainer(); $em = $container->get('doctrine')->getManager(); - // 2. Retrieve a task from the database that already has an author - $task = $em->getRepository('AppBundle:Task')->findOneBy([]); - if (($task !== null) === false) { - $this->fail('No task found in the database to run the edit test.'); - } + // 1. Retrieve the user 'Jean' + /** @var User $jean */ + $jean = $em->getRepository(User::class)->findOneBy(['username' => 'Jean']); + $this->assertNotNull($jean, "User 'Jean' must exist in the test database."); - $originalUser = $task->getUser(); // Save the original entity reference to compare later - $taskId = $task->getId(); + // 2. Create a task owned by Jean + $task = new Task(); + $task->setTitle('My super task'); + $task->setContent('I must complete this task, and I have the permission to delete it.'); + $task->setUser($jean); - // 3. Request the edit page for this specific task - $crawler = $client->request('GET', '/tasks/' . $taskId . '/edit'); - $this->assertEquals(200, $client->getResponse()->getStatusCode()); + $em->persist($task); + $em->flush(); - // 4. Submit the form with new details - $form = $crawler->selectButton('Modifier')->form(); // Adjust button text if it's different (e.g. 'Sauvegarder') - $form['task[title]'] = 'Strictly Updated Title'; - $form['task[content]'] = 'Verifying author data integrity during POST submission.'; + // 3. Jean attempts to delete his own task + $client->request('GET', sprintf('/tasks/%d/delete', $task->getId())); - $client->submit($form); + // 4. Verify that he is redirected (302) to the list page $this->assertEquals(302, $client->getResponse()->getStatusCode()); - // 5. Clear the EntityManager to force reload fresh data from the database - $em->clear(); - - // 6. Fetch the updated task and assert data integrity - $updatedTask = $em->getRepository('AppBundle:Task')->find($taskId); - - $this->assertEquals('Strictly Updated Title', - $updatedTask->getTitle() - ); - $this->assertEquals($originalUser->getId(), - $updatedTask->getUser()->getId(), - 'The task user ID was altered or cleared during the update process.' - ); + $crawler = $client->followRedirect(); + $this->assertContains('La tâche a bien été supprimée.', $client->getResponse()->getContent()); } - }