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:
2026-06-29 18:01:54 +02:00
parent caa558f582
commit 7075f0f95d
10 changed files with 392 additions and 157 deletions
@@ -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));
}
}