feat: Permettre à l'enseignant de rédiger avec un éditeur riche et joindre des fichiers
Les enseignants avaient besoin de consignes plus claires pour les élèves : le champ description en texte brut ne permettait ni mise en forme ni partage de documents. Cette limitation obligeait à décrire verbalement les ressources au lieu de les joindre directement. L'éditeur WYSIWYG (TipTap) remplace le textarea avec gras, italique, listes et liens. Le contenu HTML est sanitisé côté backend via symfony/html-sanitizer pour prévenir les injections XSS. Les pièces jointes (PDF, JPEG, PNG, max 10 Mo) sont uploadées via une API dédiée avec validation MIME côté domaine et protection path-traversal sur le téléchargement. Les descriptions en texte brut existantes restent lisibles sans migration de données.
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
@@ -110,6 +111,46 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
self::assertNull($homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSanitizesHtmlDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||
$command = $this->createCommand(description: '<p>Texte <strong>gras</strong></p><script>alert("xss")</script>');
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('<p>Texte <strong>gras</strong></p>alert("xss")', $homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotSanitizeNullDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public bool $called = false;
|
||||
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||
$command = $this->createCommand(description: null);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertFalse($sanitizer->called);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
|
||||
{
|
||||
@@ -269,12 +310,67 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
return new CreateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$rulesChecker,
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandlerWithSanitizer(bool $affecte, HtmlSanitizer $htmlSanitizer, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
|
||||
{
|
||||
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
|
||||
public function __construct(private readonly bool $affecte)
|
||||
{
|
||||
}
|
||||
|
||||
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
|
||||
{
|
||||
return $this->affecte;
|
||||
}
|
||||
};
|
||||
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
|
||||
public function __construct(private readonly HomeworkRulesCheckResult $result)
|
||||
{
|
||||
}
|
||||
|
||||
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
};
|
||||
|
||||
return new CreateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$rulesChecker,
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
|
||||
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
||||
@@ -172,6 +173,60 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
self::assertSame('Exercices sans description', $homework->title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSanitizesHtmlDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->existingHomeworkId,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Test sanitize',
|
||||
description: '<p>Texte</p><script>alert("xss")</script>',
|
||||
dueDate: '2026-04-20',
|
||||
);
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('<p>Texte</p>alert("xss")', $homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotSanitizeNullDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public bool $called = false;
|
||||
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->existingHomeworkId,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Test null desc',
|
||||
description: null,
|
||||
dueDate: '2026-04-20',
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertFalse($sanitizer->called);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenNotOwner(): void
|
||||
{
|
||||
@@ -206,7 +261,7 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
$this->homeworkRepository->save($homework);
|
||||
}
|
||||
|
||||
private function createHandler(): UpdateHomeworkHandler
|
||||
private function createHandlerWithSanitizer(HtmlSanitizer $htmlSanitizer): UpdateHomeworkHandler
|
||||
{
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
@@ -224,6 +279,37 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
$this->homeworkRepository,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(): UpdateHomeworkHandler
|
||||
{
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
return new UpdateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
|
||||
final class HomeworkHtmlSanitizerTest extends TestCase
|
||||
{
|
||||
private HomeworkHtmlSanitizer $sanitizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$config = (new HtmlSanitizerConfig())
|
||||
->allowElement('p')
|
||||
->allowElement('br')
|
||||
->allowElement('strong')
|
||||
->allowElement('em')
|
||||
->allowElement('ul')
|
||||
->allowElement('ol')
|
||||
->allowElement('li')
|
||||
->allowElement('a', ['href', 'target', 'rel']);
|
||||
|
||||
$this->sanitizer = new HomeworkHtmlSanitizer(new HtmlSanitizer($config));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsBoldText(): void
|
||||
{
|
||||
$html = '<p>Texte <strong>en gras</strong></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsItalicText(): void
|
||||
{
|
||||
$html = '<p>Texte <em>en italique</em></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsUnorderedLists(): void
|
||||
{
|
||||
$html = '<ul><li>Élément 1</li><li>Élément 2</li></ul>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsOrderedLists(): void
|
||||
{
|
||||
$html = '<ol><li>Premier</li><li>Deuxième</li></ol>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsLinksWithSafeAttributes(): void
|
||||
{
|
||||
$html = '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">Lien</a></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsScriptTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><script>alert("xss")</script>');
|
||||
|
||||
self::assertStringNotContainsString('<script>', $result);
|
||||
self::assertStringNotContainsString('alert', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsEventHandlers(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p onclick="alert(1)">Texte</p>');
|
||||
|
||||
self::assertStringNotContainsString('onclick', $result);
|
||||
self::assertStringContainsString('Texte', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsStyleTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<style>body{display:none}</style><p>Texte</p>');
|
||||
|
||||
self::assertStringNotContainsString('<style>', $result);
|
||||
self::assertStringContainsString('Texte', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsIframeTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><iframe src="evil.com"></iframe>');
|
||||
|
||||
self::assertStringNotContainsString('<iframe>', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsImgTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><img src="x" onerror="alert(1)">');
|
||||
|
||||
self::assertStringNotContainsString('<img', $result);
|
||||
self::assertStringNotContainsString('onerror', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyString(): void
|
||||
{
|
||||
self::assertSame('', $this->sanitizer->sanitize(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesPlainText(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('Texte simple sans HTML');
|
||||
|
||||
self::assertSame('Texte simple sans HTML', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsJavascriptLinks(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<a href="javascript:alert(1)">Clic</a>');
|
||||
|
||||
self::assertStringNotContainsString('javascript:', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('richContentProvider')]
|
||||
public function itPreservesRichContent(string $input, string $expectedSubstring): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize($input);
|
||||
|
||||
self::assertStringContainsString($expectedSubstring, $result);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function richContentProvider(): iterable
|
||||
{
|
||||
yield 'gras' => ['<strong>gras</strong>', '<strong>gras</strong>'];
|
||||
yield 'italique' => ['<em>italique</em>', '<em>italique</em>'];
|
||||
yield 'liste à puces' => ['<ul><li>item</li></ul>', '<li>item</li>'];
|
||||
yield 'liste numérotée' => ['<ol><li>item</li></ol>', '<ol>'];
|
||||
yield 'paragraphe' => ['<p>texte</p>', '<p>texte</p>'];
|
||||
yield 'lien' => ['<a href="https://x.com">lien</a>', 'href="https://x.com"'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user