diff --git a/src/Controller/Api/SignalementController.php b/src/Controller/Api/SignalementController.php index dcd75943a..ec360509c 100644 --- a/src/Controller/Api/SignalementController.php +++ b/src/Controller/Api/SignalementController.php @@ -7,7 +7,7 @@ use App\Entity\User; use App\Factory\Api\SignalementResponseFactory; use App\Repository\SignalementRepository; -use Nelmio\ApiDocBundle\Annotation\Model; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\When; diff --git a/src/Controller/Api/SignalementFileUploadController.php b/src/Controller/Api/SignalementFileUploadController.php index b978a492f..e0540b3fb 100644 --- a/src/Controller/Api/SignalementFileUploadController.php +++ b/src/Controller/Api/SignalementFileUploadController.php @@ -11,7 +11,7 @@ use App\Factory\Api\FileFactory; use App\Service\Signalement\SignalementFileProcessor; use Doctrine\ORM\EntityManagerInterface; -use Nelmio\ApiDocBundle\Annotation\Model; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\When; diff --git a/src/Controller/Api/SuiviCreateController.php b/src/Controller/Api/SuiviCreateController.php new file mode 100644 index 000000000..71e9ecc38 --- /dev/null +++ b/src/Controller/Api/SuiviCreateController.php @@ -0,0 +1,180 @@ + []]], + tags: ['Suivis'] + )] + #[OA\Response( + response: Response::HTTP_CREATED, + description: 'Suivi crée avec succès', + content: new OA\JsonContent(ref: new Model(type: SuiviResponse::class)) + )] + #[OA\Response( + response: Response::HTTP_NOT_FOUND, + description: 'Signalement introuvable', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'Signalement introuvable' + ), + new OA\Property( + property: 'statut', + type: 'int', + example: Response::HTTP_NOT_FOUND + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: Response::HTTP_BAD_REQUEST, + description: 'Mauvaise payload (données invalides).', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'Valeurs invalides pour les champs suivants :' + ), + new OA\Property( + property: 'status', + type: 'integer', + example: 400 + ), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property( + property: 'property', + type: 'string', + example: 'description' + ), + new OA\Property( + property: 'message', + type: 'string', + example: 'Le contenu du suivi doit faire au moins 10 caractères !' + ), + ], + type: 'object' + ) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: Response::HTTP_FORBIDDEN, + description: 'Accès à la ressource non autorisé.', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'message', + type: 'string', + example: 'Vous n\'avez pas l\'autorisation d\'accéder à cette ressource.' + ), + new OA\Property( + property: 'statut', + type: 'int', + example: Response::HTTP_FORBIDDEN + ), + ], + type: 'object' + ) + )] + public function __invoke( + #[MapRequestPayload] + SuiviRequest $suiviRequest, + ?Signalement $signalement = null, + ): JsonResponse { + if (null === $signalement) { + return $this->json( + ['message' => 'Signalement introuvable', 'status' => Response::HTTP_NOT_FOUND], + Response::HTTP_NOT_FOUND + ); + } + $this->denyAccessUnlessGranted('COMMENT_CREATE', + $signalement, + SecurityApiExceptionListener::ACCESS_DENIED + ); + + /** @var User $user */ + $user = $this->getUser(); + $suivi = $this->suiviManager->createSuivi( + signalement: $signalement, + description: $this->buildDescription($signalement, $suiviRequest), + type: Suivi::TYPE_PARTNER, + isPublic: $suiviRequest->notifyUsager, + user: $user, + ); + + return $this->json(new SuiviResponse($suivi), Response::HTTP_CREATED); + } + + private function buildDescription(Signalement $signalement, SuiviRequest $suiviRequest): string + { + $fileListAsHtml = ''; + $description = Sanitizer::sanitize($suiviRequest->description); + $filesFiltered = $signalement->getFiles()->filter(function (File $file) use ($suiviRequest) { + return in_array($file->getUuid(), $suiviRequest->files, true); + }); + + if ($filesFiltered->count() > 0) { + $fileListAsHtml = ''; + } + + return $description.$fileListAsHtml; + } +} diff --git a/src/Controller/Back/SignalementActionController.php b/src/Controller/Back/SignalementActionController.php index 0623a2eae..02d0a18a3 100755 --- a/src/Controller/Back/SignalementActionController.php +++ b/src/Controller/Back/SignalementActionController.php @@ -16,6 +16,7 @@ use App\Service\Mailer\NotificationMail; use App\Service\Mailer\NotificationMailerRegistry; use App\Service\Mailer\NotificationMailerType; +use App\Service\Sanitizer; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; @@ -109,9 +110,7 @@ public function addSuiviSignalement( $this->denyAccessUnlessGranted('COMMENT_CREATE', $signalement); if ($this->isCsrfTokenValid('signalement_add_suivi_'.$signalement->getId(), $request->get('_token')) && $form = $request->get('signalement-add-suivi')) { - $content = $form['content']; - $content = preg_replace('/]*>/', '', $content); // Remove the start

or

