feat(shared) : infra upload générique (ERP-154)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m40s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m22s

Pose une infra d'upload de fichiers générique et réutilisable dans Shared
(spec M4 § 2.7), sans toucher au module Transport.

- Table uploaded_document (migration racine DoctrineMigrations) : fichier
  téléversé immuable (PDF / images), checksum sha256, created_at/created_by.
- Service Shared\Infrastructure\Upload\FileUploader : validation MIME
  server-side via getMimeType (jamais getClientMimeType), whitelist explicite
  (PDF + images), bornage taille, checksum sha256, écriture var/uploads/{yyyy}/{mm}/.
- Endpoint POST /api/uploaded_documents (multipart, deserialize:false) +
  UploadedDocumentProcessor -> renvoie l'IRI ; MIME hors whitelist -> 422.
- COMMENT ON COLUMN sur toutes les colonnes + bloc dans ColumnCommentsCatalog.
- Mapping Doctrine Shared + path API Platform Shared.
- Tests : FileUploader (unit) + endpoint (fonctionnel, 422 / IRI / checksum).
This commit is contained in:
Matthieu
2026-06-15 16:08:24 +02:00
parent 6a83adc00a
commit b989c33cc4
12 changed files with 783 additions and 0 deletions
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Api;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests fonctionnels de l'endpoint d'upload generique (ERP-154).
*
* Couvre :
* - POST multipart d'un PDF valide -> 201, IRI renvoyee, ligne persistee,
* checksum sha256 calcule cote serveur ;
* - POST d'un MIME hors whitelist (text/plain) -> 422 ;
* - POST sans fichier -> 422 ;
* - POST anonyme -> 401 (acces /api protege globalement).
*
* @internal
*/
final class UploadedDocumentApiTest extends AbstractApiTestCase
{
private const string ENDPOINT = '/api/uploaded_documents';
/** @var list<string> */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
parent::tearDown();
}
public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$content = $this->minimalPdf();
$file = $this->makeUploadedFile($content, 'facture.pdf');
$response = $client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertArrayHasKey('@id', $data);
self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']);
self::assertSame('facture.pdf', $data['originalFilename']);
self::assertSame('application/pdf', $data['mimeType']);
self::assertSame(\strlen($content), $data['sizeBytes']);
self::assertSame(hash('sha256', $content), $data['checksum']);
self::assertSame(64, \strlen($data['checksum']));
// La ligne est bien persistee et relisible via le repository.
$id = $data['id'];
$document = $this->getEm()->getRepository(UploadedDocument::class)->find($id);
self::assertInstanceOf(UploadedDocument::class, $document);
self::assertSame(hash('sha256', $content), $document->getChecksum());
}
public function testUploadDisallowedMimeTypeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$file = $this->makeUploadedFile('just some plain text content', 'note.txt');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadWithoutFileReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => []],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadAnonymousIsRejected(): void
{
$client = self::createClient();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Upload;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use App\Shared\Infrastructure\Upload\FileUploader;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests unitaires du service generique de televersement (ERP-154).
*
* Couvre : rejet d'un MIME hors whitelist, rejet d'un fichier trop volumineux,
* et le chemin nominal (checksum sha256 calcule, taille/MIME captures, fichier
* ecrit sous var/uploads/{yyyy}/{mm}/ avec horodatage de l'horloge injectee).
*
* @internal
*/
final class FileUploaderTest extends TestCase
{
private string $uploadBaseDir;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
$this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
// Nettoyage des fichiers sources et de l'arborescence de destination.
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
$this->removeDirectory($this->uploadBaseDir);
}
public function testRejectsMimeTypeOutsideWhitelist(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile('hello world plain text', 'note.txt');
$this->expectException(UnsupportedMimeTypeException::class);
$uploader->upload($file);
}
public function testRejectsFileLargerThanMaxSize(): void
{
$uploader = $this->createUploader();
// Contenu PDF valide mais artificiellement gonfle au-dela de la borne.
$content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1);
$file = $this->makeUploadedFile($content, 'huge.pdf');
$this->expectException(FileTooLargeException::class);
$uploader->upload($file);
}
public function testStoresPdfAndComputesSha256Checksum(): void
{
$content = $this->minimalPdf();
$clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00'));
$uploader = $this->createUploader($clock);
$file = $this->makeUploadedFile($content, 'facture.pdf');
$document = $uploader->upload($file);
self::assertSame('facture.pdf', $document->getOriginalFilename());
self::assertSame('application/pdf', $document->getMimeType());
self::assertSame(\strlen($content), $document->getSizeBytes());
self::assertSame(hash('sha256', $content), $document->getChecksum());
self::assertSame(64, \strlen($document->getChecksum()));
// Chemin relatif date selon l'horloge injectee (2026/06).
self::assertStringStartsWith('2026/06/', $document->getStoredPath());
self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath());
// Le fichier ecrit a bien le contenu d'origine (checksum coherent).
self::assertSame(
$document->getChecksum(),
hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()),
);
}
private function createUploader(?MockClock $clock = null): FileUploader
{
return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock());
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
// Le 5e argument `test: true` court-circuite move_uploaded_file().
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide — l'entete `%PDF-1.4` suffit a faire detecter
* `application/pdf` par finfo (getMimeType server-side).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir) ?: [];
foreach ($items as $item) {
if ('.' === $item || '..' === $item) {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}