-
-
- {% if task.isDone %}{% else %}{% endif %}
-
-
-
{{ task.content }}
+{% block header_img %}
+
 }})
+ {% 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.content }}
+
+
+
+
+
+ Créé par :
+
+ {{ task.user ? task.user.username : 'Anonyme' }}
+
+
+
+
+
+
-
+ {% 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());
}
-
}