- $content = str_replace('

', '
', $content); // Replace the end + $content = Sanitizer::sanitize($form['content']); if (mb_strlen($content) < 10) { $this->addFlash('error', 'Le contenu du suivi doit faire au moins 10 caractères !'); @@ -121,11 +120,11 @@ public function addSuiviSignalement( /** @var User $user */ $user = $this->getUser(); $suiviManager->createSuivi( - user: $user, signalement: $signalement, description: $content, type: Suivi::TYPE_PARTNER, isPublic: !empty($form['notifyUsager']), + user: $user, ); } catch (\Throwable $exception) { $this->addFlash('error', 'Une erreur est survenue lors de la publication.'); diff --git a/src/Dto/Api/Model/Suivi.php b/src/Dto/Api/Model/Suivi.php index 8f3caf969..57ded5bce 100644 --- a/src/Dto/Api/Model/Suivi.php +++ b/src/Dto/Api/Model/Suivi.php @@ -11,13 +11,6 @@ )] class Suivi { - #[OA\Property( - description: 'Identifiant unique du suivi.', - type: 'integer', - example: 1 - )] - public int $id; - #[OA\Property( description: 'Date de création du suivi.
Exemple : `2024-11-01T10:00:00+00:00`', type: 'string', @@ -26,9 +19,9 @@ class Suivi )] public string $dateCreation; #[OA\Property( - description: 'Description détaillée du suivi.', + description: 'Description détaillée du suivi, peut contenir des balises HTML.', type: 'string', - example: 'Premier suivi associé.' + example: 'Premier suivi associé.' )] public string $description; @@ -49,9 +42,8 @@ class Suivi public function __construct( SuiviEntity $suivi, ) { - $this->id = $suivi->getId(); $this->dateCreation = $suivi->getCreatedAt()->format(\DATE_ATOM); - $this->description = $suivi->getDescription(); // traitement de suppression du html ? comment gérer les bouton/doc qui sont présent en dur dans le contenu ? + $this->description = $suivi->getDescription(); $this->public = $suivi->getIsPublic(); $this->createdBy = $suivi->getCreatedByLabel(); } diff --git a/src/Dto/Api/Request/SuiviRequest.php b/src/Dto/Api/Request/SuiviRequest.php new file mode 100644 index 000000000..54123f896 --- /dev/null +++ b/src/Dto/Api/Request/SuiviRequest.php @@ -0,0 +1,37 @@ + 1, 'dateCreation' => '2024-11-01T10:00:00+00:00', 'description' => 'Premier suivi associé.', 'public' => true, 'createdBy' => 'John Doe', ], [ - 'id' => 2, 'dateCreation' => '2024-11-02T12:30:00+00:00', 'description' => 'Deuxième suivi, accès limité.', 'public' => false, diff --git a/src/Dto/Api/Response/SuiviResponse.php b/src/Dto/Api/Response/SuiviResponse.php new file mode 100644 index 000000000..6afc45d81 --- /dev/null +++ b/src/Dto/Api/Response/SuiviResponse.php @@ -0,0 +1,38 @@ +Exemple : `2024-11-01T10:00:00+00:00`', + type: 'string', + format: 'date-time', + example: '2024-11-01T10:00:00+00:00' + )] + public string $dateCreation; + + #[OA\Property( + description: 'Description détaillée du suivi, peut contenir des balises HTML.', + type: 'string', + example: '' + )] + public string $description; + + #[OA\Property( + description: 'Indique si le suivi est visible pour les usagers.', + type: 'boolean', + example: true + )] + public bool $public; + + public function __construct(Suivi $suivi) + { + $this->dateCreation = $suivi->getCreatedAt()->format(\DATE_ATOM); + $this->description = $suivi->getDescription(); + $this->public = $suivi->getIsPublic(); + } +} diff --git a/src/EventListener/SecurityApiExceptionListener.php b/src/EventListener/SecurityApiExceptionListener.php index f88d8459e..2c765c79f 100644 --- a/src/EventListener/SecurityApiExceptionListener.php +++ b/src/EventListener/SecurityApiExceptionListener.php @@ -17,7 +17,6 @@ class SecurityApiExceptionListener public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); - if ($this->supports($exception)) { $previous = $exception->getPrevious(); $affectation = null; diff --git a/src/Validator/SanitizedLength.php b/src/Validator/SanitizedLength.php new file mode 100644 index 000000000..76ac342d9 --- /dev/null +++ b/src/Validator/SanitizedLength.php @@ -0,0 +1,21 @@ +min = $min; + if ($message) { + $this->message = $message; + } + } +} diff --git a/src/Validator/SanitizedLengthValidator.php b/src/Validator/SanitizedLengthValidator.php new file mode 100644 index 000000000..641952091 --- /dev/null +++ b/src/Validator/SanitizedLengthValidator.php @@ -0,0 +1,36 @@ +min) { + $this->context + ->buildViolation($constraint->message) + ->setParameter('{{ limit }}', (string) $constraint->min) + ->addViolation(); + } + } +} diff --git a/src/Validator/ValidFiles.php b/src/Validator/ValidFiles.php new file mode 100644 index 000000000..32b82a1ae --- /dev/null +++ b/src/Validator/ValidFiles.php @@ -0,0 +1,19 @@ +message = $message; + } + } +} diff --git a/src/Validator/ValidFilesValidator.php b/src/Validator/ValidFilesValidator.php new file mode 100644 index 000000000..ef5fcd756 --- /dev/null +++ b/src/Validator/ValidFilesValidator.php @@ -0,0 +1,56 @@ +signalementRepository->findOneBy([ + 'uuid' => $this->requestStack->getCurrentRequest()->get('signalement')] + ); + + if (null === $signalement) { + return; + } + + $uuidFiles = array_map(fn (File $file) => $file->getUuid(), $signalement->getFiles()->toArray()); + + foreach ($value as $uuid) { + if (!is_string($uuid) || !UuidV4::isValid($uuid)) { + $this->context->buildViolation('Le fichier avec l\'UUID "{{ uuid }}" n\'est pas un UUID valide.') + ->setParameter('{{ uuid }}', $uuid) + ->addViolation(); + continue; + } + if (!in_array($uuid, $uuidFiles)) { + $this->context->buildViolation('Le fichier avec l\'UUID "{{ uuid }}" n\'appartient pas au signalement.') + ->setParameter('{{ uuid }}', $uuid) + ->addViolation(); + } + } + } +} diff --git a/tests/Functional/Controller/Api/SuiviCreateControllerTest.php b/tests/Functional/Controller/Api/SuiviCreateControllerTest.php new file mode 100644 index 000000000..37ccd390f --- /dev/null +++ b/tests/Functional/Controller/Api/SuiviCreateControllerTest.php @@ -0,0 +1,73 @@ +client = static::createClient(); + $user = self::getContainer()->get('doctrine')->getRepository(User::class)->findOneBy([ + 'email' => 'api-01@histologe.fr', + ]); + + $this->router = self::getContainer()->get('router'); + + $this->client->loginUser($user, 'api'); + } + + /** + * @dataProvider provideData + */ + public function testCreateSuivi(string $signalementUuid, bool $notifyUsager, int $nbMailSent): void + { + $signalement = self::getContainer()->get(SignalementRepository::class)->findOneBy(['uuid' => $signalementUuid]); + $firstFile = $signalement?->getFiles()?->first() ?? null; + $lastFile = $signalement?->getFiles()?->last() ?? null; + $payload = [ + 'description' => 'lorem ipsum dolor sit amet', + 'notifyUsager' => $notifyUsager, + ]; + + if (null !== $firstFile && null !== $lastFile) { + $payload['files'] = [$firstFile->getUuid(), $lastFile->getUuid()]; + } + $this->client->request( + method: 'POST', + uri: $this->router->generate('api_signalements_suivis_post', ['uuid' => $signalementUuid]), + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload) + ); + if ('0000' !== $signalementUuid) { + /** @var Suivi $suiviCreated */ + $suiviCreated = $signalement->getSuivis()->last(); + + $this->assertEquals(201, $this->client->getResponse()->getStatusCode()); + $this->assertEmailCount($nbMailSent); + + $crawler = new Crawler($suiviCreated->getDescription()); + $links = $crawler->filter('a.fr-link'); + $this->assertCount(2, $links, 'Il doit y avoir exactement 2 liens dans le contenu HTML.'); + } else { + $this->assertEquals(404, $this->client->getResponse()->getStatusCode()); + } + } + + public function provideData(): \Generator + { + yield 'test create suivi with usager notification' => ['00000000-0000-0000-2022-000000000006', true, 2]; + yield 'test create suivi with no usager notification' => ['00000000-0000-0000-2022-000000000006', false, 1, '00000000-0000-0000-2022-000000000006']; + yield 'test create suivi with unknown signalement' => ['0000', false, 1]; + } +} diff --git a/tests/Unit/Validator/SanitizedLengthValidatorTest.php b/tests/Unit/Validator/SanitizedLengthValidatorTest.php new file mode 100644 index 000000000..7d41f8edd --- /dev/null +++ b/tests/Unit/Validator/SanitizedLengthValidatorTest.php @@ -0,0 +1,58 @@ +validator->validate($value, $constraint); + $this->assertNoViolation(); + } + + /** + * @dataProvider provideInvalidValues + */ + public function testSanitizedTextTooShort(string $value): void + { + $constraint = new SanitizedLength(10, 'Text too short.'); + $this->validator->validate($value, $constraint); + $this->buildViolation('Text too short.') + ->setParameter('{{ limit }}', '10') + ->assertRaised(); + } + + public function provideValidValues(): \Generator + { + yield 'null value' => [null]; + yield 'empty value' => ['']; + yield 'valid value' => ['

Lorem ipsum dolor sit amet

']; + } + + public function provideInvalidValues(): \Generator + { + yield 'too short value with no html' => ['Hi buddy!']; + yield 'too short value with html' => ['Hi buddy!']; + } + + public function testNonStringThrowsException(): void + { + $this->expectException(UnexpectedValueException::class); + $constraint = new SanitizedLength(10, 'Text too short.'); + $this->validator->validate(12345, $constraint); + } +}