fix(catalog) : M7 — durcissement stockages (états JSONB séquentiels + Assert\Unique, neutralisation injection formules XLSX partagée, parité listing/export via StorageListFilters, streaming export)
- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique. - PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier). - StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif). - Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée. - Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Application\Filter;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Filtres de liste des stockages : SOURCE UNIQUE de verite du parsing des parametres
|
||||
* de requete (?search sur numero, ?siteId[], ?storageTypeId, ?state). Partagee par le
|
||||
* StorageProvider (liste paginee) et le StorageExportController (export XLSX) pour
|
||||
* garantir que l'export reflete EXACTEMENT ce que l'utilisateur voit a l'ecran.
|
||||
*
|
||||
* Sans cette factorisation, les deux endpoints parsaient les memes filtres avec des
|
||||
* regles subtilement differentes (numero litteral « 0 » coerce a null cote export,
|
||||
* id non positif accepte cote liste mais ignore cote export, parametre tableau
|
||||
* jetant un 400 cote export) : autant de divergences liste/export. Une seule
|
||||
* implementation -> zero drift, chaque nouveau filtre se branche en un seul endroit.
|
||||
*/
|
||||
final readonly class StorageListFilters
|
||||
{
|
||||
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
|
||||
private const array VALID_STATES = [
|
||||
Storage::STATE_RECEPTION,
|
||||
Storage::STATE_PRODUCTION,
|
||||
Storage::STATE_TRIAGE,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
private function __construct(
|
||||
public ?string $search,
|
||||
public array $siteIds,
|
||||
public ?int $storageTypeId,
|
||||
public ?string $state,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Construit les filtres depuis une source brute : le `$context['filters']`
|
||||
* d'API Platform cote provider, ou `$request->query->all()` cote controller
|
||||
* d'export. Tolere scalaire ou tableau, ignore les entrees invalides — jamais
|
||||
* d'exception sur une saisie malformee (ex: `?search[]=x`).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
public static function fromQuery(array $query): self
|
||||
{
|
||||
return new self(
|
||||
self::readSearch($query['search'] ?? null),
|
||||
self::readSiteIds($query['siteId'] ?? null),
|
||||
self::readPositiveInt($query['storageTypeId'] ?? null),
|
||||
self::readState($query['state'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche partielle sur numero : valeur trimmee, ou null si absente / vide.
|
||||
* La chaine « 0 » est un numero valide (VARCHAR) et N'EST PAS coercee a null.
|
||||
*/
|
||||
private static function readSearch(mixed $raw): ?string
|
||||
{
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste d'identifiants de sites (OR). Tolere une valeur scalaire unique
|
||||
* (`?siteId=1`) ou un tableau (`?siteId[]=1&siteId[]=2`), dedup, ordre stable.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function readSiteIds(mixed $raw): array
|
||||
{
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
$id = self::readPositiveInt($value);
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiant entier STRICTEMENT POSITIF (un id metier l'est toujours) ou null.
|
||||
* Un 0 ou un negatif est traite comme « pas de filtre », jamais comme un id
|
||||
* impossible (qui renverrait une liste vide cote provider mais tout cote export).
|
||||
*/
|
||||
private static function readPositiveInt(mixed $raw): ?int
|
||||
{
|
||||
if (is_int($raw)) {
|
||||
return $raw > 0 ? $raw : null;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre ?state= : normalise en majuscules, n'accepte qu'une valeur de l'enum
|
||||
* borne {RECEPTION, PRODUCTION, TRIAGE} ; toute autre valeur est ignoree (null).
|
||||
*/
|
||||
private static function readState(mixed $raw): ?string
|
||||
{
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, self::VALID_STATES, true) ? $state : null;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ class Storage implements TimestampableInterface, BlamableInterface
|
||||
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
|
||||
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
|
||||
#[Assert\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')]
|
||||
#[Assert\Choice(
|
||||
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
|
||||
multiple: true,
|
||||
@@ -230,7 +231,12 @@ class Storage implements TimestampableInterface, BlamableInterface
|
||||
*/
|
||||
public function setStates(array $states): static
|
||||
{
|
||||
$this->states = $states;
|
||||
// `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente
|
||||
// malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif)
|
||||
// ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le
|
||||
// CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en
|
||||
// 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique.
|
||||
$this->states = array_values($states);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Application\Filter\StorageListFilters;
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
@@ -22,8 +22,9 @@ use function is_string;
|
||||
* Provider Storage (lecture, ERP-213) :
|
||||
* - LISTE : exclut par defaut les stockages soft-deleted (RG-7.07), trie par
|
||||
* site.code ASC, storageType.label ASC, numero ASC (defaut spec § 4.1), applique
|
||||
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state) et renvoie
|
||||
* une collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
|
||||
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state — parses par
|
||||
* {@see StorageListFilters}, source partagee avec l'export) et renvoie une
|
||||
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
|
||||
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
|
||||
* Echappatoire ?pagination=false respectee (alimentation d'un select).
|
||||
* - ITEM : recharge le stockage puis renvoie null (404) s'il est soft-deleted — le
|
||||
@@ -33,13 +34,6 @@ use function is_string;
|
||||
*/
|
||||
final class StorageProvider implements ProviderInterface
|
||||
{
|
||||
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
|
||||
private const array VALID_STATES = [
|
||||
Storage::STATE_RECEPTION,
|
||||
Storage::STATE_PRODUCTION,
|
||||
Storage::STATE_TRIAGE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
|
||||
private readonly StorageRepositoryInterface $repository,
|
||||
@@ -49,13 +43,16 @@ final class StorageProvider implements ProviderInterface
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
// Filtres parses par la source partagee avec l'export (parite garantie).
|
||||
$filters = StorageListFilters::fromQuery($context['filters'] ?? []);
|
||||
|
||||
// includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
false,
|
||||
$this->readSearch($context),
|
||||
$this->readSiteIds($context),
|
||||
$this->readStorageTypeId($context),
|
||||
$this->readState($context),
|
||||
$filters->search,
|
||||
$filters->siteIds,
|
||||
$filters->storageTypeId,
|
||||
$filters->state,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator.
|
||||
@@ -93,80 +90,4 @@ final class StorageProvider implements ProviderInterface
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?search=` (recherche partielle sur numero). Renvoie la valeur
|
||||
* trimmee ou null si absente / vide.
|
||||
*/
|
||||
private function readSearch(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['search'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readSiteIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['siteId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?storageTypeId=` (drawer « Filtrer »). Renvoie l'id entier ou
|
||||
* null si absent / non numerique.
|
||||
*/
|
||||
private function readStorageTypeId(array $context): ?int
|
||||
{
|
||||
$raw = $context['filters']['storageTypeId'] ?? null;
|
||||
|
||||
if (is_int($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` (RECEPTION / PRODUCTION / TRIAGE). Normalise en
|
||||
* majuscules et n'accepte qu'une valeur de l'enum borne ; toute autre valeur est
|
||||
* ignoree (null).
|
||||
*/
|
||||
private function readState(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['state'] ?? null;
|
||||
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, self::VALID_STATES, true) ? $state : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Catalog\Application\Filter\StorageListFilters;
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,8 +19,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Export XLSX de la liste des stockages (M7, spec-back § 4.5). Jumeau du
|
||||
@@ -57,10 +57,17 @@ final class StorageExportController
|
||||
Storage::STATE_TRIAGE => 'Triage',
|
||||
];
|
||||
|
||||
/**
|
||||
* Taille du lot avant `EntityManager::clear()` pendant le streaming des lignes :
|
||||
* borne la memoire (identity map) sur un gros export sans tout materialiser.
|
||||
*/
|
||||
private const int EXPORT_BATCH_SIZE = 200;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
|
||||
private readonly StorageRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
@@ -69,18 +76,18 @@ final class StorageExportController
|
||||
{
|
||||
// Memes filtres que la vue liste (StorageProvider) pour que l'export reflete
|
||||
// exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur
|
||||
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state).
|
||||
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state). Parses par
|
||||
// la MEME source que le provider ({@see StorageListFilters}) -> aucune
|
||||
// divergence possible (numero « 0 », parametre tableau, id non positif).
|
||||
// includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8).
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
|
||||
$storageTypeId = $this->readIntOrNull($request->query->get('storageTypeId'));
|
||||
$state = $this->readState($request->query->get('state'));
|
||||
$filters = StorageListFilters::fromQuery($request->query->all());
|
||||
|
||||
/** @var list<Storage> $storages */
|
||||
// Streaming via toIterable() : on ne materialise pas toute la table en memoire
|
||||
// (cf. buildRows + EXPORT_BATCH_SIZE) avant de construire le classeur.
|
||||
$storages = $this->repository
|
||||
->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state)
|
||||
->createListQueryBuilder(false, $filters->search, $filters->siteIds, $filters->storageTypeId, $filters->state)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
->toIterable()
|
||||
;
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
@@ -111,12 +118,18 @@ final class StorageExportController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Storage> $storages
|
||||
* Mappe chaque stockage en ligne d'export, en consommant un iterable paresseux
|
||||
* (Doctrine `toIterable()`). Toutes les N lignes (EXPORT_BATCH_SIZE), on vide
|
||||
* l'identity map (`clear()`) pour borner la memoire sur un gros export — sans
|
||||
* danger ici, le controller ne fait que lire.
|
||||
*
|
||||
* @param iterable<Storage> $storages
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $storages): iterable
|
||||
private function buildRows(iterable $storages): iterable
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($storages as $storage) {
|
||||
yield [
|
||||
$storage->getDisplayName(),
|
||||
@@ -127,6 +140,10 @@ final class StorageExportController
|
||||
$this->formatDate($storage->getCreatedAt()),
|
||||
$this->formatDate($storage->getUpdatedAt()),
|
||||
];
|
||||
|
||||
if (0 === ++$count % self::EXPORT_BATCH_SIZE) {
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,53 +199,4 @@ final class StorageExportController
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` comme le StorageProvider : normalise en majuscules et
|
||||
* n'accepte qu'une valeur de l'enum borne {RECEPTION, PRODUCTION, TRIAGE} ;
|
||||
* toute autre valeur est ignoree (null).
|
||||
*/
|
||||
private function readState(mixed $raw): ?string
|
||||
{
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un identifiant entier positif unique (`?storageTypeId=`). Aligne sur
|
||||
* StorageProvider (tolere int ou chaine numerique).
|
||||
*/
|
||||
private function readIntOrNull(mixed $raw): ?int
|
||||
{
|
||||
if (is_int($raw)) {
|
||||
return $raw > 0 ? $raw : null;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste, `?siteId[]=`). Aligne sur StorageProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Shared\Infrastructure\Export;
|
||||
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
|
||||
*
|
||||
@@ -31,19 +36,45 @@ final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
|
||||
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
|
||||
|
||||
// Ligne 1 : en-tete.
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
$this->writeRow($sheet, $headers, 1);
|
||||
|
||||
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
|
||||
// paresseux (generator) sans tout materialiser en memoire.
|
||||
$rowNumber = 2;
|
||||
foreach ($rows as $row) {
|
||||
$sheet->fromArray($row, null, 'A'.$rowNumber);
|
||||
$this->writeRow($sheet, $row, $rowNumber);
|
||||
++$rowNumber;
|
||||
}
|
||||
|
||||
return $this->toBinary($spreadsheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ecrit une ligne cellule par cellule. Toute valeur CHAINE est ecrite en type
|
||||
* STRING explicite (jamais interpretee comme formule), ce qui neutralise
|
||||
* l'injection de formules / DDE (« CSV / Formula injection ») : une cellule dont
|
||||
* la valeur commence par `=` `+` `-` `@` (saisie utilisateur, ex. un numero) n'est
|
||||
* pas evaluee a l'ouverture du fichier, et ce SANS apostrophe visible. Les valeurs
|
||||
* non-chaines (int / float / null) gardent leur type naturel.
|
||||
*
|
||||
* @param list<null|scalar> $row
|
||||
*/
|
||||
private function writeRow(Worksheet $sheet, array $row, int $rowNumber): void
|
||||
{
|
||||
$column = 1;
|
||||
foreach ($row as $value) {
|
||||
$coordinate = Coordinate::stringFromColumnIndex($column).$rowNumber;
|
||||
|
||||
if (is_string($value)) {
|
||||
$sheet->setCellValueExplicit($coordinate, $value, DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue($coordinate, $value);
|
||||
}
|
||||
|
||||
++$column;
|
||||
}
|
||||
}
|
||||
|
||||
private function toBinary(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
|
||||
@@ -92,6 +92,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
Assert\NotNull::class,
|
||||
Assert\Email::class,
|
||||
Assert\Choice::class,
|
||||
Assert\Unique::class,
|
||||
Assert\Regex::class,
|
||||
Assert\Bic::class,
|
||||
Assert\Iban::class,
|
||||
|
||||
@@ -138,6 +138,50 @@ final class StorageExportControllerTest extends AbstractStorageApiTestCase
|
||||
self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]);
|
||||
}
|
||||
|
||||
public function testFormulaInjectionIsNeutralized(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Numero malicieux commencant par « = » (injection de formule / DDE). Seede en
|
||||
// direct (le numero contournerait de toute facon le normalizer, qui ne fait
|
||||
// qu'un trim). L'export doit le restituer comme TEXTE litteral, jamais comme
|
||||
// une formule evaluee : si la cellule etait une formule, IOFactory::load la
|
||||
// calculerait (resultat 3 ou erreur) et « =1+2 » serait absent de la colonne.
|
||||
$this->seedStorageEntity('=1+2');
|
||||
|
||||
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('=1+2', $numeros, 'Le numero « =1+2 » doit etre stocke en texte, pas evalue.');
|
||||
}
|
||||
|
||||
public function testExportKeepsSearchTermZero(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedStorageEntity('0');
|
||||
$this->seedStorageEntity('X1');
|
||||
|
||||
// « 0 » est un numero valide : le filtre ?search=0 NE DOIT PAS etre coerce a
|
||||
// null (parite stricte avec la liste a l'ecran via StorageListFilters).
|
||||
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL.'?search=0')->getContent());
|
||||
|
||||
self::assertContains('0', $numeros);
|
||||
self::assertNotContains('X1', $numeros);
|
||||
}
|
||||
|
||||
public function testExportToleratesArrayShapedScalarParam(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedStorageEntity('NUM-ARR');
|
||||
|
||||
// ?search[]=foo : parametre tableau la ou un scalaire est attendu. L'export ne
|
||||
// doit pas planter en 400 (la liste le tolere) : la valeur est simplement
|
||||
// ignoree -> 200 avec tous les stockages.
|
||||
$response = $client->request('GET', self::EXPORT_URL.'?search[]=foo');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertContains('NUM-ARR', $this->numeros($response->getContent()));
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutStoragesViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
|
||||
@@ -80,6 +80,15 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
|
||||
self::assertArrayHasKey('displayName', $row);
|
||||
self::assertSame('Cellule '.$numero, $row['displayName']);
|
||||
|
||||
// === Piege #5 : le soft-delete n'est JAMAIS expose (§ 2.8) ===
|
||||
// `deletedAt` n'appartient a aucun groupe de lecture : un test « contrat » doit
|
||||
// garantir son ABSENCE, pas seulement la presence des champs attendus — sinon
|
||||
// un ajout accidentel a storage:read passerait au vert. (createdBy/updatedBy
|
||||
// sont, eux, exposes a dessein via la convention `default:read` du Trait
|
||||
// Timestampable/Blamable — au meme titre que createdAt/updatedAt.)
|
||||
self::assertArrayNotHasKey('deletedAt', $row, 'deletedAt ne doit pas etre expose en liste (§ 2.8).');
|
||||
self::assertArrayNotHasKey('deletedAt', $detail, 'deletedAt ne doit pas etre expose en detail (§ 2.8).');
|
||||
|
||||
// === DETAIL : memes garanties d'embarquement ===
|
||||
self::assertIsArray($detail['site']);
|
||||
self::assertArrayHasKey('name', $detail['site']);
|
||||
@@ -93,10 +102,14 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
|
||||
|
||||
/**
|
||||
* RG-7.07 : la liste (et le detail) n'exposent JAMAIS un stockage soft-deleted.
|
||||
* On seede 1 actif + 1 supprime pour que l'assertion de liste soit discriminante
|
||||
* (sinon, avec une collection vide, « absent » ne distingue pas l'exclusion du
|
||||
* soft-delete d'une page vide).
|
||||
*/
|
||||
public function testSoftDeletedIsNotExposed(): void
|
||||
{
|
||||
$deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable());
|
||||
$active = $this->seedStorageEntity('SD-ACTIVE');
|
||||
$deleted = $this->seedStorageEntity('SD-DELETED', deletedAt: new DateTimeImmutable());
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
@@ -104,9 +117,10 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
|
||||
$client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
|
||||
// … et absent de la collection (RG-7.07).
|
||||
// Collection : l'actif est present, le supprime est absent (RG-7.07).
|
||||
$list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertNull($this->memberById($list, (int) $deleted->getId()));
|
||||
self::assertNotNull($this->memberById($list, (int) $active->getId()), 'Le stockage actif doit etre liste.');
|
||||
self::assertNull($this->memberById($list, (int) $deleted->getId()), 'Le stockage soft-deleted ne doit pas etre liste.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,4 +72,40 @@ final class StorageStatesValidationTest extends AbstractStorageApiTestCase
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('states', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testDuplicateStatesAreRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Doublon dans le multi-select : 422 (Assert\Unique), pas un stockage avec un
|
||||
// tableau d'etats incoherent (RG-7.04 = sous-ensemble).
|
||||
$response = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload([
|
||||
'states' => [Storage::STATE_TRIAGE, Storage::STATE_TRIAGE],
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('states', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testNonSequentialStatesDoNotCrash(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// `states` envoye comme OBJET JSON (cle non sequentielle) : auparavant
|
||||
// persiste tel quel en JSONB objet -> le CHECK jsonb_array_length plantait en
|
||||
// 500. Doit desormais etre renormalise en liste sequentielle (array_values du
|
||||
// setter), donc accepte proprement sans 500.
|
||||
$created = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload([
|
||||
'states' => [7 => Storage::STATE_RECEPTION],
|
||||
]),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame([Storage::STATE_RECEPTION], $created['states']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Validation et normalisation serveur a l'ecriture du stockage (M7, POST / PATCH) :
|
||||
* - RG-7.06 : le numero est trimme cote serveur (et SANS changement de casse) ;
|
||||
* - numero vide -> 422 (Assert\NotBlank) sur `numero` ;
|
||||
* - relation nulle (site / storageType) -> 422 (Assert\NotNull, via le chemin de
|
||||
* denormalisation `collectDenormalizationErrors`) portant le bon propertyPath, et
|
||||
* NON un 400 qui court-circuiterait le mapping inline front (useFormErrors,
|
||||
* ERP-101).
|
||||
*
|
||||
* Pendant ces RG, le contrat de violation 422 (propertyPath aligne sur le champ
|
||||
* front) est ce que le front consomme : on l'asserte explicitement.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class StorageWriteValidationTest extends AbstractStorageApiTestCase
|
||||
{
|
||||
public function testNumeroIsTrimmedServerSide(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// RG-7.06 : numero saisi avec des espaces autour -> stocke trimme.
|
||||
$created = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload(['numero' => ' A1 ']),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('A1', $created['numero'], 'Le numero doit etre trimme cote serveur (RG-7.06).');
|
||||
|
||||
// Relecture : la normalisation est bien persistee, pas seulement reflechie.
|
||||
$detail = $client->request('GET', '/api/storages/'.$created['id'], [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
self::assertSame('A1', $detail['numero']);
|
||||
}
|
||||
|
||||
public function testBlankNumeroIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload(['numero' => ' ']),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('numero', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testNullSiteReturns422WithPropertyPath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Relation obligatoire a null : doit ressortir en 422 (NotNull) avec un
|
||||
// propertyPath `site`, pas en 400 (collectDenormalizationErrors).
|
||||
$response = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload(['site' => null]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('site', $this->violationPaths($response));
|
||||
}
|
||||
|
||||
public function testNullStorageTypeReturns422WithPropertyPath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/storages', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validStoragePayload(['storageType' => null]),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertContains('storageType', $this->violationPaths($response));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user