Merge branch 'develop' into feat/erp-149-idtf-sync
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

This commit is contained in:
2026-06-15 15:25:47 +00:00
15 changed files with 1018 additions and 178 deletions
+2 -2
View File
@@ -24,6 +24,7 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -95,7 +96,6 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
"symfony/browser-kit": "8.0.*"
}
}
Generated
+175 -175
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5412,6 +5412,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11785,180 +11959,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
+3
View File
@@ -12,6 +12,9 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Entites techniques partagees portant un #[ApiResource]
# (UploadedDocument — infra upload generique ERP-154).
- '%kernel.project_dir%/src/Shared/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+10
View File
@@ -50,6 +50,16 @@ doctrine:
# Shared sans importer la classe concrete du module Catalog (regle n°1).
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
mappings:
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
# Necessaire car les entites Shared ne sont pas couvertes par
# l'auto_mapping (qui ne cible que les bundles).
Shared:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
prefix: 'App\Shared\Domain\Entity'
alias: Shared
Core:
type: attribute
is_bundle: false
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.123'
app.version: '0.1.124'
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-154 — Infra d'upload de fichiers generique et reutilisable (src/Shared).
*
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
* ticket ne touche AUCUN module : la table vit cote Shared.
*
* Caracteristiques :
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
* deduplication eventuelle (hors scope ici).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
* creation de "user" sur base vide.
*
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
* `schema:update --force` reste un no-op une fois l'entite mappee.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
* l'entite UploadedDocument existe des ce ticket — `app:apply-column-comments`
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
*/
final class Version20260615130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE uploaded_document (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_path VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_uploaded_document_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Postgres n'indexe pas automatiquement les colonnes de FK.
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
// Recherche d'integrite / future deduplication par empreinte sha256.
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
*
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
* processor depuis l'utilisateur authentifie (null hors HTTP).
*
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
*
* Operations API :
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
* whitelist -> 422.
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
* cloisonnement d'acces (qui peut voir quel document) est volontairement
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
* document via sa propre ressource cloisonnee plutot que via cet endpoint
* technique. Ne renvoie que des metadonnees (jamais le binaire).
*
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
* s'applique donc pas ici.
*/
#[ORM\Entity]
#[ORM\Table(name: 'uploaded_document')]
#[ApiResource(
operations: [
new Get(
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
// Entree multipart : le binaire arrive en multipart/form-data sous
// le champ « file ». Sans cet inputFormats, API Platform rejette la
// requete en 415.
inputFormats: ['multipart' => ['multipart/form-data']],
// Le fichier n'est pas deserialisable dans l'entite : le processor
// lit le binaire de la requete. La validation est portee par le
// FileUploader (MIME server-side, taille), pas par les contraintes.
deserialize: false,
validate: false,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
processor: UploadedDocumentProcessor::class,
),
],
normalizationContext: ['groups' => ['uploaded_document:read']],
)]
class UploadedDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['uploaded_document:read'])]
private ?int $id = null;
#[ORM\Column(name: 'original_filename', length: 255)]
#[Groups(['uploaded_document:read'])]
private string $originalFilename;
#[ORM\Column(name: 'stored_path', length: 512)]
#[Groups(['uploaded_document:read'])]
private string $storedPath;
#[ORM\Column(name: 'mime_type', length: 100)]
#[Groups(['uploaded_document:read'])]
private string $mimeType;
#[ORM\Column(name: 'size_bytes', type: 'integer')]
#[Groups(['uploaded_document:read'])]
private int $sizeBytes;
#[ORM\Column(name: 'checksum', length: 64)]
#[Groups(['uploaded_document:read'])]
private string $checksum;
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['uploaded_document:read'])]
private DateTimeImmutable $createdAt;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?UserInterface $createdBy = null;
public function __construct(
string $originalFilename,
string $storedPath,
string $mimeType,
int $sizeBytes,
string $checksum,
DateTimeImmutable $createdAt,
) {
$this->originalFilename = $originalFilename;
$this->storedPath = $storedPath;
$this->mimeType = $mimeType;
$this->sizeBytes = $sizeBytes;
$this->checksum = $checksum;
$this->createdAt = $createdAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getOriginalFilename(): string
{
return $this->originalFilename;
}
public function getStoredPath(): string
{
return $this->storedPath;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function getSizeBytes(): int
{
return $this->sizeBytes;
}
public function getChecksum(): string
{
return $this->checksum;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le fichier televerse depasse la taille maximale autorisee
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
*/
final class FileTooLargeException extends FileUploadException
{
public function __construct(int $size, int $maxSize)
{
parent::__construct(sprintf(
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
$size,
$maxSize,
));
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use RuntimeException;
/**
* Exception de base des erreurs de televersement (FileUploader).
*
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
* qui la traduit en reponse HTTP 422.
*/
class FileUploadException extends RuntimeException {}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
*/
final class UnsupportedMimeTypeException extends FileUploadException
{
/**
* @param list<string> $allowed Types MIME autorises
*/
public function __construct(string $mimeType, array $allowed)
{
parent::__construct(sprintf(
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
$mimeType,
implode(', ', $allowed),
));
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Infrastructure\Upload\FileUploader;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Throwable;
/**
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
*
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
* en JSON-LD (avec son @id / IRI).
*
* Mapping des erreurs :
* - fichier absent -> 422 ;
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
*
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
*
* @implements ProcessorInterface<mixed, mixed>
*/
final class UploadedDocumentProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly FileUploader $fileUploader,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (!$file instanceof UploadedFile) {
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
}
try {
$document = $this->fileUploader->upload($file);
} catch (FileUploadException $e) {
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
// message metier explicite porte par l'exception.
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
}
$user = $this->security->getUser();
if ($user instanceof UserInterface) {
$document->setCreatedBy($user);
}
try {
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
} catch (Throwable $e) {
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
// uploaded_document correspondante, puis on relaie l'erreur.
$this->fileUploader->remove($document);
throw $e;
}
}
}
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
public static function comments(): array
{
return [
'uploaded_document' => [
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
'id' => 'Identifiant interne auto-incremente.',
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
],
'audit_log' => [
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Upload;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Service generique de televersement de fichiers (infra Shared, ERP-154).
*
* Responsabilites :
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
* sur le contenu reel) — JAMAIS `getClientMimeType()`, spoofable par le
* client (regle backend.md « Upload de fichiers »).
* - Whitelist MIME explicite (PDF + images courantes).
* - Bornage de la taille (MAX_SIZE_BYTES).
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
*
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
* UploadedDocumentProcessor.
*/
final class FileUploader
{
/**
* Types MIME autorises (detectes server-side) : PDF + images courantes.
*
* @var list<string>
*/
public const ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
/**
* Taille maximale autorisee : 10 Mo.
*/
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
public function __construct(
// Racine de stockage des fichiers televerses (hors web root, sous var/).
#[Autowire('%kernel.project_dir%/var/uploads')]
private readonly string $uploadBaseDir,
private readonly ClockInterface $clock,
) {}
/**
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
* un UploadedDocument NON persiste (le caller le persiste).
*
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
*/
public function upload(UploadedFile $file): UploadedDocument
{
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
// declare par le client.
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
}
// getSize() peut renvoyer false si le fichier est illisible.
$size = $file->getSize();
if (false === $size || $size > self::MAX_SIZE_BYTES) {
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
}
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
// on echoue proprement plutot que de propager un TypeError opaque au
// constructeur (parametre $checksum type string).
$checksum = hash_file('sha256', $file->getPathname());
if (false === $checksum) {
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
}
$now = $this->clock->now();
$relativeDir = $now->format('Y').'/'.$now->format('m');
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
// Nom de stockage genere aleatoirement : evite les collisions et toute
// injection via le nom client. Extension deduite du MIME.
$extension = $file->guessExtension() ?: 'bin';
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
// borne a la longueur de colonne (255).
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
$file->move($targetDir, $storedName);
return new UploadedDocument(
originalFilename: $originalFilename,
storedPath: $relativeDir.'/'.$storedName,
mimeType: $mimeType,
sizeBytes: $size,
checksum: $checksum,
createdAt: $now,
);
}
/**
* Supprime le fichier physique d'un document (compensation lorsque la
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
* references en base.
*/
public function remove(UploadedDocument $document): void
{
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
if (is_file($path)) {
@unlink($path);
}
}
}
@@ -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,161 @@
<?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()),
);
}
public function testRemoveDeletesStoredFile(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$document = $uploader->upload($file);
$storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath();
self::assertFileExists($storedPath);
// Compensation : remove() efface le fichier physique...
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
// ...et reste silencieux si on le rappelle alors que le fichier a disparu.
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
}
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);
}
}